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.
- agentflow/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|