prizmkit 1.1.140 → 1.1.142
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.
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/README.md +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +165 -125
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +13 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -6
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +5 -3
- package/bundled/dev-pipeline/prizmkit_runtime/test_result.py +160 -0
- package/bundled/dev-pipeline/scripts/check-session-status.py +11 -1
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +23 -806
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +6 -8
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +1 -1
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +6 -8
- package/bundled/dev-pipeline/scripts/prompt_framework.py +68 -56
- package/bundled/dev-pipeline/scripts/update-bug-status.py +21 -2
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +52 -131
- package/bundled/dev-pipeline/scripts/update-feature-status.py +21 -2
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +21 -2
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +3 -3
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +8 -19
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -6
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -5
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +13 -10
- package/bundled/dev-pipeline/templates/sections/bugfix-reminders.md +1 -1
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +18 -9
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +1 -1
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +14 -26
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +3 -3
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +1 -1
- package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +13 -10
- package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
- package/bundled/dev-pipeline/templates/session-status-schema.json +1 -1
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +494 -413
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +144 -684
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +20 -41
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +24 -24
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +176 -36
- package/bundled/skills/_metadata.json +6 -6
- package/bundled/skills/prizmkit/SKILL.md +13 -18
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-code-review/SKILL.md +7 -7
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-committer/SKILL.md +8 -7
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-implement/SKILL.md +3 -2
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-plan/SKILL.md +2 -1
- package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-retrospective/SKILL.md +8 -6
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +73 -80
- package/bundled/skills/prizmkit-test/SKILL.md +217 -189
- package/bundled/skills/prizmkit-test/references/external-contract-mock-guidance.md +119 -0
- package/bundled/skills/prizmkit-test/references/independent-test-review.md +269 -0
- package/bundled/skills/prizmkit-test/references/test-coverage-model.md +193 -0
- package/bundled/skills/prizmkit-test/references/test-report-template.md +164 -79
- package/bundled/skills/prizmkit-workflow/SKILL.md +28 -36
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +73 -80
- package/package.json +1 -1
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +0 -477
- package/bundled/skills/prizmkit-test/assets/behavior-risk-matrix.schema.json +0 -116
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +0 -111
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +0 -66
- package/bundled/skills/prizmkit-test/references/boundary-coverage-protocol.md +0 -104
- package/bundled/skills/prizmkit-test/references/contract-mock-protocol.md +0 -65
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +0 -208
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +0 -80
- package/bundled/skills/prizmkit-test/references/examples.md +0 -100
- package/bundled/skills/prizmkit-test/references/service-boundary-test-catalog.md +0 -228
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +0 -118
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +0 -107
- package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +0 -181
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +0 -1930
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +0 -1847
|
@@ -1,1930 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Build runner-generated PrizmKit test evidence from schema-shaped requests.
|
|
3
|
-
|
|
4
|
-
Usage:
|
|
5
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR init [--baseline COMMIT]
|
|
6
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR capture-change
|
|
7
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR inventory --request REQUEST
|
|
8
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR prepare-tests --request REQUEST
|
|
9
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
|
|
10
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
|
|
11
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR finalize --request REQUEST
|
|
12
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR render-report
|
|
13
|
-
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
|
|
14
|
-
|
|
15
|
-
Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
|
|
16
|
-
timeouts, attempts, concurrency, tool probes, test layers, and framework-specific options come
|
|
17
|
-
from model-authored requests after project inspection.
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import argparse
|
|
23
|
-
import datetime as dt
|
|
24
|
-
import fnmatch
|
|
25
|
-
import hashlib
|
|
26
|
-
import json
|
|
27
|
-
import os
|
|
28
|
-
import shutil
|
|
29
|
-
import signal
|
|
30
|
-
import subprocess
|
|
31
|
-
import sys
|
|
32
|
-
import uuid
|
|
33
|
-
from pathlib import Path
|
|
34
|
-
from typing import Any
|
|
35
|
-
|
|
36
|
-
STAGES = [
|
|
37
|
-
"CHANGE_CLASSIFY", "SCOPE_DISCOVER", "CONTRACT_MODEL", "TEST_PLAN",
|
|
38
|
-
"INFRA_READY", "TEST_BUILD", "EXECUTE_PROVE", "EVIDENCE_PACKAGE",
|
|
39
|
-
"EVIDENCE_VALIDATE",
|
|
40
|
-
]
|
|
41
|
-
CATEGORIES = ("source", "tests", "contracts", "lockfiles")
|
|
42
|
-
LAYERS = (
|
|
43
|
-
"focused", "module-component", "contract-integration",
|
|
44
|
-
"affected-module-regression", "regression-ring",
|
|
45
|
-
)
|
|
46
|
-
BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
47
|
-
REPAIR_SCOPES = {
|
|
48
|
-
"test-infrastructure", "production", "runtime", "schema", "dependency", "public-interface",
|
|
49
|
-
}
|
|
50
|
-
INVENTORY_RESERVED_ROOTS = (
|
|
51
|
-
".prizmkit/state",
|
|
52
|
-
".prizmkit/test/evidence",
|
|
53
|
-
".prizmkit/" + "dev-" + "pipeline",
|
|
54
|
-
".claude/worktrees",
|
|
55
|
-
".agents/worktrees",
|
|
56
|
-
".codebuddy/worktrees",
|
|
57
|
-
".codex/worktrees",
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
_IGNORED_DIR_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
|
|
61
|
-
FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
|
|
62
|
-
MAX_PROTOCOL_PACKAGE_ATTEMPTS = 2
|
|
63
|
-
PROTOCOL_STATE_NAME = ".protocol-recovery.json"
|
|
64
|
-
DEFAULT_EXECUTION_TIMEOUT_SECONDS = 300.0
|
|
65
|
-
MAX_EXECUTION_TIMEOUT_SECONDS = 1800.0
|
|
66
|
-
MAX_EXECUTION_OUTPUT_BYTES = 16 * 1024 * 1024
|
|
67
|
-
CANONICAL_EVIDENCE_ROOT = (".prizmkit", "test", "evidence")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class RequestError(Exception):
|
|
71
|
-
"""Raised when a request would produce unsafe or ambiguous evidence."""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def canonical_bytes(value: Any) -> bytes:
|
|
75
|
-
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def canonical_sha256(value: Any) -> str:
|
|
79
|
-
return hashlib.sha256(canonical_bytes(value)).hexdigest()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def file_sha256(path: Path) -> str:
|
|
83
|
-
digest = hashlib.sha256()
|
|
84
|
-
with path.open("rb") as handle:
|
|
85
|
-
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
86
|
-
digest.update(chunk)
|
|
87
|
-
return digest.hexdigest()
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def write_json_atomic(path: Path, value: Any) -> None:
|
|
91
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
-
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
93
|
-
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
94
|
-
os.replace(temporary, path)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def load_json(path: Path) -> Any:
|
|
98
|
-
try:
|
|
99
|
-
return json.loads(path.read_text(encoding="utf-8"))
|
|
100
|
-
except FileNotFoundError as exc:
|
|
101
|
-
raise RequestError(f"file does not exist: {path}") from exc
|
|
102
|
-
except json.JSONDecodeError as exc:
|
|
103
|
-
raise RequestError(f"invalid JSON: {path}: {exc}") from exc
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def confined(root: Path, value: str, *, must_exist: bool = False, directory: bool = False) -> Path:
|
|
107
|
-
if not isinstance(value, str) or not value:
|
|
108
|
-
raise RequestError("path must be a non-empty string")
|
|
109
|
-
candidate = (root / value).resolve() if not Path(value).is_absolute() else Path(value).resolve()
|
|
110
|
-
try:
|
|
111
|
-
candidate.relative_to(root.resolve())
|
|
112
|
-
except ValueError as exc:
|
|
113
|
-
raise RequestError(f"path escapes root: {value}") from exc
|
|
114
|
-
if must_exist and not candidate.exists():
|
|
115
|
-
raise RequestError(f"path does not exist: {value}")
|
|
116
|
-
if directory and candidate.exists() and not candidate.is_dir():
|
|
117
|
-
raise RequestError(f"path is not a directory: {value}")
|
|
118
|
-
return candidate
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def canonical_evidence_root(project_root: Path) -> Path:
|
|
122
|
-
return project_root.joinpath(*CANONICAL_EVIDENCE_ROOT).resolve()
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def require_canonical_evidence_dir(project_root: Path, evidence_dir: Path) -> None:
|
|
126
|
-
root = canonical_evidence_root(project_root)
|
|
127
|
-
try:
|
|
128
|
-
evidence_dir.resolve().relative_to(root)
|
|
129
|
-
except ValueError as exc:
|
|
130
|
-
raise RequestError(
|
|
131
|
-
"evidence directory must be under .prizmkit/test/evidence"
|
|
132
|
-
) from exc
|
|
133
|
-
if evidence_dir.resolve() == root:
|
|
134
|
-
raise RequestError("evidence directory must be a package child of .prizmkit/test/evidence")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def max_timeout(value: Any) -> float:
|
|
138
|
-
if value is None:
|
|
139
|
-
return DEFAULT_EXECUTION_TIMEOUT_SECONDS
|
|
140
|
-
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
|
141
|
-
raise RequestError("timeout_seconds must be a positive number")
|
|
142
|
-
if value > MAX_EXECUTION_TIMEOUT_SECONDS:
|
|
143
|
-
raise RequestError(
|
|
144
|
-
f"timeout_seconds must not exceed {MAX_EXECUTION_TIMEOUT_SECONDS:g} seconds"
|
|
145
|
-
)
|
|
146
|
-
return float(value)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
def evidence_relative(evidence_dir: Path, path: Path) -> str:
|
|
150
|
-
try:
|
|
151
|
-
return path.resolve().relative_to(evidence_dir.resolve()).as_posix()
|
|
152
|
-
except ValueError as exc:
|
|
153
|
-
raise RequestError(f"evidence output escapes evidence directory: {path}") from exc
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def request_file(evidence_dir: Path, value: str) -> Path:
|
|
157
|
-
return confined(evidence_dir, value, must_exist=True)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def receipt_file(evidence_dir: Path, value: str) -> Path:
|
|
161
|
-
direct = confined(evidence_dir, value)
|
|
162
|
-
if direct.is_file():
|
|
163
|
-
return direct
|
|
164
|
-
return confined(evidence_dir, f"receipts/{value}", must_exist=True)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def ensure_object(value: Any, label: str) -> dict[str, Any]:
|
|
168
|
-
if not isinstance(value, dict):
|
|
169
|
-
raise RequestError(f"{label} must be an object")
|
|
170
|
-
return value
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> list[str]:
|
|
174
|
-
if not isinstance(value, list) or (not allow_empty and not value):
|
|
175
|
-
raise RequestError(f"{label} must be a{' non-empty' if not allow_empty else ''} string array")
|
|
176
|
-
if any(not isinstance(item, str) or not item for item in value):
|
|
177
|
-
raise RequestError(f"{label} contains a non-string or empty value")
|
|
178
|
-
return value
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def normalize_relative(value: str) -> str:
|
|
182
|
-
normalized = Path(value).as_posix()
|
|
183
|
-
if normalized == "." or normalized.startswith("../") or "/../" in normalized:
|
|
184
|
-
raise RequestError(f"path is not project-relative: {value}")
|
|
185
|
-
return normalized
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def is_reserved_inventory_path(relative: str) -> bool:
|
|
189
|
-
path = Path(relative)
|
|
190
|
-
if any(part in _IGNORED_DIR_NAMES for part in path.parts):
|
|
191
|
-
return True
|
|
192
|
-
return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
|
|
196
|
-
result = subprocess.run(
|
|
197
|
-
["git", "-C", str(project_root), *args],
|
|
198
|
-
capture_output=True,
|
|
199
|
-
check=False,
|
|
200
|
-
)
|
|
201
|
-
if check and result.returncode != 0:
|
|
202
|
-
raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
|
|
203
|
-
return result
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def git_baseline(project_root: Path, requested: str | None = None) -> str:
|
|
207
|
-
baseline = requested or "HEAD"
|
|
208
|
-
result = project_git(project_root, "rev-parse", "--verify", baseline)
|
|
209
|
-
if result.returncode != 0:
|
|
210
|
-
raise RequestError(f"baseline commit is not resolvable: {baseline}")
|
|
211
|
-
return result.stdout.decode().strip()
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
|
|
215
|
-
result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
|
|
216
|
-
raw = result.stdout
|
|
217
|
-
entries: list[dict[str, Any]] = []
|
|
218
|
-
tokens = raw.split(b"\0")
|
|
219
|
-
index = 0
|
|
220
|
-
while index < len(tokens):
|
|
221
|
-
token = tokens[index]
|
|
222
|
-
index += 1
|
|
223
|
-
if not token:
|
|
224
|
-
continue
|
|
225
|
-
status = token[:2].decode(errors="replace")
|
|
226
|
-
path = token[3:].decode(errors="replace")
|
|
227
|
-
old_path = None
|
|
228
|
-
if "R" in status or "C" in status:
|
|
229
|
-
if index >= len(tokens):
|
|
230
|
-
raise RequestError("git status returned an incomplete rename/copy record")
|
|
231
|
-
old_path = tokens[index].decode(errors="replace")
|
|
232
|
-
index += 1
|
|
233
|
-
relative = normalize_relative(path)
|
|
234
|
-
if not is_reserved_inventory_path(relative):
|
|
235
|
-
record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
|
|
236
|
-
entries.append(record)
|
|
237
|
-
if old_path is not None:
|
|
238
|
-
old_relative = normalize_relative(old_path)
|
|
239
|
-
if not is_reserved_inventory_path(old_relative):
|
|
240
|
-
entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
|
|
241
|
-
return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
|
|
245
|
-
result = project_git(
|
|
246
|
-
project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
|
|
247
|
-
baseline, "--", ".",
|
|
248
|
-
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
249
|
-
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
250
|
-
check=True,
|
|
251
|
-
)
|
|
252
|
-
tokens = result.stdout.split(b"\0")
|
|
253
|
-
entries: list[dict[str, Any]] = []
|
|
254
|
-
index = 0
|
|
255
|
-
while index < len(tokens):
|
|
256
|
-
status_token = tokens[index].decode(errors="replace")
|
|
257
|
-
index += 1
|
|
258
|
-
if not status_token:
|
|
259
|
-
continue
|
|
260
|
-
if index >= len(tokens):
|
|
261
|
-
raise RequestError("git diff returned an incomplete changed-file record")
|
|
262
|
-
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
263
|
-
index += 1
|
|
264
|
-
status = status_token[0]
|
|
265
|
-
old_path = None
|
|
266
|
-
if status in {"R", "C"}:
|
|
267
|
-
if index >= len(tokens):
|
|
268
|
-
raise RequestError("git diff returned an incomplete rename/copy record")
|
|
269
|
-
old_path = first
|
|
270
|
-
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
271
|
-
index += 1
|
|
272
|
-
if not is_reserved_inventory_path(first):
|
|
273
|
-
entries.append({"path": first, "status": status, "old_path": old_path})
|
|
274
|
-
if old_path and not is_reserved_inventory_path(old_path):
|
|
275
|
-
entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
|
|
276
|
-
return entries
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def merge_changed_file_entries(
|
|
280
|
-
baseline_entries: list[dict[str, Any]],
|
|
281
|
-
working_entries: list[dict[str, Any]],
|
|
282
|
-
) -> list[dict[str, Any]]:
|
|
283
|
-
merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
|
|
284
|
-
for entry in baseline_entries + working_entries:
|
|
285
|
-
key = (entry["path"], entry.get("old_path"), entry.get("role"))
|
|
286
|
-
existing = merged.get(key)
|
|
287
|
-
if existing is None:
|
|
288
|
-
merged[key] = dict(entry)
|
|
289
|
-
continue
|
|
290
|
-
statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
|
|
291
|
-
if "D" in statuses:
|
|
292
|
-
existing["status"] = "D"
|
|
293
|
-
elif "R" in statuses:
|
|
294
|
-
existing["status"] = "R"
|
|
295
|
-
elif "C" in statuses:
|
|
296
|
-
existing["status"] = "C"
|
|
297
|
-
elif statuses:
|
|
298
|
-
existing["status"] = "M"
|
|
299
|
-
return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
|
|
303
|
-
status_entries = git_status_entries(project_root)
|
|
304
|
-
baseline_entries = git_diff_entries(project_root, baseline)
|
|
305
|
-
status_entries = merge_changed_file_entries(baseline_entries, status_entries)
|
|
306
|
-
tracked = project_git(
|
|
307
|
-
project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
|
|
308
|
-
baseline, "--", ".",
|
|
309
|
-
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
310
|
-
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
311
|
-
)
|
|
312
|
-
if tracked.returncode != 0:
|
|
313
|
-
raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
|
|
314
|
-
chunks = [tracked.stdout]
|
|
315
|
-
untracked = [
|
|
316
|
-
item["path"] for item in status_entries
|
|
317
|
-
if item["status"].strip() == "??" and item.get("old_path") is None
|
|
318
|
-
]
|
|
319
|
-
for relative in sorted(untracked):
|
|
320
|
-
candidate = confined(project_root, relative, must_exist=True)
|
|
321
|
-
if candidate.is_file():
|
|
322
|
-
result = subprocess.run(
|
|
323
|
-
["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
|
|
324
|
-
cwd=project_root,
|
|
325
|
-
capture_output=True,
|
|
326
|
-
check=False,
|
|
327
|
-
)
|
|
328
|
-
if result.returncode not in (0, 1):
|
|
329
|
-
raise RequestError(f"cannot capture untracked file: {relative}")
|
|
330
|
-
chunks.append(result.stdout)
|
|
331
|
-
patch = b"".join(chunk for chunk in chunks if chunk)
|
|
332
|
-
return patch, status_entries
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
def write_text_atomic(path: Path, value: str) -> None:
|
|
336
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
337
|
-
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
338
|
-
temporary.write_text(value, encoding="utf-8")
|
|
339
|
-
os.replace(temporary, path)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
343
|
-
if not isinstance(value, list):
|
|
344
|
-
raise RequestError("external_targets must be an array")
|
|
345
|
-
for target in value:
|
|
346
|
-
if not isinstance(target, dict):
|
|
347
|
-
raise RequestError("external target must be an object")
|
|
348
|
-
required = {"name", "external", "classification", "endpoint_evidence", "allow_evidence", "deny_evidence"}
|
|
349
|
-
if set(target) != required:
|
|
350
|
-
raise RequestError(f"external target fields must be exactly {sorted(required)}")
|
|
351
|
-
if not isinstance(target["external"], bool):
|
|
352
|
-
raise RequestError("external target external flag must be boolean")
|
|
353
|
-
for key in ("endpoint_evidence", "allow_evidence", "deny_evidence"):
|
|
354
|
-
ensure_string_list(target[key], f"external target {key}", allow_empty=(key != "endpoint_evidence"))
|
|
355
|
-
if target["external"] and target["classification"] in BLOCKED_EXTERNAL_CLASSES:
|
|
356
|
-
raise RequestError(f"execution blocked for external target classified {target['classification']}: {target['name']}")
|
|
357
|
-
if target["external"] and target["classification"] not in {"isolated", "test", "staging"}:
|
|
358
|
-
raise RequestError(f"invalid external target classification: {target['classification']}")
|
|
359
|
-
if target["external"] and not target["allow_evidence"]:
|
|
360
|
-
raise RequestError(f"external target lacks explicit allow evidence: {target['name']}")
|
|
361
|
-
if target["external"] and target["deny_evidence"]:
|
|
362
|
-
raise RequestError(f"external target matched deny evidence: {target['name']}")
|
|
363
|
-
return value
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def load_change_capture(
|
|
367
|
-
project_root: Path,
|
|
368
|
-
evidence_dir: Path,
|
|
369
|
-
*,
|
|
370
|
-
require_fresh: bool = False,
|
|
371
|
-
) -> dict[str, Any]:
|
|
372
|
-
capture_path = evidence_dir / "change-capture.json"
|
|
373
|
-
if not capture_path.is_file():
|
|
374
|
-
raise RequestError("canonical change capture is required; run capture-change first")
|
|
375
|
-
capture = ensure_object(load_json(capture_path), "change capture")
|
|
376
|
-
baseline = capture.get("baseline_commit")
|
|
377
|
-
patch_path = evidence_dir / "source-change.patch"
|
|
378
|
-
if (
|
|
379
|
-
capture.get("capture_format") != "prizmkit-canonical-change-v1"
|
|
380
|
-
or not isinstance(baseline, str)
|
|
381
|
-
or not patch_path.is_file()
|
|
382
|
-
or capture.get("patch_path") != "source-change.patch"
|
|
383
|
-
or capture.get("patch_sha256") != file_sha256(patch_path)
|
|
384
|
-
or not isinstance(capture.get("changed_files"), list)
|
|
385
|
-
):
|
|
386
|
-
raise RequestError("canonical change capture is incomplete or inconsistent")
|
|
387
|
-
if require_fresh:
|
|
388
|
-
live_patch, live_entries = canonical_change_patch(project_root, baseline)
|
|
389
|
-
live_patch_sha256 = hashlib.sha256(live_patch).hexdigest()
|
|
390
|
-
if (
|
|
391
|
-
live_patch_sha256 != capture["patch_sha256"]
|
|
392
|
-
or live_entries != capture["changed_files"]
|
|
393
|
-
):
|
|
394
|
-
raise RequestError(
|
|
395
|
-
"canonical change capture is stale; rerun capture-change and inventory after repository changes"
|
|
396
|
-
)
|
|
397
|
-
return capture
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
|
|
401
|
-
paths = {
|
|
402
|
-
item["path"]
|
|
403
|
-
for item in capture.get("changed_files", [])
|
|
404
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
405
|
-
}
|
|
406
|
-
return sorted(paths)
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
|
|
410
|
-
path = evidence_dir / "test-plan.json"
|
|
411
|
-
if not path.exists():
|
|
412
|
-
return None
|
|
413
|
-
return ensure_object(load_json(path), "test plan")
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
417
|
-
if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
|
|
418
|
-
raise RequestError("builder-generated test plan is required before execution")
|
|
419
|
-
entries = [item for item in plan["tests"] if isinstance(item, dict)]
|
|
420
|
-
if len(entries) != len(plan["tests"]):
|
|
421
|
-
raise RequestError("test plan contains a non-object test entry")
|
|
422
|
-
return entries
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
426
|
-
request = ensure_object(request, "execution request")
|
|
427
|
-
required = {
|
|
428
|
-
"request_version", "purpose", "command", "cwd", "environment",
|
|
429
|
-
"tool_version_commands", "layer", "external_targets",
|
|
430
|
-
}
|
|
431
|
-
optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
|
|
432
|
-
if not required <= set(request) or set(request) - required - optional:
|
|
433
|
-
raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
|
|
434
|
-
if request["request_version"] != "1.0":
|
|
435
|
-
raise RequestError("unsupported execution request version")
|
|
436
|
-
command = ensure_string_list(request["command"], "command", allow_empty=False)
|
|
437
|
-
if not command[0]:
|
|
438
|
-
raise RequestError("command executable is empty")
|
|
439
|
-
if not isinstance(request["cwd"], str):
|
|
440
|
-
raise RequestError("cwd must be a string")
|
|
441
|
-
environment = ensure_object(request["environment"], "environment")
|
|
442
|
-
if any(not isinstance(key, str) or not isinstance(value, str) for key, value in environment.items()):
|
|
443
|
-
raise RequestError("environment must contain string keys and complete string values")
|
|
444
|
-
probes = ensure_object(request["tool_version_commands"], "tool_version_commands")
|
|
445
|
-
for name, probe in probes.items():
|
|
446
|
-
if not isinstance(name, str):
|
|
447
|
-
raise RequestError("tool probe names must be strings")
|
|
448
|
-
ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
|
|
449
|
-
has_ids = "test_ids" in request
|
|
450
|
-
has_paths = "test_paths" in request
|
|
451
|
-
has_auto_bind = "auto_bind" in request
|
|
452
|
-
if has_auto_bind and not isinstance(request["auto_bind"], bool):
|
|
453
|
-
raise RequestError("execution request auto_bind must be boolean")
|
|
454
|
-
binding_modes = int(has_ids) + int(has_paths) + int(request.get("auto_bind") is True)
|
|
455
|
-
if binding_modes == 0:
|
|
456
|
-
raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
|
|
457
|
-
if binding_modes != 1 or (has_auto_bind and request["auto_bind"] is False):
|
|
458
|
-
raise RequestError("execution request binding modes test_ids, test_paths, and auto_bind=true are mutually exclusive")
|
|
459
|
-
if has_ids:
|
|
460
|
-
test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
|
|
461
|
-
if len(set(test_ids)) != len(test_ids):
|
|
462
|
-
raise RequestError("execution request test_ids contains duplicates")
|
|
463
|
-
if has_paths:
|
|
464
|
-
test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
|
|
465
|
-
if len(set(test_paths)) != len(test_paths):
|
|
466
|
-
raise RequestError("execution request test_paths contains duplicates")
|
|
467
|
-
if request["layer"] not in LAYERS and request["layer"] != "lightweight":
|
|
468
|
-
raise RequestError(f"unknown execution layer: {request['layer']}")
|
|
469
|
-
if plan is not None and has_ids:
|
|
470
|
-
tests = test_plan_entries(plan)
|
|
471
|
-
known = {item.get("id") for item in tests}
|
|
472
|
-
unknown = sorted(set(request["test_ids"]) - known)
|
|
473
|
-
if unknown:
|
|
474
|
-
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
475
|
-
test_map = {item.get("id"): item for item in tests}
|
|
476
|
-
invalid_layer = sorted(
|
|
477
|
-
test_id for test_id in request["test_ids"]
|
|
478
|
-
if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
|
|
479
|
-
)
|
|
480
|
-
if invalid_layer:
|
|
481
|
-
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
482
|
-
validate_external_targets(request["external_targets"])
|
|
483
|
-
if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
|
|
484
|
-
raise RequestError("timeout_seconds must be a positive number")
|
|
485
|
-
return request
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
|
|
489
|
-
if not isinstance(value, str) or not value or value.startswith("-"):
|
|
490
|
-
return None
|
|
491
|
-
candidate = Path(value)
|
|
492
|
-
resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
|
|
493
|
-
try:
|
|
494
|
-
return resolved.relative_to(project_root.resolve()).as_posix()
|
|
495
|
-
except ValueError:
|
|
496
|
-
return None
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
|
|
500
|
-
cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
501
|
-
command = request["command"]
|
|
502
|
-
executable = Path(command[0]).name.lower()
|
|
503
|
-
subcommands = {item for item in command[1:] if not item.startswith("-")}
|
|
504
|
-
path_tokens: list[str] = []
|
|
505
|
-
after_separator = False
|
|
506
|
-
skip_option_value = False
|
|
507
|
-
options_with_values = {
|
|
508
|
-
"-C", "--config", "--config-file", "--project", "--package", "-p",
|
|
509
|
-
"--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
|
|
510
|
-
}
|
|
511
|
-
scope_affecting_options = {
|
|
512
|
-
"-C", "--config", "--config-file", "--project", "--package", "--root", "--rootdir",
|
|
513
|
-
"--cwd", "--testPathPattern", "--testNamePattern", "--grep", "-k", "--filter", "-t",
|
|
514
|
-
}
|
|
515
|
-
for token in command[1:]:
|
|
516
|
-
option_name = token.split("=", 1)[0]
|
|
517
|
-
if option_name in scope_affecting_options:
|
|
518
|
-
raise RequestError(
|
|
519
|
-
"command scope cannot be inferred when the native command uses a test-selection "
|
|
520
|
-
f"option: {option_name}"
|
|
521
|
-
)
|
|
522
|
-
if skip_option_value:
|
|
523
|
-
skip_option_value = False
|
|
524
|
-
continue
|
|
525
|
-
if token == "--":
|
|
526
|
-
after_separator = True
|
|
527
|
-
continue
|
|
528
|
-
if token in options_with_values:
|
|
529
|
-
skip_option_value = True
|
|
530
|
-
continue
|
|
531
|
-
if token.startswith("-"):
|
|
532
|
-
continue
|
|
533
|
-
if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
|
|
534
|
-
continue
|
|
535
|
-
if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
|
|
536
|
-
path_tokens.append(token)
|
|
537
|
-
|
|
538
|
-
package_wide = (
|
|
539
|
-
executable in {"pytest", "vitest", "jest", "mocha", "busted"}
|
|
540
|
-
or (executable in {"python", "python3", "py.test"} and "pytest" in command)
|
|
541
|
-
or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
|
|
542
|
-
or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
|
|
543
|
-
)
|
|
544
|
-
if not path_tokens:
|
|
545
|
-
if package_wide:
|
|
546
|
-
return sorted({item["id"] for item in tests})
|
|
547
|
-
raise RequestError(
|
|
548
|
-
"command scope cannot be inferred from the native command; use a recognized package-wide command or an explicit test path in the command"
|
|
549
|
-
)
|
|
550
|
-
|
|
551
|
-
matched_paths: set[str] = set()
|
|
552
|
-
for token in path_tokens:
|
|
553
|
-
recursive_scope = token == "..." or token.endswith("/...")
|
|
554
|
-
scope_token = token[:-3].rstrip("/") if recursive_scope else token
|
|
555
|
-
normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
|
|
556
|
-
if normalized is None:
|
|
557
|
-
continue
|
|
558
|
-
has_glob = any(mark in normalized for mark in "*?[]")
|
|
559
|
-
for test in tests:
|
|
560
|
-
test_path = test["project_path"]
|
|
561
|
-
within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
|
|
562
|
-
if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
|
|
563
|
-
not has_glob and not recursive_scope and within_scope
|
|
564
|
-
):
|
|
565
|
-
matched_paths.add(test_path)
|
|
566
|
-
if not matched_paths:
|
|
567
|
-
raise RequestError(
|
|
568
|
-
"native command scope does not cover any prepared test"
|
|
569
|
-
)
|
|
570
|
-
return [item["id"] for item in tests if item["project_path"] in matched_paths]
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
|
|
574
|
-
plan = plan_for_execution(evidence_dir)
|
|
575
|
-
if plan is None:
|
|
576
|
-
if "test_ids" not in request:
|
|
577
|
-
raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
|
|
578
|
-
return request
|
|
579
|
-
if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
|
|
580
|
-
raise RequestError("canonical lifecycle requires prepare-tests before execute")
|
|
581
|
-
tests = test_plan_entries(plan)
|
|
582
|
-
by_id = {item["id"]: item for item in tests}
|
|
583
|
-
by_path = {item["project_path"]: item for item in tests}
|
|
584
|
-
if "test_ids" in request:
|
|
585
|
-
declared = list(request["test_ids"])
|
|
586
|
-
elif "test_paths" in request:
|
|
587
|
-
unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
|
|
588
|
-
if unknown_paths:
|
|
589
|
-
raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
|
|
590
|
-
declared = [by_path[path]["id"] for path in request["test_paths"]]
|
|
591
|
-
else:
|
|
592
|
-
declared = None
|
|
593
|
-
inferred = command_scope_test_ids(project_root, request, tests)
|
|
594
|
-
if declared is not None and set(declared) != set(inferred):
|
|
595
|
-
raise RequestError(
|
|
596
|
-
"execution request binding does not exactly match native command scope "
|
|
597
|
-
f"(declared: {', '.join(sorted(declared))}; inferred: {', '.join(sorted(inferred))})"
|
|
598
|
-
)
|
|
599
|
-
resolved = inferred
|
|
600
|
-
if not resolved:
|
|
601
|
-
raise RequestError("execution request resolved no prepared tests")
|
|
602
|
-
unknown = sorted(set(resolved) - set(by_id))
|
|
603
|
-
if unknown:
|
|
604
|
-
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
605
|
-
invalid_layer = sorted(
|
|
606
|
-
test_id for test_id in resolved
|
|
607
|
-
if request["layer"] not in (by_id[test_id].get("layers") or [])
|
|
608
|
-
)
|
|
609
|
-
if invalid_layer:
|
|
610
|
-
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
611
|
-
request.pop("test_paths", None)
|
|
612
|
-
request.pop("auto_bind", None)
|
|
613
|
-
request["test_ids"] = resolved
|
|
614
|
-
return request
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
|
|
618
|
-
project_resolved = project_root.resolve()
|
|
619
|
-
paths: set[Path] = set()
|
|
620
|
-
for pattern in patterns:
|
|
621
|
-
if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
|
|
622
|
-
raise RequestError(f"inventory pattern is not project-relative: {pattern}")
|
|
623
|
-
for candidate in project_root.glob(pattern):
|
|
624
|
-
if candidate.is_file():
|
|
625
|
-
paths.add(candidate.resolve())
|
|
626
|
-
entries = []
|
|
627
|
-
for candidate in sorted(paths):
|
|
628
|
-
relative = candidate.relative_to(project_resolved).as_posix()
|
|
629
|
-
if relative not in exclusions and not is_reserved_inventory_path(relative):
|
|
630
|
-
entries.append({"path": relative, "sha256": file_sha256(candidate)})
|
|
631
|
-
return entries
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
|
|
635
|
-
membership: dict[str, str] = {}
|
|
636
|
-
for category, entries in output_categories.items():
|
|
637
|
-
for entry in entries:
|
|
638
|
-
path = entry["path"]
|
|
639
|
-
previous = membership.get(path)
|
|
640
|
-
if previous is not None:
|
|
641
|
-
raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
|
|
642
|
-
membership[path] = category
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
def validate_module_root_request(
|
|
646
|
-
project_root: Path,
|
|
647
|
-
evidence_dir: Path,
|
|
648
|
-
module_roots: list[Any],
|
|
649
|
-
changed_files: list[str],
|
|
650
|
-
) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
|
|
651
|
-
normalized_roots: list[str] = []
|
|
652
|
-
explicit_changed: dict[str, list[str]] = {}
|
|
653
|
-
for item in module_roots:
|
|
654
|
-
if isinstance(item, str):
|
|
655
|
-
relative = normalize_relative(item)
|
|
656
|
-
changed_set: list[str] = []
|
|
657
|
-
elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
|
|
658
|
-
relative = normalize_relative(item["path"])
|
|
659
|
-
changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
|
|
660
|
-
else:
|
|
661
|
-
raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
|
|
662
|
-
confined(project_root, relative, must_exist=True)
|
|
663
|
-
if relative in normalized_roots:
|
|
664
|
-
raise RequestError(f"duplicate module root: {relative}")
|
|
665
|
-
for changed in changed_set:
|
|
666
|
-
changed = normalize_relative(changed)
|
|
667
|
-
if not path_is_below_or_equal(changed, relative):
|
|
668
|
-
raise RequestError(f"module root changed file lies outside root: {changed}")
|
|
669
|
-
if changed not in changed_files:
|
|
670
|
-
raise RequestError(f"module root changed file is not in changed_files: {changed}")
|
|
671
|
-
normalized_roots.append(relative)
|
|
672
|
-
explicit_changed[relative] = sorted(changed_set)
|
|
673
|
-
enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
|
|
674
|
-
return normalized_roots, explicit_changed, enumerated
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
def path_is_below_or_equal(path: str, root: str) -> bool:
|
|
678
|
-
normalized_path = Path(path)
|
|
679
|
-
normalized_root = Path(root)
|
|
680
|
-
return normalized_path == normalized_root or normalized_root in normalized_path.parents
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
def enumerate_module_root_files(
|
|
684
|
-
project_root: Path,
|
|
685
|
-
evidence_dir: Path,
|
|
686
|
-
module_roots: list[str],
|
|
687
|
-
) -> dict[str, list[str]]:
|
|
688
|
-
project_resolved = project_root.resolve()
|
|
689
|
-
evidence_resolved = evidence_dir.resolve()
|
|
690
|
-
result: dict[str, list[str]] = {}
|
|
691
|
-
for relative in module_roots:
|
|
692
|
-
root = confined(project_root, relative, must_exist=True)
|
|
693
|
-
candidates = [root] if root.is_file() else root.rglob("*")
|
|
694
|
-
files: list[str] = []
|
|
695
|
-
for candidate in candidates:
|
|
696
|
-
if not candidate.is_file() or ".git" in candidate.parts or "__pycache__" in candidate.parts:
|
|
697
|
-
continue
|
|
698
|
-
resolved = candidate.resolve()
|
|
699
|
-
if resolved == evidence_resolved or evidence_resolved in resolved.parents:
|
|
700
|
-
continue
|
|
701
|
-
files.append(resolved.relative_to(project_resolved).as_posix())
|
|
702
|
-
result[relative] = sorted(files)
|
|
703
|
-
return result
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
|
|
707
|
-
request = ensure_object(load_json(request_path), "inventory request")
|
|
708
|
-
required = {"request_version", "categories", "changed_files", "module_roots", "exclusions", "discovery_evidence", "plan_inputs"}
|
|
709
|
-
if set(request) != required or request["request_version"] != "1.0":
|
|
710
|
-
raise RequestError(f"inventory request fields must be exactly {sorted(required)} with version 1.0")
|
|
711
|
-
categories = ensure_object(request["categories"], "inventory categories")
|
|
712
|
-
if set(categories) != set(CATEGORIES):
|
|
713
|
-
raise RequestError(f"inventory categories must be exactly {list(CATEGORIES)}")
|
|
714
|
-
exclusions = request["exclusions"]
|
|
715
|
-
if not isinstance(exclusions, list):
|
|
716
|
-
raise RequestError("inventory exclusions must be an array")
|
|
717
|
-
exclusion_paths: set[str] = set()
|
|
718
|
-
for exclusion in exclusions:
|
|
719
|
-
if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
|
|
720
|
-
raise RequestError("each inventory exclusion requires path, reason, and evidence")
|
|
721
|
-
relative = normalize_relative(exclusion["path"])
|
|
722
|
-
if is_reserved_inventory_path(relative):
|
|
723
|
-
raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
|
|
724
|
-
confined(project_root, relative)
|
|
725
|
-
if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
|
|
726
|
-
raise RequestError("inventory exclusion reason is too short")
|
|
727
|
-
if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
|
|
728
|
-
raise RequestError("inventory exclusion lacks evidence")
|
|
729
|
-
exclusion_paths.add(relative)
|
|
730
|
-
changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
|
|
731
|
-
if len(changed_files) != len(request["changed_files"]):
|
|
732
|
-
raise RequestError("changed_files contains duplicates")
|
|
733
|
-
capture_path = evidence_dir / "change-capture.json"
|
|
734
|
-
captured_deleted: set[str] = set()
|
|
735
|
-
if capture_path.is_file():
|
|
736
|
-
capture = ensure_object(load_json(capture_path), "change capture")
|
|
737
|
-
for entry in capture.get("changed_files", []):
|
|
738
|
-
if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
|
|
739
|
-
captured_deleted.add(entry.get("path"))
|
|
740
|
-
if isinstance(entry, dict) and entry.get("role") == "source":
|
|
741
|
-
captured_deleted.add(entry.get("path"))
|
|
742
|
-
for relative in changed_files:
|
|
743
|
-
confined(
|
|
744
|
-
project_root,
|
|
745
|
-
relative,
|
|
746
|
-
must_exist=relative not in exclusion_paths and relative not in captured_deleted,
|
|
747
|
-
)
|
|
748
|
-
raw_module_roots = request["module_roots"]
|
|
749
|
-
if not isinstance(raw_module_roots, list) or not raw_module_roots:
|
|
750
|
-
raise RequestError("module_roots must be a non-empty array")
|
|
751
|
-
module_roots, explicit_changed, module_root_files = validate_module_root_request(
|
|
752
|
-
project_root, evidence_dir, raw_module_roots, changed_files,
|
|
753
|
-
)
|
|
754
|
-
output_categories = {
|
|
755
|
-
name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
|
|
756
|
-
for name in CATEGORIES
|
|
757
|
-
}
|
|
758
|
-
validate_category_disjointness(output_categories)
|
|
759
|
-
inventoried_paths = {
|
|
760
|
-
entry["path"]
|
|
761
|
-
for entries in output_categories.values()
|
|
762
|
-
for entry in entries
|
|
763
|
-
}
|
|
764
|
-
capture_path = evidence_dir / "change-capture.json"
|
|
765
|
-
if capture_path.is_file():
|
|
766
|
-
capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
|
|
767
|
-
captured_paths = set(captured_changed_paths(capture))
|
|
768
|
-
if captured_paths != set(changed_files):
|
|
769
|
-
missing = sorted(captured_paths - set(changed_files))
|
|
770
|
-
extra = sorted(set(changed_files) - captured_paths)
|
|
771
|
-
details = []
|
|
772
|
-
if missing:
|
|
773
|
-
details.append("missing from inventory request: " + ", ".join(missing))
|
|
774
|
-
if extra:
|
|
775
|
-
details.append("not present in canonical capture: " + ", ".join(extra))
|
|
776
|
-
raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
|
|
777
|
-
captured_deleted = {
|
|
778
|
-
item["path"]
|
|
779
|
-
for item in capture.get("changed_files", [])
|
|
780
|
-
if isinstance(item, dict)
|
|
781
|
-
and isinstance(item.get("path"), str)
|
|
782
|
-
and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
|
|
783
|
-
}
|
|
784
|
-
unaccounted = sorted(
|
|
785
|
-
captured_paths
|
|
786
|
-
- inventoried_paths
|
|
787
|
-
- exclusion_paths
|
|
788
|
-
- captured_deleted
|
|
789
|
-
)
|
|
790
|
-
if unaccounted:
|
|
791
|
-
raise RequestError(
|
|
792
|
-
"canonical changed files are neither inventoried nor explicitly excluded: "
|
|
793
|
-
+ ", ".join(unaccounted)
|
|
794
|
-
)
|
|
795
|
-
elif (evidence_dir / "lifecycle.json").is_file():
|
|
796
|
-
raise RequestError("canonical lifecycle requires capture-change before inventory")
|
|
797
|
-
missing_from_inventory = sorted({
|
|
798
|
-
relative
|
|
799
|
-
for files in module_root_files.values()
|
|
800
|
-
for relative in files
|
|
801
|
-
if relative not in inventoried_paths and relative not in exclusion_paths
|
|
802
|
-
})
|
|
803
|
-
if missing_from_inventory:
|
|
804
|
-
raise RequestError(
|
|
805
|
-
"module root contains files that are neither inventoried nor excluded: "
|
|
806
|
-
+ ", ".join(missing_from_inventory[:20])
|
|
807
|
-
)
|
|
808
|
-
for root, changed_set in explicit_changed.items():
|
|
809
|
-
if changed_set and not set(changed_set) <= set(module_root_files[root]):
|
|
810
|
-
raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
|
|
811
|
-
output = {
|
|
812
|
-
"inventory_request_path": evidence_relative(evidence_dir, request_path),
|
|
813
|
-
"inventory_request_sha256": file_sha256(request_path),
|
|
814
|
-
"categories": output_categories,
|
|
815
|
-
"changed_files": changed_files,
|
|
816
|
-
"module_roots": module_roots,
|
|
817
|
-
"module_root_files": module_root_files,
|
|
818
|
-
"module_root_changed_files": explicit_changed,
|
|
819
|
-
"exclusions": sorted(exclusion_paths),
|
|
820
|
-
"discovery_evidence": request["discovery_evidence"],
|
|
821
|
-
"plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
|
|
822
|
-
}
|
|
823
|
-
output_path = confined(evidence_dir, output_name)
|
|
824
|
-
write_json_atomic(output_path, output)
|
|
825
|
-
return output_path
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
def run_process(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> tuple[int, bytes, bytes]:
|
|
829
|
-
timeout_value = max_timeout(timeout)
|
|
830
|
-
creationflags = 0
|
|
831
|
-
process_kwargs: dict[str, Any] = {}
|
|
832
|
-
if os.name == "nt":
|
|
833
|
-
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
834
|
-
else:
|
|
835
|
-
process_kwargs["start_new_session"] = True
|
|
836
|
-
process = subprocess.Popen(
|
|
837
|
-
argv, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
838
|
-
creationflags=creationflags, **process_kwargs,
|
|
839
|
-
)
|
|
840
|
-
try:
|
|
841
|
-
stdout, stderr = process.communicate(timeout=timeout_value)
|
|
842
|
-
return process.returncode, stdout, stderr
|
|
843
|
-
except subprocess.TimeoutExpired:
|
|
844
|
-
if os.name == "nt":
|
|
845
|
-
subprocess.run(
|
|
846
|
-
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
847
|
-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
|
848
|
-
)
|
|
849
|
-
else:
|
|
850
|
-
try:
|
|
851
|
-
os.killpg(process.pid, signal.SIGTERM)
|
|
852
|
-
process.wait(timeout=1)
|
|
853
|
-
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
854
|
-
try:
|
|
855
|
-
os.killpg(process.pid, signal.SIGKILL)
|
|
856
|
-
except ProcessLookupError:
|
|
857
|
-
pass
|
|
858
|
-
stdout, stderr = process.communicate()
|
|
859
|
-
return 124, stdout, stderr + f"\nPrizmKit runner timeout after {timeout_value:g} seconds\n".encode()
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
def limit_output(value: bytes, label: str) -> bytes:
|
|
863
|
-
if len(value) <= MAX_EXECUTION_OUTPUT_BYTES:
|
|
864
|
-
return value
|
|
865
|
-
marker = f"\nPrizmKit runner {label} truncated after {MAX_EXECUTION_OUTPUT_BYTES} bytes\n".encode()
|
|
866
|
-
return value[:MAX_EXECUTION_OUTPUT_BYTES - len(marker)] + marker
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
def run_probe(argv: list[str], cwd: Path, environment: dict[str, str], timeout: float | None) -> dict[str, Any]:
|
|
870
|
-
try:
|
|
871
|
-
exit_code, stdout, stderr = run_process(argv, cwd, environment, timeout)
|
|
872
|
-
return {
|
|
873
|
-
"command": argv, "exit_code": exit_code,
|
|
874
|
-
"stdout": limit_output(stdout, "probe stdout").decode(errors="replace"),
|
|
875
|
-
"stderr": limit_output(stderr, "probe stderr").decode(errors="replace"),
|
|
876
|
-
}
|
|
877
|
-
except OSError as exc:
|
|
878
|
-
return {"command": argv, "error": str(exc)}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
def append_receipt(evidence_dir: Path, receipt: dict[str, Any]) -> None:
|
|
882
|
-
receipt_path = evidence_dir / "receipts" / f"{receipt['execution_id']}.json"
|
|
883
|
-
if receipt_path.exists():
|
|
884
|
-
raise RequestError(f"receipt already exists: {receipt_path.name}")
|
|
885
|
-
write_json_atomic(receipt_path, receipt)
|
|
886
|
-
path = evidence_dir / "executions.json"
|
|
887
|
-
existing: Any = load_json(path) if path.exists() else []
|
|
888
|
-
if not isinstance(existing, list):
|
|
889
|
-
raise RequestError("executions.json must remain an array")
|
|
890
|
-
existing.append(receipt)
|
|
891
|
-
write_json_atomic(path, existing)
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
def ensure_runner_snapshot(evidence_dir: Path) -> tuple[str, str]:
|
|
895
|
-
source = Path(__file__).resolve()
|
|
896
|
-
digest = file_sha256(source)
|
|
897
|
-
relative = f"runner/build_test_evidence-{digest}.py"
|
|
898
|
-
destination = evidence_dir / relative
|
|
899
|
-
if destination.exists():
|
|
900
|
-
if file_sha256(destination) != digest:
|
|
901
|
-
raise RequestError("archived runner snapshot hash mismatch")
|
|
902
|
-
else:
|
|
903
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
904
|
-
shutil.copy2(source, destination)
|
|
905
|
-
return relative, digest
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
def execute_request(
|
|
909
|
-
project_root: Path,
|
|
910
|
-
evidence_dir: Path,
|
|
911
|
-
request_path: Path,
|
|
912
|
-
*,
|
|
913
|
-
replay_of: str | None = None,
|
|
914
|
-
selected_execution: bool = True,
|
|
915
|
-
) -> dict[str, Any]:
|
|
916
|
-
request_value = load_json(request_path)
|
|
917
|
-
original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
|
|
918
|
-
original_binding_fields = {
|
|
919
|
-
key for key in ("test_ids", "test_paths", "auto_bind")
|
|
920
|
-
if isinstance(request_value, dict) and key in request_value
|
|
921
|
-
}
|
|
922
|
-
request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
|
|
923
|
-
request = resolve_execution_test_ids(project_root, evidence_dir, request)
|
|
924
|
-
if original_binding_fields != {"test_ids"} or original_test_ids != request["test_ids"]:
|
|
925
|
-
write_json_atomic(request_path, request)
|
|
926
|
-
cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
927
|
-
complete_environment = {
|
|
928
|
-
key: value for key, value in os.environ.items()
|
|
929
|
-
if key in {"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "GOMODCACHE", "GOPATH", "GOCACHE", "NPM_CONFIG_CACHE", "PIP_CACHE_DIR"}
|
|
930
|
-
}
|
|
931
|
-
complete_environment.setdefault("PATH", os.defpath)
|
|
932
|
-
if os.name == "nt" and os.environ.get("SYSTEMROOT"):
|
|
933
|
-
complete_environment["SYSTEMROOT"] = os.environ["SYSTEMROOT"]
|
|
934
|
-
complete_environment.update(request["environment"])
|
|
935
|
-
timeout = max_timeout(request.get("timeout_seconds"))
|
|
936
|
-
tools = {
|
|
937
|
-
name: run_probe(argv, cwd, complete_environment, timeout)
|
|
938
|
-
for name, argv in request["tool_version_commands"].items()
|
|
939
|
-
}
|
|
940
|
-
probes_reliable = all(
|
|
941
|
-
isinstance(result, dict)
|
|
942
|
-
and result.get("exit_code") == 0
|
|
943
|
-
and "error" not in result
|
|
944
|
-
for result in tools.values()
|
|
945
|
-
)
|
|
946
|
-
execution_id = str(uuid.uuid4())
|
|
947
|
-
runner_path, runner_hash = ensure_runner_snapshot(evidence_dir)
|
|
948
|
-
raw_dir = evidence_dir / "raw"
|
|
949
|
-
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
950
|
-
stdout_path = raw_dir / f"{execution_id}.stdout"
|
|
951
|
-
stderr_path = raw_dir / f"{execution_id}.stderr"
|
|
952
|
-
started = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
953
|
-
timed_out = False
|
|
954
|
-
output_limited = False
|
|
955
|
-
execution_error = False
|
|
956
|
-
try:
|
|
957
|
-
exit_code, stdout, stderr = run_process(
|
|
958
|
-
request["command"], cwd, complete_environment, timeout,
|
|
959
|
-
)
|
|
960
|
-
timed_out = exit_code == 124 and b"PrizmKit runner timeout" in stderr
|
|
961
|
-
output_limited = (
|
|
962
|
-
len(stdout) > MAX_EXECUTION_OUTPUT_BYTES
|
|
963
|
-
or len(stderr) > MAX_EXECUTION_OUTPUT_BYTES
|
|
964
|
-
)
|
|
965
|
-
except OSError as exc:
|
|
966
|
-
exit_code = 127
|
|
967
|
-
stdout = b""
|
|
968
|
-
stderr = f"PrizmKit runner execution error: {exc}\n".encode()
|
|
969
|
-
execution_error = True
|
|
970
|
-
stdout_path.write_bytes(limit_output(stdout, "stdout"))
|
|
971
|
-
stderr_path.write_bytes(limit_output(stderr, "stderr"))
|
|
972
|
-
finished = dt.datetime.now(dt.timezone.utc).isoformat()
|
|
973
|
-
existing = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
|
|
974
|
-
if not isinstance(existing, list):
|
|
975
|
-
raise RequestError("executions.json must remain an array")
|
|
976
|
-
same_request_attempts = sum(1 for item in existing if isinstance(item, dict) and item.get("request_sha256") == file_sha256(request_path))
|
|
977
|
-
receipt = {
|
|
978
|
-
"receipt_format": "prizmkit-runner-generated-v1",
|
|
979
|
-
"runner_path": runner_path,
|
|
980
|
-
"runner_sha256": runner_hash,
|
|
981
|
-
"execution_id": execution_id,
|
|
982
|
-
"attempt_index": same_request_attempts + 1,
|
|
983
|
-
"request_path": evidence_relative(evidence_dir, request_path),
|
|
984
|
-
"request_sha256": file_sha256(request_path),
|
|
985
|
-
"purpose": request["purpose"],
|
|
986
|
-
"command": request["command"],
|
|
987
|
-
"cwd": request["cwd"],
|
|
988
|
-
"environment": complete_environment,
|
|
989
|
-
"tool_versions": tools,
|
|
990
|
-
"layer": request["layer"],
|
|
991
|
-
"test_ids": request["test_ids"],
|
|
992
|
-
"external_targets": request["external_targets"],
|
|
993
|
-
"timeout_seconds": timeout,
|
|
994
|
-
"exit_code": exit_code,
|
|
995
|
-
"stdout_path": evidence_relative(evidence_dir, stdout_path),
|
|
996
|
-
"stderr_path": evidence_relative(evidence_dir, stderr_path),
|
|
997
|
-
"stdout_sha256": file_sha256(stdout_path),
|
|
998
|
-
"stderr_sha256": file_sha256(stderr_path),
|
|
999
|
-
"selected_execution": selected_execution,
|
|
1000
|
-
"reliable": probes_reliable and not timed_out and not output_limited and not execution_error,
|
|
1001
|
-
"started_at": started,
|
|
1002
|
-
"finished_at": finished,
|
|
1003
|
-
"replay_of": replay_of,
|
|
1004
|
-
"isolation": {"kind": "project-direct"},
|
|
1005
|
-
}
|
|
1006
|
-
append_receipt(evidence_dir, receipt)
|
|
1007
|
-
return receipt
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
def run_capture_change(
|
|
1011
|
-
project_root: Path,
|
|
1012
|
-
evidence_dir: Path,
|
|
1013
|
-
baseline: str | None,
|
|
1014
|
-
output_name: str,
|
|
1015
|
-
) -> Path:
|
|
1016
|
-
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1017
|
-
expected_baseline = lifecycle.get("baseline_commit")
|
|
1018
|
-
resolved_baseline = git_baseline(project_root, baseline)
|
|
1019
|
-
if isinstance(expected_baseline, str) and resolved_baseline != expected_baseline:
|
|
1020
|
-
raise RequestError(
|
|
1021
|
-
"capture baseline does not match the baseline pinned during init"
|
|
1022
|
-
)
|
|
1023
|
-
patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
|
|
1024
|
-
patch_path = confined(evidence_dir, "source-change.patch")
|
|
1025
|
-
patch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1026
|
-
patch_path.write_bytes(patch)
|
|
1027
|
-
capture = {
|
|
1028
|
-
"capture_format": "prizmkit-canonical-change-v1",
|
|
1029
|
-
"baseline_commit": resolved_baseline,
|
|
1030
|
-
"patch_path": evidence_relative(evidence_dir, patch_path),
|
|
1031
|
-
"patch_sha256": file_sha256(patch_path),
|
|
1032
|
-
"changed_files": status_entries,
|
|
1033
|
-
}
|
|
1034
|
-
output = confined(evidence_dir, output_name)
|
|
1035
|
-
write_json_atomic(output, capture)
|
|
1036
|
-
return output
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
|
|
1040
|
-
require_canonical_evidence_dir(project_root, evidence_dir)
|
|
1041
|
-
align_protocol_state(project_root, protocol_run_key(project_root, baseline))
|
|
1042
|
-
require_protocol_budget(project_root, package_initialization=True)
|
|
1043
|
-
if evidence_dir.exists() and any(evidence_dir.iterdir()):
|
|
1044
|
-
if (evidence_dir / "manifest.json").exists():
|
|
1045
|
-
raise RequestError("evidence package is finalized; initialize a new evidence directory")
|
|
1046
|
-
raise RequestError("evidence directory is not empty; initialize a new evidence directory")
|
|
1047
|
-
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
1048
|
-
resolved_baseline = git_baseline(project_root, baseline)
|
|
1049
|
-
update_protocol_state(project_root, event="package-initialized", increment="package_initializations")
|
|
1050
|
-
for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
|
|
1051
|
-
(evidence_dir / directory).mkdir(parents=True, exist_ok=True)
|
|
1052
|
-
write_json_atomic(evidence_dir / "executions.json", [])
|
|
1053
|
-
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1054
|
-
"lifecycle_format": "prizmkit-canonical-lifecycle-v1",
|
|
1055
|
-
"status": "initialized",
|
|
1056
|
-
"baseline_commit": resolved_baseline,
|
|
1057
|
-
"artifact_dir": None,
|
|
1058
|
-
"finalized": False,
|
|
1059
|
-
"immutable_identity": None,
|
|
1060
|
-
})
|
|
1061
|
-
output = confined(evidence_dir, output_name)
|
|
1062
|
-
write_json_atomic(output, {
|
|
1063
|
-
"command": "init",
|
|
1064
|
-
"evidence_dir": ".",
|
|
1065
|
-
"baseline_commit": resolved_baseline,
|
|
1066
|
-
"next": "capture-change",
|
|
1067
|
-
})
|
|
1068
|
-
return output
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
def protocol_state_path(project_root: Path) -> Path:
|
|
1072
|
-
return canonical_evidence_root(project_root) / PROTOCOL_STATE_NAME
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
def align_protocol_state(project_root: Path, run_key: str) -> None:
|
|
1076
|
-
state_path = protocol_state_path(project_root)
|
|
1077
|
-
if state_path.exists():
|
|
1078
|
-
state = ensure_object(load_json(state_path), "protocol recovery state")
|
|
1079
|
-
if state.get("run_key") == run_key:
|
|
1080
|
-
return
|
|
1081
|
-
write_json_atomic(state_path, {"run_key": run_key})
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
def update_protocol_state(
|
|
1085
|
-
project_root: Path,
|
|
1086
|
-
*,
|
|
1087
|
-
event: str,
|
|
1088
|
-
error: str | None = None,
|
|
1089
|
-
increment: str | None = None,
|
|
1090
|
-
) -> dict[str, Any]:
|
|
1091
|
-
state_path = protocol_state_path(project_root)
|
|
1092
|
-
state: dict[str, Any] = {}
|
|
1093
|
-
if state_path.exists():
|
|
1094
|
-
state = ensure_object(load_json(state_path), "protocol recovery state")
|
|
1095
|
-
if increment is not None:
|
|
1096
|
-
count = state.get(increment, 0)
|
|
1097
|
-
if not isinstance(count, int) or isinstance(count, bool):
|
|
1098
|
-
count = 0
|
|
1099
|
-
state[increment] = count + 1
|
|
1100
|
-
state["last_event"] = event
|
|
1101
|
-
state["last_error"] = error
|
|
1102
|
-
state["max_package_initializations"] = MAX_PROTOCOL_PACKAGE_ATTEMPTS
|
|
1103
|
-
state["max_preflight_failures"] = MAX_PROTOCOL_PACKAGE_ATTEMPTS
|
|
1104
|
-
state["blocked"] = (
|
|
1105
|
-
state.get("package_initializations", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS
|
|
1106
|
-
or state.get("preflight_failures", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS
|
|
1107
|
-
)
|
|
1108
|
-
write_json_atomic(state_path, state)
|
|
1109
|
-
return state
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
def require_protocol_budget(project_root: Path, *, package_initialization: bool = False) -> None:
|
|
1113
|
-
state_path = protocol_state_path(project_root)
|
|
1114
|
-
if not state_path.exists():
|
|
1115
|
-
return
|
|
1116
|
-
state = ensure_object(load_json(state_path), "protocol recovery state")
|
|
1117
|
-
if package_initialization and state.get("package_initializations", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS:
|
|
1118
|
-
raise RequestError(
|
|
1119
|
-
"evidence protocol package budget exhausted; stop and return TEST_BLOCKED "
|
|
1120
|
-
"instead of creating another evidence package"
|
|
1121
|
-
)
|
|
1122
|
-
if state.get("preflight_failures", 0) >= MAX_PROTOCOL_PACKAGE_ATTEMPTS:
|
|
1123
|
-
raise RequestError(
|
|
1124
|
-
"evidence protocol preflight budget exhausted; return TEST_BLOCKED "
|
|
1125
|
-
"instead of continuing semantic record repair"
|
|
1126
|
-
)
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
def protocol_run_key(project_root: Path, baseline: str | None) -> str:
|
|
1130
|
-
resolved_baseline = git_baseline(project_root, baseline)
|
|
1131
|
-
patch, _ = canonical_change_patch(project_root, resolved_baseline)
|
|
1132
|
-
return canonical_sha256({
|
|
1133
|
-
"baseline_commit": resolved_baseline,
|
|
1134
|
-
"working_diff_sha256": hashlib.sha256(patch).hexdigest(),
|
|
1135
|
-
})
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
def semantic_record_paths(change_class: str) -> set[str]:
|
|
1139
|
-
common = {"change-classification.json", "scope.json"}
|
|
1140
|
-
if change_class == "behavior":
|
|
1141
|
-
return common | {"behavior-risk-matrix.json", "infrastructure-changes.json"}
|
|
1142
|
-
return common | {"lightweight-verification.json"}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
def require_semantic_record_files(evidence_dir: Path, change_class: str) -> None:
|
|
1146
|
-
missing = sorted(
|
|
1147
|
-
relative for relative in semantic_record_paths(change_class)
|
|
1148
|
-
if not (evidence_dir / relative).is_file()
|
|
1149
|
-
)
|
|
1150
|
-
if missing:
|
|
1151
|
-
raise RequestError(
|
|
1152
|
-
"pre-finalize semantic records are incomplete: " + ", ".join(missing)
|
|
1153
|
-
+ "; keep this package mutable and repair it before finalize"
|
|
1154
|
-
)
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
def preflight_semantic_records(evidence_dir: Path, change_class: str) -> None:
|
|
1158
|
-
require_semantic_record_files(evidence_dir, change_class)
|
|
1159
|
-
validator_path = Path(__file__).resolve().with_name("validate_test_evidence.py")
|
|
1160
|
-
try:
|
|
1161
|
-
import importlib.util
|
|
1162
|
-
spec = importlib.util.spec_from_file_location("prizmkit_test_validator_preflight", validator_path)
|
|
1163
|
-
if spec is None or spec.loader is None:
|
|
1164
|
-
raise RequestError("cannot load semantic preflight validator")
|
|
1165
|
-
module = importlib.util.module_from_spec(spec)
|
|
1166
|
-
spec.loader.exec_module(module)
|
|
1167
|
-
records_schema = module.load_schema("authoritative-records.schema.json")
|
|
1168
|
-
matrix_schema = module.load_schema("behavior-risk-matrix.schema.json")
|
|
1169
|
-
errors: list[str] = []
|
|
1170
|
-
definitions = {
|
|
1171
|
-
"change-classification.json": "changeClassification",
|
|
1172
|
-
"scope.json": "scope",
|
|
1173
|
-
"infrastructure-changes.json": "infrastructureChanges",
|
|
1174
|
-
"lightweight-verification.json": "lightweightVerification",
|
|
1175
|
-
}
|
|
1176
|
-
for relative in sorted(semantic_record_paths(change_class)):
|
|
1177
|
-
value = load_json(evidence_dir / relative)
|
|
1178
|
-
if relative == "behavior-risk-matrix.json":
|
|
1179
|
-
module.validate_schema(value, matrix_schema, matrix_schema, "behavior-risk-matrix", errors)
|
|
1180
|
-
else:
|
|
1181
|
-
module.validate_definition(value, definitions[relative], records_schema, relative, errors)
|
|
1182
|
-
if errors:
|
|
1183
|
-
raise RequestError("semantic preflight failed: " + "; ".join(errors[:20]))
|
|
1184
|
-
except ImportError as exc:
|
|
1185
|
-
raise RequestError(f"cannot run semantic preflight: {exc}") from exc
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
def selected_layer_blockers(plan: dict[str, Any] | None, executions: list[Any]) -> list[str]:
|
|
1189
|
-
if not isinstance(plan, dict):
|
|
1190
|
-
return []
|
|
1191
|
-
selected_layers = {
|
|
1192
|
-
item.get("layer")
|
|
1193
|
-
for item in executions
|
|
1194
|
-
if isinstance(item, dict) and item.get("selected_execution") is True
|
|
1195
|
-
}
|
|
1196
|
-
blockers: list[str] = []
|
|
1197
|
-
for layer in plan.get("layers", []):
|
|
1198
|
-
if not isinstance(layer, dict) or layer.get("required") is not True:
|
|
1199
|
-
continue
|
|
1200
|
-
name = layer.get("name")
|
|
1201
|
-
if name not in selected_layers:
|
|
1202
|
-
blockers.append(f"required layer has no selected execution: {name}")
|
|
1203
|
-
return blockers
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
|
|
1207
|
-
path = evidence_dir / "lifecycle.json"
|
|
1208
|
-
if not path.exists():
|
|
1209
|
-
if required:
|
|
1210
|
-
raise RequestError("evidence package is not initialized; run init first")
|
|
1211
|
-
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
1212
|
-
return ensure_object(load_json(path), "lifecycle")
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
|
|
1216
|
-
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
1217
|
-
if not lifecycle_path.exists():
|
|
1218
|
-
if (evidence_dir / "manifest.json").exists():
|
|
1219
|
-
raise RequestError(
|
|
1220
|
-
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
1221
|
-
)
|
|
1222
|
-
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
1223
|
-
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1224
|
-
if lifecycle.get("finalized") is True:
|
|
1225
|
-
raise RequestError(
|
|
1226
|
-
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
1227
|
-
)
|
|
1228
|
-
return lifecycle
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
def run_render_report(evidence_dir: Path, output_name: str) -> Path:
|
|
1232
|
-
manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
|
|
1233
|
-
verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
|
|
1234
|
-
validation_path = evidence_dir / "validation.json"
|
|
1235
|
-
validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
|
|
1236
|
-
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
1237
|
-
lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
|
|
1238
|
-
executions = load_json(evidence_dir / "executions.json")
|
|
1239
|
-
artifact_dir = lifecycle.get("artifact_dir")
|
|
1240
|
-
lines = [
|
|
1241
|
-
"# Test Evidence Report",
|
|
1242
|
-
"",
|
|
1243
|
-
"Derived from structured evidence",
|
|
1244
|
-
f"Evidence ID: {manifest.get('evidence_id')}",
|
|
1245
|
-
f"Verdict: {verdict.get('verdict')}",
|
|
1246
|
-
f"Validator result: {validation.get('result') if validation else 'pending'}",
|
|
1247
|
-
"sensitivity=project-controlled",
|
|
1248
|
-
"The project owns access control, retention, and upload policy.",
|
|
1249
|
-
"Mocked code-level evidence does not verify a real deployed environment.",
|
|
1250
|
-
"",
|
|
1251
|
-
"## Scope",
|
|
1252
|
-
f"- Artifact Dir: {artifact_dir or 'not provided'}",
|
|
1253
|
-
"",
|
|
1254
|
-
f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
|
|
1255
|
-
"The legacy test-report interface is not supported.",
|
|
1256
|
-
"",
|
|
1257
|
-
]
|
|
1258
|
-
output = confined(evidence_dir, output_name)
|
|
1259
|
-
rendered = "\n".join(lines)
|
|
1260
|
-
lifecycle_finalized = lifecycle.get("finalized") is True
|
|
1261
|
-
if lifecycle_finalized:
|
|
1262
|
-
if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
|
|
1263
|
-
raise RequestError(
|
|
1264
|
-
"evidence package is finalized and immutable; re-init a new package before changing the report"
|
|
1265
|
-
)
|
|
1266
|
-
return output
|
|
1267
|
-
write_text_atomic(output, rendered)
|
|
1268
|
-
return output
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
def aggregate_inventory(entries: list[dict[str, str]]) -> str:
|
|
1272
|
-
return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
|
|
1276
|
-
capture = load_change_capture(Path("."), evidence_dir)
|
|
1277
|
-
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1278
|
-
test_paths = {
|
|
1279
|
-
item.get("path")
|
|
1280
|
-
for item in inventory.get("categories", {}).get("tests", [])
|
|
1281
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1282
|
-
}
|
|
1283
|
-
statuses: dict[str, str] = {}
|
|
1284
|
-
for entry in capture.get("changed_files", []):
|
|
1285
|
-
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
1286
|
-
continue
|
|
1287
|
-
relative = entry["path"]
|
|
1288
|
-
if relative not in test_paths or entry.get("role") == "source":
|
|
1289
|
-
continue
|
|
1290
|
-
status = str(entry.get("status", "")).strip()
|
|
1291
|
-
if status == "??" or status.startswith("A"):
|
|
1292
|
-
statuses[relative] = "added"
|
|
1293
|
-
elif status.startswith("M") or status.endswith("M"):
|
|
1294
|
-
statuses[relative] = "modified"
|
|
1295
|
-
elif status.startswith("R") or status.startswith("C"):
|
|
1296
|
-
statuses[relative] = "modified"
|
|
1297
|
-
return statuses
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
def stable_test_id(project_path: str, selector: str | None = None) -> str:
|
|
1301
|
-
identity = project_path if not selector else f"{project_path}::{selector}"
|
|
1302
|
-
return f"native-test-{canonical_sha256(identity)[:16]}"
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
1306
|
-
layers = request.get("layers")
|
|
1307
|
-
if layers is None and isinstance(existing_plan, dict):
|
|
1308
|
-
layers = existing_plan.get("layers")
|
|
1309
|
-
if not isinstance(layers, list) or len(layers) != len(LAYERS):
|
|
1310
|
-
raise RequestError("test preparation must assess all five layers exactly once")
|
|
1311
|
-
normalized: list[dict[str, Any]] = []
|
|
1312
|
-
seen: set[str] = set()
|
|
1313
|
-
for item in layers:
|
|
1314
|
-
if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
|
|
1315
|
-
raise RequestError("test preparation layer entries require name, required, and not_applicable")
|
|
1316
|
-
name = item.get("name")
|
|
1317
|
-
if name not in LAYERS or name in seen:
|
|
1318
|
-
raise RequestError(f"invalid or duplicate test preparation layer: {name}")
|
|
1319
|
-
seen.add(name)
|
|
1320
|
-
if not isinstance(item.get("required"), bool):
|
|
1321
|
-
raise RequestError(f"test preparation layer required must be boolean: {name}")
|
|
1322
|
-
if item["required"] is True and item.get("not_applicable") is not None:
|
|
1323
|
-
raise RequestError(f"required test preparation layer cannot be N/A: {name}")
|
|
1324
|
-
if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
|
|
1325
|
-
raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
|
|
1326
|
-
normalized.append(item)
|
|
1327
|
-
return normalized
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
|
|
1331
|
-
request = ensure_object(load_json(request_path), "test preparation request")
|
|
1332
|
-
allowed = {"request_version", "layers", "tests"}
|
|
1333
|
-
if set(request) != allowed or request.get("request_version") != "1.0":
|
|
1334
|
-
raise RequestError("test preparation request must contain request_version, layers, and tests only")
|
|
1335
|
-
requested = request.get("tests")
|
|
1336
|
-
if not isinstance(requested, list) or not requested:
|
|
1337
|
-
raise RequestError("test preparation request tests must be a non-empty array")
|
|
1338
|
-
existing_plan_path = evidence_dir / "test-plan.json"
|
|
1339
|
-
existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
|
|
1340
|
-
prepared_layers = prepare_test_layers(request, existing_plan)
|
|
1341
|
-
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1342
|
-
inventory_paths = {
|
|
1343
|
-
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1344
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1345
|
-
}
|
|
1346
|
-
changed = changed_test_statuses(evidence_dir)
|
|
1347
|
-
changed_native_tests = set(changed) & inventory_paths
|
|
1348
|
-
selected_paths: set[str] = set()
|
|
1349
|
-
normalized: list[dict[str, Any]] = []
|
|
1350
|
-
for item in requested:
|
|
1351
|
-
if not isinstance(item, dict):
|
|
1352
|
-
raise RequestError("test preparation entries must be objects")
|
|
1353
|
-
allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
|
|
1354
|
-
if set(item) - allowed_item:
|
|
1355
|
-
raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
|
|
1356
|
-
project_path = normalize_relative(item.get("project_path", ""))
|
|
1357
|
-
if project_path in selected_paths:
|
|
1358
|
-
raise RequestError(f"duplicate native test path: {project_path}")
|
|
1359
|
-
selected_paths.add(project_path)
|
|
1360
|
-
if project_path not in inventory_paths:
|
|
1361
|
-
raise RequestError(f"native test is not in the test inventory: {project_path}")
|
|
1362
|
-
source = confined(project_root, project_path, must_exist=True)
|
|
1363
|
-
behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
|
|
1364
|
-
layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
|
|
1365
|
-
unknown_layers = sorted(set(layers) - set(LAYERS))
|
|
1366
|
-
if unknown_layers:
|
|
1367
|
-
raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
|
|
1368
|
-
if len(layers) != len(set(layers)):
|
|
1369
|
-
raise RequestError(f"test {project_path} contains duplicate layers")
|
|
1370
|
-
selector = item.get("selector")
|
|
1371
|
-
if selector is not None and not isinstance(selector, str):
|
|
1372
|
-
raise RequestError(f"test {project_path} selector must be a string")
|
|
1373
|
-
status = changed.get(project_path, "existing")
|
|
1374
|
-
entry = {
|
|
1375
|
-
"id": stable_test_id(project_path, selector),
|
|
1376
|
-
"behavior_ids": behavior_ids,
|
|
1377
|
-
"change_status": status,
|
|
1378
|
-
"project_path": project_path,
|
|
1379
|
-
"snapshot_path": None,
|
|
1380
|
-
"inventory_path": project_path,
|
|
1381
|
-
"layers": layers,
|
|
1382
|
-
}
|
|
1383
|
-
if selector is not None:
|
|
1384
|
-
entry["selector"] = selector
|
|
1385
|
-
if isinstance(item.get("runner"), str):
|
|
1386
|
-
entry["runner"] = item["runner"]
|
|
1387
|
-
normalized.append(entry)
|
|
1388
|
-
unbound = sorted(changed_native_tests - selected_paths)
|
|
1389
|
-
if unbound:
|
|
1390
|
-
raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
|
|
1391
|
-
generated = evidence_dir / "generated-tests"
|
|
1392
|
-
generated.mkdir(parents=True, exist_ok=True)
|
|
1393
|
-
used_snapshots: set[str] = set()
|
|
1394
|
-
for entry in normalized:
|
|
1395
|
-
if entry["change_status"] in {"added", "modified"}:
|
|
1396
|
-
destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
|
|
1397
|
-
if destination_relative in used_snapshots:
|
|
1398
|
-
destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
|
|
1399
|
-
destination = confined(evidence_dir, destination_relative)
|
|
1400
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1401
|
-
shutil.copy2(project_root / entry["project_path"], destination)
|
|
1402
|
-
entry["snapshot_path"] = destination_relative
|
|
1403
|
-
used_snapshots.add(destination_relative)
|
|
1404
|
-
plan = {
|
|
1405
|
-
"target_hashes": {},
|
|
1406
|
-
"plan_inputs": inventory.get("plan_inputs", {}),
|
|
1407
|
-
"layers": prepared_layers,
|
|
1408
|
-
"tests": normalized,
|
|
1409
|
-
}
|
|
1410
|
-
existing_plan = evidence_dir / "test-plan.json"
|
|
1411
|
-
if existing_plan.is_file():
|
|
1412
|
-
prior = ensure_object(load_json(existing_plan), "existing test plan")
|
|
1413
|
-
plan["target_hashes"] = prior.get("target_hashes", {})
|
|
1414
|
-
plan["layers"] = prior.get("layers", prepared_layers)
|
|
1415
|
-
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1416
|
-
write_json_atomic(evidence_dir / output_name, {
|
|
1417
|
-
"format": "prizmkit-native-test-preparation-v1",
|
|
1418
|
-
"request_path": evidence_relative(evidence_dir, request_path),
|
|
1419
|
-
"request_sha256": file_sha256(request_path),
|
|
1420
|
-
"tests": normalized,
|
|
1421
|
-
"changed_test_statuses": changed,
|
|
1422
|
-
})
|
|
1423
|
-
return evidence_dir / output_name
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
|
|
1427
|
-
tests = plan.get("tests")
|
|
1428
|
-
if not isinstance(tests, list):
|
|
1429
|
-
return
|
|
1430
|
-
captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
|
|
1431
|
-
inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
|
|
1432
|
-
inventory_paths = {
|
|
1433
|
-
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1434
|
-
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1435
|
-
}
|
|
1436
|
-
selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
|
|
1437
|
-
unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
|
|
1438
|
-
if unbound:
|
|
1439
|
-
raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
|
|
1440
|
-
generated = evidence_dir / "generated-tests"
|
|
1441
|
-
generated.mkdir(parents=True, exist_ok=True)
|
|
1442
|
-
for test in tests:
|
|
1443
|
-
if not isinstance(test, dict):
|
|
1444
|
-
continue
|
|
1445
|
-
project_path = test.get("project_path")
|
|
1446
|
-
if not isinstance(project_path, str):
|
|
1447
|
-
continue
|
|
1448
|
-
status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
|
|
1449
|
-
if status not in {"existing", "added", "modified"}:
|
|
1450
|
-
raise RequestError(f"unknown test change_status: {test.get('id')}")
|
|
1451
|
-
test["change_status"] = status
|
|
1452
|
-
if status == "existing":
|
|
1453
|
-
test["snapshot_path"] = None
|
|
1454
|
-
continue
|
|
1455
|
-
source = confined(project_root, project_path, must_exist=True)
|
|
1456
|
-
destination_relative = test.get("snapshot_path")
|
|
1457
|
-
if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
|
|
1458
|
-
destination_relative = f"generated-tests/{Path(project_path).name}"
|
|
1459
|
-
destination = confined(evidence_dir, destination_relative)
|
|
1460
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1461
|
-
shutil.copy2(source, destination)
|
|
1462
|
-
test["snapshot_path"] = destination_relative
|
|
1463
|
-
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
def stage_output_owner(relative: str, change_class: str) -> str:
|
|
1467
|
-
if relative in {"change-classification.json"}:
|
|
1468
|
-
return "CHANGE_CLASSIFY"
|
|
1469
|
-
if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
|
|
1470
|
-
return "SCOPE_DISCOVER"
|
|
1471
|
-
if relative in {"behavior-risk-matrix.json"}:
|
|
1472
|
-
return "CONTRACT_MODEL"
|
|
1473
|
-
if relative in {"test-plan.json"} or relative == "test-preparation.json":
|
|
1474
|
-
return "TEST_PLAN"
|
|
1475
|
-
if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
|
|
1476
|
-
return "INFRA_READY"
|
|
1477
|
-
if relative.startswith("generated-tests/"):
|
|
1478
|
-
return "TEST_BUILD"
|
|
1479
|
-
if relative == "executions.json" or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
|
|
1480
|
-
return "EXECUTE_PROVE"
|
|
1481
|
-
if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
|
|
1482
|
-
return "EVIDENCE_PACKAGE"
|
|
1483
|
-
return "EVIDENCE_PACKAGE"
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
def build_manifest(evidence_dir: Path, *, evidence_id: str, identity: dict[str, Any], target_hashes: dict[str, str], change_class: str, baseline_commit: str, final_verdict: str, attested: bool = False) -> dict[str, Any]:
|
|
1487
|
-
file_paths = sorted(
|
|
1488
|
-
path.relative_to(evidence_dir).as_posix()
|
|
1489
|
-
for path in evidence_dir.rglob("*")
|
|
1490
|
-
if path.is_file() and path.name != "manifest.json"
|
|
1491
|
-
)
|
|
1492
|
-
files = [
|
|
1493
|
-
{"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
|
|
1494
|
-
for relative in file_paths
|
|
1495
|
-
]
|
|
1496
|
-
outputs_by_stage = {stage: [] for stage in STAGES}
|
|
1497
|
-
for entry in files:
|
|
1498
|
-
outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
|
|
1499
|
-
stages = []
|
|
1500
|
-
for stage in STAGES:
|
|
1501
|
-
outputs = sorted(outputs_by_stage.get(stage, []))
|
|
1502
|
-
stages.append({
|
|
1503
|
-
"name": stage,
|
|
1504
|
-
"status": "complete" if stage != "EVIDENCE_VALIDATE" or attested else "pending",
|
|
1505
|
-
"outputs": outputs,
|
|
1506
|
-
"not_applicable": None,
|
|
1507
|
-
})
|
|
1508
|
-
return {
|
|
1509
|
-
"protocol_version": "1.0",
|
|
1510
|
-
"evidence_id": evidence_id,
|
|
1511
|
-
"evidence_id_inputs": identity,
|
|
1512
|
-
"sensitivity": "project-controlled",
|
|
1513
|
-
"environment_claim": "mocked-code-level-only",
|
|
1514
|
-
"change_class": change_class,
|
|
1515
|
-
"baseline_commit": baseline_commit,
|
|
1516
|
-
"working_diff_sha256": identity["working_diff_sha256"],
|
|
1517
|
-
"target_hashes": target_hashes,
|
|
1518
|
-
"stages": stages,
|
|
1519
|
-
"files": files,
|
|
1520
|
-
"final_verdict": final_verdict,
|
|
1521
|
-
"compatibility": "legacy-test-report-interface-not-supported",
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
|
|
1526
|
-
if not artifact_dir:
|
|
1527
|
-
return
|
|
1528
|
-
artifact = Path(artifact_dir)
|
|
1529
|
-
if not artifact.is_absolute() and project_root is not None:
|
|
1530
|
-
artifact = project_root / artifact
|
|
1531
|
-
artifact = artifact.resolve()
|
|
1532
|
-
if not artifact.is_dir():
|
|
1533
|
-
raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
|
|
1534
|
-
pointer = artifact / "test-report-path.txt"
|
|
1535
|
-
write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
|
|
1539
|
-
require_canonical_evidence_dir(project_root, evidence_dir)
|
|
1540
|
-
require_protocol_budget(project_root)
|
|
1541
|
-
allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
|
|
1542
|
-
if set(request) - allowed:
|
|
1543
|
-
raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
|
|
1544
|
-
if request.get("request_version", "1.0") != "1.0":
|
|
1545
|
-
raise RequestError("unsupported finalize request version")
|
|
1546
|
-
if request.get("repair_scope") is not None and request.get("repair_scope") not in REPAIR_SCOPES:
|
|
1547
|
-
raise RequestError("repair_scope must identify an evidence-backed repair domain")
|
|
1548
|
-
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1549
|
-
if lifecycle.get("finalized") is True:
|
|
1550
|
-
raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
|
|
1551
|
-
capture_path = evidence_dir / "change-capture.json"
|
|
1552
|
-
if not capture_path.exists():
|
|
1553
|
-
run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
|
|
1554
|
-
capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
|
|
1555
|
-
lifecycle_baseline = lifecycle.get("baseline_commit")
|
|
1556
|
-
baseline = capture.get("baseline_commit")
|
|
1557
|
-
if not isinstance(lifecycle_baseline, str) or baseline != lifecycle_baseline:
|
|
1558
|
-
raise RequestError("canonical change capture baseline does not match init baseline")
|
|
1559
|
-
patch_path = evidence_dir / "source-change.patch"
|
|
1560
|
-
if not isinstance(baseline, str) or not patch_path.is_file():
|
|
1561
|
-
raise RequestError("canonical change capture is incomplete")
|
|
1562
|
-
change_class = request.get("change_class", "behavior")
|
|
1563
|
-
if change_class not in {"behavior", "lightweight"}:
|
|
1564
|
-
raise RequestError("change_class must be behavior or lightweight")
|
|
1565
|
-
try:
|
|
1566
|
-
require_semantic_record_files(evidence_dir, change_class)
|
|
1567
|
-
except RequestError as exc:
|
|
1568
|
-
state = update_protocol_state(
|
|
1569
|
-
project_root,
|
|
1570
|
-
event="preflight-failed",
|
|
1571
|
-
error=str(exc),
|
|
1572
|
-
increment="preflight_failures",
|
|
1573
|
-
)
|
|
1574
|
-
if state.get("blocked"):
|
|
1575
|
-
raise RequestError(
|
|
1576
|
-
str(exc) + "; evidence protocol recovery budget exhausted; return TEST_BLOCKED "
|
|
1577
|
-
"and do not initialize another package"
|
|
1578
|
-
) from exc
|
|
1579
|
-
raise
|
|
1580
|
-
|
|
1581
|
-
artifact_dir = request.get("artifact_dir")
|
|
1582
|
-
if artifact_dir is not None and not isinstance(artifact_dir, str):
|
|
1583
|
-
raise RequestError("artifact_dir must be a project-relative directory")
|
|
1584
|
-
if artifact_dir is not None:
|
|
1585
|
-
artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
|
|
1586
|
-
lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
|
|
1587
|
-
scope_relative = request.get("scope_path", "scope.json")
|
|
1588
|
-
scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
|
|
1589
|
-
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1590
|
-
environment_path = evidence_dir / request.get("environment_path", "environment.json")
|
|
1591
|
-
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
1592
|
-
plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
|
|
1593
|
-
if isinstance(plan, dict):
|
|
1594
|
-
normalize_test_snapshots(project_root, evidence_dir, plan)
|
|
1595
|
-
categories = inventory.get("categories", {})
|
|
1596
|
-
target_hashes = {
|
|
1597
|
-
category: aggregate_inventory(categories.get(category, []))
|
|
1598
|
-
for category in CATEGORIES
|
|
1599
|
-
}
|
|
1600
|
-
target_hashes["environment"] = canonical_sha256(environment)
|
|
1601
|
-
target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
|
|
1602
|
-
if isinstance(plan, dict):
|
|
1603
|
-
plan["target_hashes"] = target_hashes
|
|
1604
|
-
plan["plan_inputs"] = inventory.get("plan_inputs", {})
|
|
1605
|
-
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1606
|
-
scope["target_hashes"] = target_hashes
|
|
1607
|
-
write_json_atomic(evidence_dir / scope_relative, scope)
|
|
1608
|
-
try:
|
|
1609
|
-
preflight_semantic_records(evidence_dir, change_class)
|
|
1610
|
-
except RequestError as exc:
|
|
1611
|
-
state = update_protocol_state(
|
|
1612
|
-
project_root,
|
|
1613
|
-
event="preflight-failed",
|
|
1614
|
-
error=str(exc),
|
|
1615
|
-
increment="preflight_failures",
|
|
1616
|
-
)
|
|
1617
|
-
if state.get("blocked"):
|
|
1618
|
-
raise RequestError(
|
|
1619
|
-
str(exc) + "; evidence protocol recovery budget exhausted; return TEST_BLOCKED "
|
|
1620
|
-
"and do not initialize another package"
|
|
1621
|
-
) from exc
|
|
1622
|
-
raise
|
|
1623
|
-
identity = {
|
|
1624
|
-
"baseline_commit": baseline,
|
|
1625
|
-
"working_diff_sha256": file_sha256(patch_path),
|
|
1626
|
-
"scope_sha256": canonical_sha256(scope),
|
|
1627
|
-
}
|
|
1628
|
-
evidence_id = canonical_sha256(identity)
|
|
1629
|
-
destination = evidence_dir.parent / evidence_id
|
|
1630
|
-
executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
|
|
1631
|
-
blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
|
|
1632
|
-
blockers.extend(selected_layer_blockers(plan, executions if isinstance(executions, list) else []))
|
|
1633
|
-
required_records = {
|
|
1634
|
-
"change-classification.json", "scope.json", "target-inventory.json", "environment.json",
|
|
1635
|
-
"source-change.patch",
|
|
1636
|
-
}
|
|
1637
|
-
if change_class == "behavior":
|
|
1638
|
-
required_records.update({
|
|
1639
|
-
"behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
|
|
1640
|
-
"infrastructure-changes.json",
|
|
1641
|
-
})
|
|
1642
|
-
else:
|
|
1643
|
-
required_records.add("lightweight-verification.json")
|
|
1644
|
-
missing_records = sorted(
|
|
1645
|
-
relative for relative in required_records
|
|
1646
|
-
if not (evidence_dir / relative).is_file()
|
|
1647
|
-
)
|
|
1648
|
-
if missing_records:
|
|
1649
|
-
state = update_protocol_state(
|
|
1650
|
-
project_root,
|
|
1651
|
-
event="finalize-precondition-failed",
|
|
1652
|
-
error="required evidence records are missing: " + ", ".join(missing_records),
|
|
1653
|
-
increment="preflight_failures",
|
|
1654
|
-
)
|
|
1655
|
-
suffix = ""
|
|
1656
|
-
if state.get("blocked"):
|
|
1657
|
-
suffix = "; evidence protocol recovery budget exhausted; return TEST_BLOCKED"
|
|
1658
|
-
raise RequestError(
|
|
1659
|
-
"pre-finalize package records are incomplete: " + ", ".join(missing_records) + suffix
|
|
1660
|
-
)
|
|
1661
|
-
selected_unreliable = [
|
|
1662
|
-
item for item in executions
|
|
1663
|
-
if isinstance(item, dict)
|
|
1664
|
-
and item.get("selected_execution") is True
|
|
1665
|
-
and item.get("reliable") is not True
|
|
1666
|
-
]
|
|
1667
|
-
for execution in selected_unreliable:
|
|
1668
|
-
blockers.append(
|
|
1669
|
-
"selected execution is unreliable: "
|
|
1670
|
-
+ str(execution.get("execution_id", "unknown-execution"))
|
|
1671
|
-
)
|
|
1672
|
-
selected_failures = [
|
|
1673
|
-
item for item in executions
|
|
1674
|
-
if isinstance(item, dict)
|
|
1675
|
-
and item.get("selected_execution") is True
|
|
1676
|
-
and item.get("reliable") is True
|
|
1677
|
-
and item.get("exit_code") != 0
|
|
1678
|
-
]
|
|
1679
|
-
if selected_failures and not request.get("repair_scope"):
|
|
1680
|
-
blockers.append("reliable failure exists but repair_scope is not evidence-backed")
|
|
1681
|
-
verdict = (
|
|
1682
|
-
"TEST_BLOCKED" if blockers
|
|
1683
|
-
else "TEST_FAIL" if selected_failures and request.get("repair_scope")
|
|
1684
|
-
else "TEST_PASS"
|
|
1685
|
-
)
|
|
1686
|
-
verdict_record: dict[str, Any] = {
|
|
1687
|
-
"verdict": verdict,
|
|
1688
|
-
"testing_domain_only": True,
|
|
1689
|
-
"authorizes_commit_or_release": False,
|
|
1690
|
-
"real_environment_validated": False,
|
|
1691
|
-
"repairs_business_defects": False,
|
|
1692
|
-
"code_evidence_replayable": verdict == "TEST_PASS",
|
|
1693
|
-
"blockers": blockers if verdict == "TEST_BLOCKED" else [],
|
|
1694
|
-
"reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
|
|
1695
|
-
}
|
|
1696
|
-
if verdict == "TEST_FAIL" and request.get("repair_scope"):
|
|
1697
|
-
verdict_record["repair_scope"] = request["repair_scope"]
|
|
1698
|
-
write_json_atomic(evidence_dir / "verdict.json", verdict_record)
|
|
1699
|
-
write_json_atomic(evidence_dir / "validation.json", {})
|
|
1700
|
-
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1701
|
-
**lifecycle,
|
|
1702
|
-
"status": "finalizing",
|
|
1703
|
-
"finalized": False,
|
|
1704
|
-
"immutable_identity": identity,
|
|
1705
|
-
})
|
|
1706
|
-
# First manifest: pinned before render-report so the report can reference it.
|
|
1707
|
-
manifest = build_manifest(
|
|
1708
|
-
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1709
|
-
target_hashes=target_hashes, change_class=change_class,
|
|
1710
|
-
baseline_commit=baseline, final_verdict=verdict,
|
|
1711
|
-
attested=False,
|
|
1712
|
-
)
|
|
1713
|
-
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1714
|
-
run_render_report(evidence_dir, "test-report.md")
|
|
1715
|
-
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1716
|
-
**lifecycle,
|
|
1717
|
-
"status": "payload-finalized",
|
|
1718
|
-
"finalized": False,
|
|
1719
|
-
"immutable_identity": identity,
|
|
1720
|
-
})
|
|
1721
|
-
output = confined(evidence_dir, output_name)
|
|
1722
|
-
final_evidence_relative = destination.relative_to(project_root).as_posix()
|
|
1723
|
-
write_json_atomic(output, {
|
|
1724
|
-
"command": "finalize",
|
|
1725
|
-
"evidence_id": evidence_id,
|
|
1726
|
-
"evidence_dir": final_evidence_relative,
|
|
1727
|
-
"manifest_path": f"{final_evidence_relative}/manifest.json",
|
|
1728
|
-
"verdict_path": f"{final_evidence_relative}/verdict.json",
|
|
1729
|
-
"report_path": f"{final_evidence_relative}/test-report.md",
|
|
1730
|
-
"validation_path": f"{final_evidence_relative}/validation.json",
|
|
1731
|
-
"next": "validate_test_evidence.py --attest",
|
|
1732
|
-
})
|
|
1733
|
-
# Second manifest: re-hashes after render-report produces test-report.md.
|
|
1734
|
-
manifest = build_manifest(
|
|
1735
|
-
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1736
|
-
target_hashes=target_hashes, change_class=change_class,
|
|
1737
|
-
baseline_commit=baseline, final_verdict=verdict,
|
|
1738
|
-
attested=False,
|
|
1739
|
-
)
|
|
1740
|
-
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1741
|
-
if destination != evidence_dir:
|
|
1742
|
-
if destination.exists():
|
|
1743
|
-
try:
|
|
1744
|
-
existing_man = load_json(destination / "manifest.json")
|
|
1745
|
-
if isinstance(existing_man, dict) and existing_man.get("final_verdict") == "TEST_PASS":
|
|
1746
|
-
write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
|
|
1747
|
-
return destination / output_name
|
|
1748
|
-
except Exception:
|
|
1749
|
-
pass
|
|
1750
|
-
raise RequestError(
|
|
1751
|
-
f"evidence identity already exists at {destination}; re-init a new package"
|
|
1752
|
-
)
|
|
1753
|
-
os.replace(evidence_dir, destination)
|
|
1754
|
-
final_evidence_dir = destination if destination != evidence_dir else evidence_dir
|
|
1755
|
-
write_handoff_pointer(final_evidence_dir, lifecycle.get("artifact_dir"), project_root)
|
|
1756
|
-
update_protocol_state(
|
|
1757
|
-
project_root,
|
|
1758
|
-
event="finalized",
|
|
1759
|
-
error="; ".join(verdict_record.get("blockers", [])) if verdict == "TEST_BLOCKED" else None,
|
|
1760
|
-
)
|
|
1761
|
-
return final_evidence_dir / output_name
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
def run_resume(
|
|
1765
|
-
project_root: Path,
|
|
1766
|
-
evidence_dir: Path,
|
|
1767
|
-
manifest_path: Path,
|
|
1768
|
-
inventory_path: Path,
|
|
1769
|
-
output_name: str,
|
|
1770
|
-
) -> Path:
|
|
1771
|
-
manifest = ensure_object(load_json(manifest_path), "manifest")
|
|
1772
|
-
inventory = ensure_object(load_json(inventory_path), "inventory")
|
|
1773
|
-
lifecycle = load_lifecycle(evidence_dir)
|
|
1774
|
-
if lifecycle.get("finalized") is True:
|
|
1775
|
-
raise RequestError(
|
|
1776
|
-
"evidence package is finalized and immutable; re-init a new evidence package before resume"
|
|
1777
|
-
)
|
|
1778
|
-
categories = ensure_object(inventory.get("categories"), "inventory categories")
|
|
1779
|
-
environment_path = evidence_dir / "environment.json"
|
|
1780
|
-
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
1781
|
-
aggregate = lambda entries: canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
1782
|
-
live_categories: dict[str, list[dict[str, str]]] = {}
|
|
1783
|
-
for category in CATEGORIES:
|
|
1784
|
-
live_entries: list[dict[str, str]] = []
|
|
1785
|
-
for entry in categories.get(category, []):
|
|
1786
|
-
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
1787
|
-
raise RequestError(f"invalid resume inventory entry: {category}")
|
|
1788
|
-
candidate = confined(project_root, entry["path"], must_exist=True)
|
|
1789
|
-
live_entries.append({"path": entry["path"], "sha256": file_sha256(candidate)})
|
|
1790
|
-
live_categories[category] = live_entries
|
|
1791
|
-
current = {
|
|
1792
|
-
"source": aggregate(live_categories["source"]),
|
|
1793
|
-
"tests": aggregate(live_categories["tests"]),
|
|
1794
|
-
"contracts": aggregate(live_categories["contracts"]),
|
|
1795
|
-
"lockfiles": aggregate(live_categories["lockfiles"]),
|
|
1796
|
-
"environment": canonical_sha256(environment),
|
|
1797
|
-
"plan": canonical_sha256(inventory.get("plan_inputs", {})),
|
|
1798
|
-
}
|
|
1799
|
-
changed = sorted(key for key, value in current.items() if manifest.get("target_hashes", {}).get(key) != value)
|
|
1800
|
-
first_invalid: str | None = None
|
|
1801
|
-
reasons: list[str] = []
|
|
1802
|
-
dependency_stage = {
|
|
1803
|
-
"source": "SCOPE_DISCOVER", "tests": "TEST_PLAN", "contracts": "CONTRACT_MODEL",
|
|
1804
|
-
"lockfiles": "INFRA_READY", "environment": "INFRA_READY", "plan": "TEST_PLAN",
|
|
1805
|
-
}
|
|
1806
|
-
if changed:
|
|
1807
|
-
first_invalid = min((dependency_stage[key] for key in changed), key=STAGES.index)
|
|
1808
|
-
reasons.append(f"target hashes changed: {', '.join(changed)}")
|
|
1809
|
-
files = {entry.get("path"): entry for entry in manifest.get("files", []) if isinstance(entry, dict)}
|
|
1810
|
-
for stage in manifest.get("stages", []):
|
|
1811
|
-
if not isinstance(stage, dict):
|
|
1812
|
-
continue
|
|
1813
|
-
for relative in stage.get("outputs", []):
|
|
1814
|
-
candidate = confined(evidence_dir, relative)
|
|
1815
|
-
entry = files.get(relative)
|
|
1816
|
-
if not candidate.is_file() or not entry or file_sha256(candidate) != entry.get("sha256"):
|
|
1817
|
-
stage_name = stage.get("name")
|
|
1818
|
-
if stage_name in STAGES and (first_invalid is None or STAGES.index(stage_name) < STAGES.index(first_invalid)):
|
|
1819
|
-
first_invalid = stage_name
|
|
1820
|
-
reasons.append(f"missing or drifted stage output: {relative}")
|
|
1821
|
-
invalidated = STAGES[STAGES.index(first_invalid):] if first_invalid else []
|
|
1822
|
-
decision = {
|
|
1823
|
-
"decision_format": "prizmkit-resume-invalidation-v1",
|
|
1824
|
-
"manifest_path": evidence_relative(evidence_dir, manifest_path),
|
|
1825
|
-
"manifest_sha256": file_sha256(manifest_path),
|
|
1826
|
-
"inventory_path": evidence_relative(evidence_dir, inventory_path),
|
|
1827
|
-
"inventory_sha256": file_sha256(inventory_path),
|
|
1828
|
-
"current_target_hashes": current,
|
|
1829
|
-
"changed_hashes": changed,
|
|
1830
|
-
"first_invalid_stage": first_invalid,
|
|
1831
|
-
"invalidated_stages": invalidated,
|
|
1832
|
-
"preserve_prior_executions": True,
|
|
1833
|
-
"reasons": reasons,
|
|
1834
|
-
"semantic_review_required": True,
|
|
1835
|
-
}
|
|
1836
|
-
output = confined(evidence_dir, output_name)
|
|
1837
|
-
write_json_atomic(output, decision)
|
|
1838
|
-
return output
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
def main() -> int:
|
|
1842
|
-
parser = argparse.ArgumentParser(description="Build trusted, request-driven test evidence")
|
|
1843
|
-
parser.add_argument("--project-root", required=True)
|
|
1844
|
-
parser.add_argument("--evidence-dir", required=True)
|
|
1845
|
-
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
1846
|
-
init = subparsers.add_parser("init")
|
|
1847
|
-
init.add_argument("--baseline")
|
|
1848
|
-
init.add_argument("--output", default="init.json")
|
|
1849
|
-
capture = subparsers.add_parser("capture-change")
|
|
1850
|
-
capture.add_argument("--baseline")
|
|
1851
|
-
capture.add_argument("--output", default="change-capture.json")
|
|
1852
|
-
inventory = subparsers.add_parser("inventory")
|
|
1853
|
-
inventory.add_argument("--request", required=True)
|
|
1854
|
-
inventory.add_argument("--output", default="target-inventory.json")
|
|
1855
|
-
prepare = subparsers.add_parser("prepare-tests")
|
|
1856
|
-
prepare.add_argument("--request", required=True)
|
|
1857
|
-
prepare.add_argument("--output", default="test-preparation.json")
|
|
1858
|
-
execute = subparsers.add_parser("execute")
|
|
1859
|
-
group = execute.add_mutually_exclusive_group(required=True)
|
|
1860
|
-
group.add_argument("--request")
|
|
1861
|
-
group.add_argument("--replay-receipt")
|
|
1862
|
-
finalize = subparsers.add_parser("finalize")
|
|
1863
|
-
finalize.add_argument("--request", required=True, help="semantic finalization request")
|
|
1864
|
-
finalize.add_argument("--output", default="finalize.json")
|
|
1865
|
-
report = subparsers.add_parser("render-report")
|
|
1866
|
-
report.add_argument("--output", default="test-report.md")
|
|
1867
|
-
resume = subparsers.add_parser("resume")
|
|
1868
|
-
resume.add_argument("--manifest", required=True)
|
|
1869
|
-
resume.add_argument("--inventory", required=True)
|
|
1870
|
-
resume.add_argument("--output", default="resume-decision.json")
|
|
1871
|
-
args = parser.parse_args()
|
|
1872
|
-
project_root = Path(args.project_root).resolve()
|
|
1873
|
-
evidence_dir = Path(args.evidence_dir).resolve()
|
|
1874
|
-
try:
|
|
1875
|
-
if not project_root.is_dir():
|
|
1876
|
-
raise RequestError(f"project root does not exist: {project_root}")
|
|
1877
|
-
require_canonical_evidence_dir(project_root, evidence_dir)
|
|
1878
|
-
if args.subcommand == "init":
|
|
1879
|
-
output = run_init(project_root, evidence_dir, args.baseline, args.output)
|
|
1880
|
-
print(f"Evidence initialized: {output}")
|
|
1881
|
-
elif args.subcommand == "capture-change":
|
|
1882
|
-
require_mutable_lifecycle(evidence_dir)
|
|
1883
|
-
output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
|
|
1884
|
-
print(f"Change captured: {output}")
|
|
1885
|
-
elif args.subcommand == "inventory":
|
|
1886
|
-
require_mutable_lifecycle(evidence_dir)
|
|
1887
|
-
output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
1888
|
-
print(f"Inventory written: {output}")
|
|
1889
|
-
elif args.subcommand == "prepare-tests":
|
|
1890
|
-
require_mutable_lifecycle(evidence_dir)
|
|
1891
|
-
output = run_prepare_tests(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
1892
|
-
print(f"Native tests prepared: {output}")
|
|
1893
|
-
elif args.subcommand == "execute":
|
|
1894
|
-
require_mutable_lifecycle(evidence_dir)
|
|
1895
|
-
if args.request:
|
|
1896
|
-
receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
1897
|
-
else:
|
|
1898
|
-
prior_path = receipt_file(evidence_dir, args.replay_receipt)
|
|
1899
|
-
prior = ensure_object(load_json(prior_path), "replay receipt")
|
|
1900
|
-
if prior.get("receipt_format") != "prizmkit-runner-generated-v1":
|
|
1901
|
-
raise RequestError("replay source is not a runner-generated receipt")
|
|
1902
|
-
original_request = request_file(evidence_dir, prior.get("request_path", ""))
|
|
1903
|
-
if file_sha256(original_request) != prior.get("request_sha256"):
|
|
1904
|
-
raise RequestError("recorded request hash no longer matches replay source")
|
|
1905
|
-
receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
|
|
1906
|
-
print(json.dumps(receipt, ensure_ascii=False))
|
|
1907
|
-
elif args.subcommand == "render-report":
|
|
1908
|
-
load_lifecycle(evidence_dir)
|
|
1909
|
-
output = run_render_report(evidence_dir, args.output)
|
|
1910
|
-
print(f"Report rendered: {output}")
|
|
1911
|
-
elif args.subcommand == "finalize":
|
|
1912
|
-
load_lifecycle(evidence_dir, required=True)
|
|
1913
|
-
request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
|
|
1914
|
-
output = run_finalize(project_root, evidence_dir, request, args.output)
|
|
1915
|
-
print(f"Evidence finalized: {output}")
|
|
1916
|
-
elif args.subcommand == "resume":
|
|
1917
|
-
load_lifecycle(evidence_dir)
|
|
1918
|
-
output = run_resume(
|
|
1919
|
-
project_root, evidence_dir, request_file(evidence_dir, args.manifest),
|
|
1920
|
-
request_file(evidence_dir, args.inventory), args.output,
|
|
1921
|
-
)
|
|
1922
|
-
print(f"Resume decision written: {output}")
|
|
1923
|
-
return 0
|
|
1924
|
-
except (RequestError, OSError, ValueError) as exc:
|
|
1925
|
-
print(f"Test evidence builder: BLOCKED\n- {exc}", file=sys.stderr)
|
|
1926
|
-
return 2
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
if __name__ == "__main__":
|
|
1930
|
-
sys.exit(main())
|