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,516 @@
1
+ """Deterministic, stdlib-only producer of ``review-manifest.json``.
2
+
3
+ This module is the review-cycle *runner*: it projects declared finding state
4
+ (from a compact ``findings-final.json`` sidecar) into the manifest schema that
5
+ ``agentflow record-review`` consumes. It never parses ``findings-final.yaml``
6
+ and never adjudicates a finding's verdict — adjudication stays in the review
7
+ system; this module only removes the manual transcription/computation steps the
8
+ #8 dogfood proved error-prone. It deliberately lives outside ``review.py`` (the
9
+ YAML-blind verifier), importing only contract constants from it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import json
16
+ import secrets
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+
21
+ from .git import current_branch
22
+ from .review import (
23
+ SEVERITIES,
24
+ STATUSES,
25
+ WORKFLOW_REVIEW_DEPTHS,
26
+ validate_manifest,
27
+ validate_manifest_against_plan,
28
+ )
29
+
30
+ MANIFEST_SCHEMA_VERSION = "1.0.0"
31
+ MANIFEST_FILENAME = "review-manifest.json"
32
+
33
+ # status values that make a finding inactive (never block, never warn)
34
+ INACTIVE_STATUSES = frozenset({"fixed", "rejected", "superseded"})
35
+ ACTIVE_STATUSES = frozenset(STATUSES) - INACTIVE_STATUSES
36
+
37
+ # Optional per-finding fields carried from the sidecar into the manifest index.
38
+ OPTIONAL_INDEX_FIELDS = ("steelman_verdict", "superseded_by", "fix_commit")
39
+
40
+
41
+ def _non_empty_string(value: Any) -> bool:
42
+ return isinstance(value, str) and bool(value.strip())
43
+
44
+
45
+ def _validate_repair_context(finding: Dict[str, Any], required: bool) -> None:
46
+ fid = finding["id"]
47
+ refs = finding.get("agentflow_refs")
48
+ owner = refs.get("plan_step") if isinstance(refs, dict) else None
49
+ for field, value in (
50
+ ("claim", finding.get("claim")),
51
+ ("suggested_fix", finding.get("suggested_fix")),
52
+ ("agentflow_refs.plan_step", owner),
53
+ ):
54
+ if value is None:
55
+ if required:
56
+ raise ValueError(f"finding {fid} {field} must be a non-empty string")
57
+ elif not _non_empty_string(value):
58
+ raise ValueError(f"finding {fid} {field} must be a non-empty string")
59
+
60
+ file_value = finding.get("file")
61
+ line = finding.get("line")
62
+ line_end = finding.get("line_end")
63
+ if file_value is not None and not _non_empty_string(file_value):
64
+ raise ValueError(f"finding {fid} file must be a non-empty string")
65
+ if (line is not None or line_end is not None) and file_value is None:
66
+ raise ValueError(f"finding {fid} line requires file")
67
+ for field, value in (("line", line), ("line_end", line_end)):
68
+ if value is not None and (isinstance(value, bool) or not isinstance(value, int) or value < 1):
69
+ raise ValueError(f"finding {fid} {field} must be a positive integer")
70
+ if line_end is not None and line is None:
71
+ raise ValueError(f"finding {fid} line_end requires line")
72
+ if line is not None and line_end is not None and line_end < line:
73
+ raise ValueError(f"finding {fid} line_end must be greater than or equal to line")
74
+
75
+ # Required review artifacts. The runner fails closed when any is absent so a
76
+ # manifest cannot be recorded with only the compact JSON sidecar hashed.
77
+ REQUIRED_ARTIFACTS = (
78
+ "findings-final.json",
79
+ "findings-final.yaml",
80
+ "synthesis.md",
81
+ "gate.yaml",
82
+ )
83
+
84
+ # Extra required artifacts (beyond the findings sidecar) per declared depth.
85
+ # Monotonic superset: each deeper profile requires everything the lighter ones
86
+ # do. ``deep`` reproduces the historical four-pass requirement exactly.
87
+ DEPTH_REQUIRED_ARTIFACTS = {
88
+ "none": (),
89
+ "light": (),
90
+ "standard": (),
91
+ "spec_quality": ("gate.yaml",),
92
+ "deep": REQUIRED_ARTIFACTS[1:], # findings-final.yaml, synthesis.md, gate.yaml
93
+ }
94
+
95
+ # Deterministic artifact order. Optional names are listed only when present;
96
+ # paths are relative to state_dir (matching review.normalize_artifact_path).
97
+ KNOWN_ARTIFACTS = (
98
+ "findings-final.json",
99
+ "findings-final.yaml",
100
+ "findings-bp.yaml",
101
+ "findings-adv.yaml",
102
+ "synthesis.md",
103
+ "gate.yaml",
104
+ "ready-for-pr.md",
105
+ )
106
+
107
+
108
+ def mint_review_run_id() -> str:
109
+ """Mint a fresh ``RR-<YYYYMMDDTHHMMSSZ>-<8hex>`` review run id."""
110
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
111
+ return f"RR-{stamp}-{secrets.token_hex(4)}"
112
+
113
+
114
+ def load_findings(path: Path) -> List[Dict[str, Any]]:
115
+ """Load and validate the compact ``findings-final.json`` sidecar.
116
+
117
+ Expects ``{"findings": [ {id, severity, status, ...}, ... ]}``. Fails closed
118
+ with ValueError on a missing/unreadable file, a non-list ``findings``,
119
+ duplicate ids, non-string optional fields, or any row missing a non-empty
120
+ ``id`` or carrying an out-of-enum severity/status.
121
+ """
122
+ try:
123
+ raw = json.loads(path.read_text(encoding="utf-8"))
124
+ except OSError as exc:
125
+ raise ValueError(f"unreadable findings sidecar: {exc}") from exc
126
+ except json.JSONDecodeError as exc:
127
+ raise ValueError(f"malformed findings sidecar JSON: {exc}") from exc
128
+ if not isinstance(raw, dict):
129
+ raise ValueError("findings sidecar must be a JSON object")
130
+ findings = raw.get("findings")
131
+ if not isinstance(findings, list):
132
+ raise ValueError("findings sidecar 'findings' must be a list")
133
+ seen_ids = set()
134
+ for index, row in enumerate(findings, start=1):
135
+ if not isinstance(row, dict):
136
+ raise ValueError(f"finding {index} must be an object")
137
+ fid = row.get("id")
138
+ if not isinstance(fid, str) or not fid.strip():
139
+ raise ValueError(f"finding {index} missing a non-empty id")
140
+ if fid in seen_ids:
141
+ raise ValueError(f"duplicate finding id: {fid}")
142
+ seen_ids.add(fid)
143
+ if row.get("severity") not in SEVERITIES:
144
+ raise ValueError(f"finding {fid} severity invalid: {row.get('severity')!r}")
145
+ if row.get("status") not in STATUSES:
146
+ raise ValueError(f"finding {fid} status invalid: {row.get('status')!r}")
147
+ for field in OPTIONAL_INDEX_FIELDS:
148
+ value = row.get(field)
149
+ if value is not None and not isinstance(value, str):
150
+ raise ValueError(f"finding {fid} {field} must be a string")
151
+ _validate_repair_context(row, row["status"] in ACTIVE_STATUSES)
152
+ return findings
153
+
154
+
155
+ def load_policy_config(path: Path) -> Dict[str, Any]:
156
+ """Load the machine policy source (``docs/ai/config.json``) with stdlib json."""
157
+ try:
158
+ config = json.loads(path.read_text(encoding="utf-8"))
159
+ except OSError as exc:
160
+ raise ValueError(f"unreadable policy config: {exc}") from exc
161
+ except json.JSONDecodeError as exc:
162
+ raise ValueError(f"malformed policy config JSON: {exc}") from exc
163
+ if not isinstance(config, dict):
164
+ raise ValueError("policy config must be a JSON object")
165
+ if not isinstance(config.get("branch_modifiers"), dict):
166
+ raise ValueError("policy config missing 'branch_modifiers' object")
167
+ if not isinstance(config.get("gate_policy"), dict):
168
+ raise ValueError("policy config missing 'gate_policy' object")
169
+ return config
170
+
171
+
172
+ def resolve_gate_policy(
173
+ config: Dict[str, Any], branch: str
174
+ ) -> Tuple[str, List[str], List[str]]:
175
+ """Resolve a branch ref to ``(gate_name, blocks_on, warns_on)``.
176
+
177
+ Matches ``branch`` against ``branch_modifiers`` globs with fnmatchcase so
178
+ behavior is deterministic across platforms. The most specific match wins:
179
+ any non-``*`` match (longest pattern) is preferred over the ``*`` default.
180
+ Raises ValueError if no ``*`` default exists or the named gate is absent
181
+ from ``gate_policy``.
182
+ """
183
+ modifiers = config["branch_modifiers"]
184
+ normalized_branch = _normalize_branch_ref(branch)
185
+ specific = [
186
+ p for p in modifiers if p != "*" and fnmatch.fnmatchcase(normalized_branch, p)
187
+ ]
188
+ if specific:
189
+ pattern = max(specific, key=len)
190
+ elif "*" in modifiers:
191
+ pattern = "*"
192
+ else:
193
+ raise ValueError("branch_modifiers has no '*' default")
194
+ # Only the ``gate`` key is consulted here. Other modifier keys (e.g.
195
+ # ``require_hotfix_debt``) are intentionally ignored: branch-debt and
196
+ # readiness concerns are owned by the pass-4 ``gate.yaml``, not this
197
+ # deterministic finding-policy projection.
198
+ modifier = modifiers[pattern]
199
+ if not isinstance(modifier, dict):
200
+ raise ValueError(f"branch modifier {pattern!r} must be an object")
201
+ gate_name = modifier.get("gate")
202
+ if not isinstance(gate_name, str) or not gate_name.strip():
203
+ raise ValueError(f"branch modifier {pattern!r} missing non-empty gate")
204
+ policy = config["gate_policy"].get(gate_name)
205
+ if not isinstance(policy, dict):
206
+ raise ValueError(f"no gate_policy for gate {gate_name!r}")
207
+ blocks_on = _policy_severities(gate_name, policy, "blocks_on")
208
+ warns_on = _policy_severities(gate_name, policy, "warns_on")
209
+ return gate_name, blocks_on, warns_on
210
+
211
+
212
+ def _normalize_branch_ref(branch: str) -> str:
213
+ """Normalize common full/remote branch refs before policy glob matching."""
214
+ prefixes = (
215
+ "refs/heads/",
216
+ "refs/remotes/",
217
+ "remotes/",
218
+ )
219
+ normalized = branch.strip()
220
+ for prefix in prefixes:
221
+ if normalized.startswith(prefix):
222
+ normalized = normalized[len(prefix):]
223
+ break
224
+ if normalized.startswith("origin/"):
225
+ normalized = normalized[len("origin/"):]
226
+ return normalized
227
+
228
+
229
+ def _policy_severities(gate_name: str, policy: Dict[str, Any], field: str) -> List[str]:
230
+ value = policy.get(field, [])
231
+ if not isinstance(value, list):
232
+ raise ValueError(f"gate_policy {gate_name!r} {field} must be a list")
233
+ severities: List[str] = []
234
+ for item in value:
235
+ if not isinstance(item, str) or item not in SEVERITIES:
236
+ raise ValueError(
237
+ f"gate_policy {gate_name!r} {field} contains invalid severity: {item!r}"
238
+ )
239
+ severities.append(item)
240
+ return severities
241
+
242
+
243
+ def compute_gate(
244
+ findings: List[Dict[str, Any]],
245
+ blocks_on: List[str],
246
+ warns_on: List[str],
247
+ ) -> Tuple[str, List[str]]:
248
+ """Compute ``(gate_status, active_blocking)`` over FINAL finding severity.
249
+
250
+ Only active findings (status not in ``INACTIVE_STATUSES``) count. A finding
251
+ whose severity is in ``blocks_on`` is blocking; one whose severity is in
252
+ ``warns_on`` contributes a warn. ``gate_status`` is fail > warn > pass.
253
+ ``active_blocking`` preserves input order.
254
+ """
255
+ blocking: List[str] = []
256
+ warned = False
257
+ for finding in findings:
258
+ if finding["status"] in INACTIVE_STATUSES:
259
+ continue
260
+ severity = finding["severity"]
261
+ if severity in blocks_on:
262
+ blocking.append(finding["id"])
263
+ elif severity in warns_on:
264
+ warned = True
265
+ if blocking:
266
+ return "fail", blocking
267
+ if warned:
268
+ return "warn", []
269
+ return "pass", []
270
+
271
+
272
+ def project_findings(findings: List[Dict[str, Any]]) -> Dict[str, Any]:
273
+ """Project findings into ``counts_by_severity``/``counts_by_status``/``index``.
274
+
275
+ Counts cover all findings; the index lists every finding with its id,
276
+ severity, and status, plus any non-empty optional fields
277
+ (``steelman_verdict``, ``superseded_by``, ``fix_commit``).
278
+ """
279
+ counts_by_severity: Dict[str, int] = {}
280
+ counts_by_status: Dict[str, int] = {}
281
+ index: List[Dict[str, Any]] = []
282
+ for finding in findings:
283
+ severity = finding["severity"]
284
+ status = finding["status"]
285
+ counts_by_severity[severity] = counts_by_severity.get(severity, 0) + 1
286
+ counts_by_status[status] = counts_by_status.get(status, 0) + 1
287
+ row: Dict[str, Any] = {
288
+ "finding_id": finding["id"],
289
+ "severity": severity,
290
+ "status": status,
291
+ }
292
+ for field in OPTIONAL_INDEX_FIELDS:
293
+ value = finding.get(field)
294
+ if value:
295
+ row[field] = value
296
+ refs = finding.get("agentflow_refs")
297
+ owner = refs.get("plan_step") if isinstance(refs, dict) else None
298
+ if owner:
299
+ row["owning_step"] = owner
300
+ if finding.get("claim"):
301
+ row["claim"] = finding["claim"]
302
+ if finding.get("file"):
303
+ location: Dict[str, Any] = {"path": finding["file"]}
304
+ for field in ("line", "line_end"):
305
+ if finding.get(field) is not None:
306
+ location[field] = finding[field]
307
+ row["location"] = location
308
+ if finding.get("suggested_fix"):
309
+ row["suggested_fix"] = finding["suggested_fix"]
310
+ index.append(row)
311
+ return {
312
+ "counts_by_severity": counts_by_severity,
313
+ "counts_by_status": counts_by_status,
314
+ "index": index,
315
+ }
316
+
317
+
318
+ def build_manifest(
319
+ review_run_id: str,
320
+ state_dir: str,
321
+ policy_name: str,
322
+ gate_status: str,
323
+ active_blocking: List[str],
324
+ projection: Dict[str, Any],
325
+ artifacts: List[Dict[str, str]],
326
+ depth_profile: str = "deep",
327
+ ) -> Dict[str, Any]:
328
+ """Assemble a ``review-manifest.json`` object and self-validate the contract.
329
+
330
+ Raises ValueError (via ``validate_manifest``) if the assembled object does
331
+ not satisfy the manifest contract the verifier enforces.
332
+ """
333
+ manifest: Dict[str, Any] = {
334
+ "schema_version": MANIFEST_SCHEMA_VERSION,
335
+ "review_run_id": review_run_id,
336
+ "state_dir": state_dir,
337
+ "policy": policy_name,
338
+ "gate_status": gate_status,
339
+ "active_blocking": list(active_blocking),
340
+ "depth_profile": depth_profile,
341
+ "amendment_ready": True,
342
+ "findings": projection,
343
+ "artifacts": artifacts,
344
+ }
345
+ validate_manifest(manifest)
346
+ return manifest
347
+
348
+
349
+ def build_artifacts(
350
+ state_dir_abs: Path,
351
+ findings_artifact: str = "findings-final.json",
352
+ required_extra: Tuple[str, ...] = REQUIRED_ARTIFACTS[1:],
353
+ ) -> List[Dict[str, str]]:
354
+ """List review artifacts in ``state_dir_abs``, fixed order.
355
+
356
+ Raises ValueError if any required artifact is missing. ``required_extra``
357
+ is the non-sidecar artifact set the declared depth demands (default: the
358
+ deep four-pass set). Returns ``[{"path": name}, ...]`` for each name in
359
+ ``KNOWN_ARTIFACTS`` that exists as a file. Paths are relative to state_dir,
360
+ which is what ``review.normalize_artifact_path`` resolves against
361
+ ``root/state_dir``.
362
+ """
363
+ required = (findings_artifact, *required_extra)
364
+ for name in required:
365
+ if not (state_dir_abs / name).is_file():
366
+ raise ValueError(f"required review artifact missing: {name}")
367
+ artifacts: List[Dict[str, str]] = []
368
+ seen = set()
369
+ for name in (findings_artifact, *KNOWN_ARTIFACTS):
370
+ if name in seen:
371
+ continue
372
+ seen.add(name)
373
+ if (state_dir_abs / name).is_file():
374
+ artifacts.append({"path": name})
375
+ return artifacts
376
+
377
+
378
+ def _resolve_state_dir(root: Path, state_dir: str) -> Tuple[Path, str]:
379
+ """Return ``(abs_path, root_relative_posix)`` for ``state_dir``; reject escapes."""
380
+ resolved_root = root.resolve()
381
+ state_abs = (resolved_root / state_dir).resolve(strict=False)
382
+ try:
383
+ rel = state_abs.relative_to(resolved_root).as_posix()
384
+ except ValueError as exc:
385
+ raise ValueError(f"state_dir escapes root: {state_dir!r}") from exc
386
+ return state_abs, rel
387
+
388
+
389
+ def _state_dir_hint(findings_json: Optional[str], state_dir: str) -> Optional[str]:
390
+ """Advice for a not-found/escaping relative ``--findings-json``.
391
+
392
+ Returns ``None`` for the default (``None``) or an absolute path — those are
393
+ never re-rooted, so the state-dir advice would mislead. Otherwise returns a
394
+ hint that the path is state-dir-relative, plus a "did you mean" suggestion
395
+ when the path's parts start with the ``--state-dir`` parts (the doubling
396
+ case). Compares path parts, not raw string prefixes, so ``.../main2/...`` is
397
+ not treated as living under ``.../main``.
398
+ """
399
+ if not findings_json:
400
+ return None
401
+ candidate = Path(findings_json)
402
+ if candidate.is_absolute():
403
+ return None
404
+ hint = " (--findings-json is resolved relative to --state-dir)"
405
+ state_parts = Path(state_dir).parts
406
+ cand_parts = candidate.parts
407
+ if (
408
+ len(cand_parts) > len(state_parts)
409
+ and cand_parts[: len(state_parts)] == state_parts
410
+ ):
411
+ stripped = Path(*cand_parts[len(state_parts):]).as_posix()
412
+ hint += f"; did you mean --findings-json {stripped}?"
413
+ return hint
414
+
415
+
416
+ def _resolve_findings_json(state_abs: Path, findings_json: Optional[str]) -> Tuple[Path, str]:
417
+ """Return ``(abs_path, state_relative_posix)`` for the sidecar; reject escapes."""
418
+ if findings_json is None:
419
+ return state_abs / "findings-final.json", "findings-final.json"
420
+ if not findings_json.strip():
421
+ raise ValueError("findings_json must be a non-empty path")
422
+ sidecar = Path(findings_json)
423
+ if not sidecar.is_absolute() and ".." in sidecar.parts:
424
+ raise ValueError(
425
+ f"findings_json escapes state_dir: {findings_json!r}"
426
+ " (--findings-json is resolved relative to --state-dir)"
427
+ )
428
+ sidecar_abs = sidecar.resolve(strict=False) if sidecar.is_absolute() else (
429
+ state_abs / sidecar
430
+ ).resolve(strict=False)
431
+ try:
432
+ sidecar_rel = sidecar_abs.relative_to(state_abs).as_posix()
433
+ except ValueError as exc:
434
+ raise ValueError(
435
+ f"findings_json escapes state_dir: {findings_json!r}"
436
+ " (--findings-json is resolved relative to --state-dir)"
437
+ ) from exc
438
+ return sidecar_abs, sidecar_rel
439
+
440
+
441
+ def produce_manifest(
442
+ root: Path,
443
+ state_dir: str,
444
+ branch: Optional[str],
445
+ findings_json: Optional[str],
446
+ config_path: Path,
447
+ depth_profile: str = "deep",
448
+ ) -> Dict[str, Any]:
449
+ """Produce a validated ``review-manifest.json`` object (does not write it).
450
+
451
+ ``depth_profile`` (default ``deep``) declares the review depth this recorded
452
+ run satisfies and selects the required artifact set: lighter depths require
453
+ fewer artifacts. Raises ValueError on any malformed input or contract
454
+ violation.
455
+ """
456
+ if depth_profile not in WORKFLOW_REVIEW_DEPTHS:
457
+ raise ValueError(f"depth_profile invalid: {depth_profile!r}")
458
+ state_abs, state_rel = _resolve_state_dir(root, state_dir)
459
+
460
+ resolved_branch = branch or current_branch(root)
461
+ if not resolved_branch:
462
+ raise ValueError("could not resolve branch; pass --branch")
463
+
464
+ sidecar, sidecar_rel = _resolve_findings_json(state_abs, findings_json)
465
+ if not sidecar.is_file():
466
+ hint = _state_dir_hint(findings_json, state_dir) or ""
467
+ raise ValueError(f"findings sidecar not found: {sidecar}{hint}")
468
+ findings = load_findings(sidecar)
469
+
470
+ config = load_policy_config(config_path)
471
+ policy_name, blocks_on, warns_on = resolve_gate_policy(config, resolved_branch)
472
+ gate_status, active_blocking = compute_gate(findings, blocks_on, warns_on)
473
+ projection = project_findings(findings)
474
+ artifacts = build_artifacts(
475
+ state_abs,
476
+ findings_artifact=sidecar_rel,
477
+ required_extra=DEPTH_REQUIRED_ARTIFACTS[depth_profile],
478
+ )
479
+
480
+ manifest = build_manifest(
481
+ review_run_id=mint_review_run_id(),
482
+ state_dir=state_rel,
483
+ policy_name=policy_name,
484
+ gate_status=gate_status,
485
+ active_blocking=active_blocking,
486
+ projection=projection,
487
+ artifacts=artifacts,
488
+ depth_profile=depth_profile,
489
+ )
490
+ plan_path = root / ".agent/plan.lock.json"
491
+ try:
492
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
493
+ except OSError as exc:
494
+ raise ValueError(f"locked plan unavailable: {exc}") from exc
495
+ except json.JSONDecodeError as exc:
496
+ raise ValueError(f"locked plan is malformed JSON: {exc}") from exc
497
+ validate_manifest_against_plan(manifest, plan)
498
+ return manifest
499
+
500
+
501
+ def exit_code_for(
502
+ manifest: Dict[str, Any], fail_on_block: bool, strict_exit: bool
503
+ ) -> int:
504
+ """Process exit status for a *valid* manifest. Never mutates ``manifest``.
505
+
506
+ Default 0 (so evidence is still written even on warn/fail). ``fail_on_block``
507
+ returns 1 when ``active_blocking`` is non-empty or ``gate_status == 'fail'``.
508
+ ``strict_exit`` additionally returns 1 on ``gate_status == 'warn'``.
509
+ """
510
+ if fail_on_block and (
511
+ manifest.get("active_blocking") or manifest.get("gate_status") == "fail"
512
+ ):
513
+ return 1
514
+ if strict_exit and manifest.get("gate_status") in ("warn", "fail"):
515
+ return 1
516
+ return 0