prizmkit 1.1.130 → 1.1.134
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/adapters/claude/command-adapter.js +0 -2
- package/bundled/dev-pipeline/.env.example +1 -1
- package/bundled/dev-pipeline/assets/skill-subagent-integration.md +11 -6
- package/bundled/dev-pipeline/prizmkit_runtime/checkpoint_state.py +445 -8
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +2 -9
- package/bundled/dev-pipeline/prizmkit_runtime/processes.py +21 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +87 -66
- package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +53 -3
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +55 -25
- package/bundled/dev-pipeline/prizmkit_runtime/runner_status.py +4 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +119 -18
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +411 -17
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +57 -14
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +55 -2
- package/bundled/dev-pipeline/scripts/prompt_framework.py +152 -1
- package/bundled/dev-pipeline/scripts/update-bug-status.py +53 -0
- package/bundled/dev-pipeline/scripts/update-checkpoint.py +294 -69
- package/bundled/dev-pipeline/scripts/update-feature-status.py +57 -0
- package/bundled/dev-pipeline/scripts/update-refactor-status.py +53 -0
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +5 -5
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +28 -22
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +24 -10
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +20 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-diagnose-plan.md +1 -1
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +9 -19
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-test.md +21 -0
- package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +10 -10
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +15 -0
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +2 -2
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +26 -23
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +5 -5
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +15 -11
- package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +6 -6
- package/bundled/dev-pipeline/templates/sections/refactor-phase-test.md +21 -0
- package/bundled/dev-pipeline/templates/sections/refactor-reminders.md +1 -1
- package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
- package/bundled/dev-pipeline/templates/sections/task-contract.md +4 -0
- package/bundled/dev-pipeline/tests/test_checkpoint_state.py +362 -3
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +349 -48
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +129 -0
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +91 -0
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +567 -23
- package/bundled/dev-pipeline/tests/test_unified_cli.py +37 -10
- package/bundled/skills/_metadata.json +63 -26
- package/bundled/skills/app-planner/SKILL.md +4 -3
- package/bundled/skills/bug-fix-workflow/SKILL.md +89 -180
- package/bundled/skills/bug-planner/SKILL.md +12 -27
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +131 -220
- package/bundled/skills/bugfix-pipeline-launcher/references/configuration.md +36 -90
- package/bundled/skills/feature-pipeline-launcher/SKILL.md +129 -271
- package/bundled/skills/feature-pipeline-launcher/references/configuration.md +35 -76
- package/bundled/skills/feature-planner/SKILL.md +27 -22
- package/bundled/skills/feature-planner/assets/planning-guide.md +4 -4
- package/bundled/skills/feature-workflow/SKILL.md +94 -121
- package/bundled/skills/prizmkit/SKILL.md +130 -94
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-code-review/SKILL.md +132 -86
- package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +42 -1
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
- package/bundled/skills/prizmkit-committer/SKILL.md +125 -48
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-implement/SKILL.md +99 -73
- package/bundled/skills/prizmkit-implement/references/implementation-subagent-procedure.md +1 -1
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-init/SKILL.md +5 -5
- package/bundled/skills/prizmkit-plan/SKILL.md +146 -105
- package/bundled/skills/prizmkit-plan/assets/plan-template.md +18 -0
- package/bundled/skills/prizmkit-plan/references/independent-plan-review.md +242 -0
- package/bundled/skills/prizmkit-plan/references/review-plan-spec-loop.md +4 -4
- package/bundled/skills/prizmkit-plan/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-retrospective/SKILL.md +111 -53
- package/bundled/skills/prizmkit-retrospective/references/knowledge-injection-steps.md +7 -6
- package/bundled/skills/prizmkit-retrospective/references/structural-sync-steps.md +5 -4
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-test/SKILL.md +52 -6
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +63 -12
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +13 -0
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +6 -1
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +8 -1
- package/bundled/skills/prizmkit-test/references/examples.md +1 -1
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +4 -1
- package/bundled/skills/prizmkit-test/references/test-report-template.md +1 -1
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
- package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +744 -23
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +194 -15
- package/bundled/skills/prizmkit-workflow/SKILL.md +266 -0
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +181 -0
- package/bundled/skills/recovery-workflow/SKILL.md +62 -241
- package/bundled/skills/recovery-workflow/evals/evals.json +17 -33
- package/bundled/skills/recovery-workflow/references/detection.md +28 -52
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +105 -439
- package/bundled/skills/refactor-pipeline-launcher/SKILL.md +136 -275
- package/bundled/skills/refactor-pipeline-launcher/references/configuration.md +53 -88
- package/bundled/skills/refactor-planner/SKILL.md +12 -57
- package/bundled/skills/refactor-planner/references/behavior-preservation.md +111 -230
- package/bundled/skills/refactor-planner/references/error-recovery.md +37 -0
- package/bundled/skills/refactor-planner/references/planning-phases.md +11 -17
- package/bundled/skills/refactor-planner/scripts/validate-and-generate-refactor.py +1 -1
- package/bundled/skills/refactor-workflow/SKILL.md +98 -139
- package/bundled/templates/project-memory-template.md +4 -4
- package/package.json +1 -1
- package/src/index.js +4 -0
- package/src/prompts.js +10 -3
- package/src/scaffold.js +1 -1
- package/bundled/dev-pipeline/tests/test-deploy-safety.sh +0 -223
- package/bundled/skills/feature-pipeline-launcher/scripts/preflight-check.py +0 -462
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
"""Build runner-generated PrizmKit test evidence from schema-shaped requests.
|
|
3
3
|
|
|
4
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
|
|
5
7
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR inventory --request REQUEST
|
|
6
8
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
|
|
7
9
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
|
|
8
10
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
|
|
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
|
|
9
13
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
|
|
10
14
|
|
|
11
15
|
Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
|
|
@@ -35,13 +39,23 @@ STAGES = [
|
|
|
35
39
|
"EVIDENCE_VALIDATE",
|
|
36
40
|
]
|
|
37
41
|
CATEGORIES = ("source", "tests", "contracts", "lockfiles")
|
|
42
|
+
LAYERS = (
|
|
43
|
+
"focused", "module-component", "contract-integration",
|
|
44
|
+
"affected-module-regression", "regression-ring",
|
|
45
|
+
)
|
|
38
46
|
BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
39
47
|
SNAPSHOT_VOLATILE_ROOTS = (
|
|
40
48
|
".prizmkit/state",
|
|
41
49
|
".prizmkit/test/evidence",
|
|
50
|
+
".prizmkit/" + "dev-" + "pipeline",
|
|
42
51
|
".claude/worktrees",
|
|
52
|
+
".agents/worktrees",
|
|
53
|
+
".codebuddy/worktrees",
|
|
54
|
+
".codex/worktrees",
|
|
43
55
|
)
|
|
44
|
-
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
|
|
56
|
+
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
|
|
57
|
+
INVENTORY_RESERVED_ROOTS = SNAPSHOT_VOLATILE_ROOTS
|
|
58
|
+
FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
|
|
45
59
|
|
|
46
60
|
|
|
47
61
|
class RequestError(Exception):
|
|
@@ -127,6 +141,167 @@ def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> l
|
|
|
127
141
|
return value
|
|
128
142
|
|
|
129
143
|
|
|
144
|
+
def normalize_relative(value: str) -> str:
|
|
145
|
+
normalized = Path(value).as_posix()
|
|
146
|
+
if normalized == "." or normalized.startswith("../") or "/../" in normalized:
|
|
147
|
+
raise RequestError(f"path is not project-relative: {value}")
|
|
148
|
+
return normalized
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_reserved_inventory_path(relative: str) -> bool:
|
|
152
|
+
path = Path(relative)
|
|
153
|
+
if any(part in SNAPSHOT_VOLATILE_NAMES for part in path.parts):
|
|
154
|
+
return True
|
|
155
|
+
return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
|
|
159
|
+
result = subprocess.run(
|
|
160
|
+
["git", "-C", str(project_root), *args],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
check=False,
|
|
163
|
+
)
|
|
164
|
+
if check and result.returncode != 0:
|
|
165
|
+
raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def git_baseline(project_root: Path, requested: str | None = None) -> str:
|
|
170
|
+
baseline = requested or "HEAD"
|
|
171
|
+
result = project_git(project_root, "rev-parse", "--verify", baseline)
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
raise RequestError(f"baseline commit is not resolvable: {baseline}")
|
|
174
|
+
return result.stdout.decode().strip()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
|
|
178
|
+
result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
|
|
179
|
+
raw = result.stdout
|
|
180
|
+
entries: list[dict[str, Any]] = []
|
|
181
|
+
tokens = raw.split(b"\0")
|
|
182
|
+
index = 0
|
|
183
|
+
while index < len(tokens):
|
|
184
|
+
token = tokens[index]
|
|
185
|
+
index += 1
|
|
186
|
+
if not token:
|
|
187
|
+
continue
|
|
188
|
+
status = token[:2].decode(errors="replace")
|
|
189
|
+
path = token[3:].decode(errors="replace")
|
|
190
|
+
old_path = None
|
|
191
|
+
if "R" in status or "C" in status:
|
|
192
|
+
if index >= len(tokens):
|
|
193
|
+
raise RequestError("git status returned an incomplete rename/copy record")
|
|
194
|
+
old_path = tokens[index].decode(errors="replace")
|
|
195
|
+
index += 1
|
|
196
|
+
relative = normalize_relative(path)
|
|
197
|
+
if not is_reserved_inventory_path(relative):
|
|
198
|
+
record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
|
|
199
|
+
entries.append(record)
|
|
200
|
+
if old_path is not None:
|
|
201
|
+
old_relative = normalize_relative(old_path)
|
|
202
|
+
if not is_reserved_inventory_path(old_relative):
|
|
203
|
+
entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
|
|
204
|
+
return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
|
|
208
|
+
result = project_git(
|
|
209
|
+
project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
|
|
210
|
+
baseline, "--", ".",
|
|
211
|
+
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
212
|
+
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
213
|
+
check=True,
|
|
214
|
+
)
|
|
215
|
+
tokens = result.stdout.split(b"\0")
|
|
216
|
+
entries: list[dict[str, Any]] = []
|
|
217
|
+
index = 0
|
|
218
|
+
while index < len(tokens):
|
|
219
|
+
status_token = tokens[index].decode(errors="replace")
|
|
220
|
+
index += 1
|
|
221
|
+
if not status_token:
|
|
222
|
+
continue
|
|
223
|
+
if index >= len(tokens):
|
|
224
|
+
raise RequestError("git diff returned an incomplete changed-file record")
|
|
225
|
+
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
226
|
+
index += 1
|
|
227
|
+
status = status_token[0]
|
|
228
|
+
old_path = None
|
|
229
|
+
if status in {"R", "C"}:
|
|
230
|
+
if index >= len(tokens):
|
|
231
|
+
raise RequestError("git diff returned an incomplete rename/copy record")
|
|
232
|
+
old_path = first
|
|
233
|
+
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
234
|
+
index += 1
|
|
235
|
+
if not is_reserved_inventory_path(first):
|
|
236
|
+
entries.append({"path": first, "status": status, "old_path": old_path})
|
|
237
|
+
if old_path and not is_reserved_inventory_path(old_path):
|
|
238
|
+
entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
|
|
239
|
+
return entries
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def merge_changed_file_entries(
|
|
243
|
+
baseline_entries: list[dict[str, Any]],
|
|
244
|
+
working_entries: list[dict[str, Any]],
|
|
245
|
+
) -> list[dict[str, Any]]:
|
|
246
|
+
merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
|
|
247
|
+
for entry in baseline_entries + working_entries:
|
|
248
|
+
key = (entry["path"], entry.get("old_path"), entry.get("role"))
|
|
249
|
+
existing = merged.get(key)
|
|
250
|
+
if existing is None:
|
|
251
|
+
merged[key] = dict(entry)
|
|
252
|
+
continue
|
|
253
|
+
statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
|
|
254
|
+
if "D" in statuses:
|
|
255
|
+
existing["status"] = "D"
|
|
256
|
+
elif "R" in statuses:
|
|
257
|
+
existing["status"] = "R"
|
|
258
|
+
elif "C" in statuses:
|
|
259
|
+
existing["status"] = "C"
|
|
260
|
+
elif statuses:
|
|
261
|
+
existing["status"] = "M"
|
|
262
|
+
return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
|
|
266
|
+
status_entries = git_status_entries(project_root)
|
|
267
|
+
baseline_entries = git_diff_entries(project_root, baseline)
|
|
268
|
+
status_entries = merge_changed_file_entries(baseline_entries, status_entries)
|
|
269
|
+
tracked = project_git(
|
|
270
|
+
project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
|
|
271
|
+
baseline, "--", ".",
|
|
272
|
+
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
273
|
+
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
274
|
+
)
|
|
275
|
+
if tracked.returncode != 0:
|
|
276
|
+
raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
|
|
277
|
+
chunks = [tracked.stdout]
|
|
278
|
+
untracked = [
|
|
279
|
+
item["path"] for item in status_entries
|
|
280
|
+
if item["status"].strip() == "??" and item.get("old_path") is None
|
|
281
|
+
]
|
|
282
|
+
for relative in sorted(untracked):
|
|
283
|
+
candidate = confined(project_root, relative, must_exist=True)
|
|
284
|
+
if candidate.is_file():
|
|
285
|
+
result = subprocess.run(
|
|
286
|
+
["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
|
|
287
|
+
cwd=project_root,
|
|
288
|
+
capture_output=True,
|
|
289
|
+
check=False,
|
|
290
|
+
)
|
|
291
|
+
if result.returncode not in (0, 1):
|
|
292
|
+
raise RequestError(f"cannot capture untracked file: {relative}")
|
|
293
|
+
chunks.append(result.stdout)
|
|
294
|
+
patch = b"".join(chunk for chunk in chunks if chunk)
|
|
295
|
+
return patch, status_entries
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def write_text_atomic(path: Path, value: str) -> None:
|
|
299
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
301
|
+
temporary.write_text(value, encoding="utf-8")
|
|
302
|
+
os.replace(temporary, path)
|
|
303
|
+
|
|
304
|
+
|
|
130
305
|
def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
131
306
|
if not isinstance(value, list):
|
|
132
307
|
raise RequestError("external_targets must be an array")
|
|
@@ -151,7 +326,14 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
|
151
326
|
return value
|
|
152
327
|
|
|
153
328
|
|
|
154
|
-
def
|
|
329
|
+
def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
|
|
330
|
+
path = evidence_dir / "test-plan.json"
|
|
331
|
+
if not path.exists():
|
|
332
|
+
return None
|
|
333
|
+
return ensure_object(load_json(path), "test plan")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
155
337
|
request = ensure_object(request, "execution request")
|
|
156
338
|
required = {
|
|
157
339
|
"request_version", "purpose", "command", "cwd", "environment",
|
|
@@ -175,7 +357,24 @@ def validate_execution_request(request: Any) -> dict[str, Any]:
|
|
|
175
357
|
if not isinstance(name, str):
|
|
176
358
|
raise RequestError("tool probe names must be strings")
|
|
177
359
|
ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
|
|
178
|
-
ensure_string_list(request["test_ids"], "test_ids")
|
|
360
|
+
test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
|
|
361
|
+
if len(set(test_ids)) != len(test_ids):
|
|
362
|
+
raise RequestError("execution request test_ids contains duplicates")
|
|
363
|
+
if request["layer"] not in LAYERS and request["layer"] != "lightweight":
|
|
364
|
+
raise RequestError(f"unknown execution layer: {request['layer']}")
|
|
365
|
+
if plan is not None:
|
|
366
|
+
tests = plan.get("tests") if isinstance(plan, dict) else None
|
|
367
|
+
known = {item.get("id") for item in tests if isinstance(item, dict)} if isinstance(tests, list) else set()
|
|
368
|
+
unknown = sorted(set(test_ids) - known)
|
|
369
|
+
if unknown:
|
|
370
|
+
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
371
|
+
test_map = {item.get("id"): item for item in tests if isinstance(item, dict)} if isinstance(tests, list) else {}
|
|
372
|
+
invalid_layer = sorted(
|
|
373
|
+
test_id for test_id in test_ids
|
|
374
|
+
if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
|
|
375
|
+
)
|
|
376
|
+
if invalid_layer:
|
|
377
|
+
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
179
378
|
validate_external_targets(request["external_targets"])
|
|
180
379
|
if "timeout_seconds" in request and (isinstance(request["timeout_seconds"], bool) or not isinstance(request["timeout_seconds"], (int, float)) or request["timeout_seconds"] <= 0):
|
|
181
380
|
raise RequestError("timeout_seconds must be a positive number")
|
|
@@ -215,11 +414,60 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
|
|
|
215
414
|
entries = []
|
|
216
415
|
for candidate in sorted(paths):
|
|
217
416
|
relative = candidate.relative_to(project_root).as_posix()
|
|
218
|
-
if relative not in exclusions:
|
|
417
|
+
if relative not in exclusions and not is_reserved_inventory_path(relative):
|
|
219
418
|
entries.append({"path": relative, "sha256": file_sha256(candidate)})
|
|
220
419
|
return entries
|
|
221
420
|
|
|
222
421
|
|
|
422
|
+
def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
|
|
423
|
+
membership: dict[str, str] = {}
|
|
424
|
+
for category, entries in output_categories.items():
|
|
425
|
+
for entry in entries:
|
|
426
|
+
path = entry["path"]
|
|
427
|
+
previous = membership.get(path)
|
|
428
|
+
if previous is not None:
|
|
429
|
+
raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
|
|
430
|
+
membership[path] = category
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def validate_module_root_request(
|
|
434
|
+
project_root: Path,
|
|
435
|
+
evidence_dir: Path,
|
|
436
|
+
module_roots: list[Any],
|
|
437
|
+
changed_files: list[str],
|
|
438
|
+
) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
|
|
439
|
+
normalized_roots: list[str] = []
|
|
440
|
+
explicit_changed: dict[str, list[str]] = {}
|
|
441
|
+
for item in module_roots:
|
|
442
|
+
if isinstance(item, str):
|
|
443
|
+
relative = normalize_relative(item)
|
|
444
|
+
changed_set: list[str] = []
|
|
445
|
+
elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
|
|
446
|
+
relative = normalize_relative(item["path"])
|
|
447
|
+
changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
|
|
448
|
+
else:
|
|
449
|
+
raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
|
|
450
|
+
confined(project_root, relative, must_exist=True)
|
|
451
|
+
if relative in normalized_roots:
|
|
452
|
+
raise RequestError(f"duplicate module root: {relative}")
|
|
453
|
+
for changed in changed_set:
|
|
454
|
+
changed = normalize_relative(changed)
|
|
455
|
+
if not path_is_below_or_equal(changed, relative):
|
|
456
|
+
raise RequestError(f"module root changed file lies outside root: {changed}")
|
|
457
|
+
if changed not in changed_files:
|
|
458
|
+
raise RequestError(f"module root changed file is not in changed_files: {changed}")
|
|
459
|
+
normalized_roots.append(relative)
|
|
460
|
+
explicit_changed[relative] = sorted(changed_set)
|
|
461
|
+
enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
|
|
462
|
+
return normalized_roots, explicit_changed, enumerated
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def path_is_below_or_equal(path: str, root: str) -> bool:
|
|
466
|
+
normalized_path = Path(path)
|
|
467
|
+
normalized_root = Path(root)
|
|
468
|
+
return normalized_path == normalized_root or normalized_root in normalized_path.parents
|
|
469
|
+
|
|
470
|
+
|
|
223
471
|
def enumerate_module_root_files(
|
|
224
472
|
project_root: Path,
|
|
225
473
|
evidence_dir: Path,
|
|
@@ -257,28 +505,49 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
257
505
|
for exclusion in exclusions:
|
|
258
506
|
if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
|
|
259
507
|
raise RequestError("each inventory exclusion requires path, reason, and evidence")
|
|
260
|
-
|
|
508
|
+
relative = normalize_relative(exclusion["path"])
|
|
509
|
+
if is_reserved_inventory_path(relative):
|
|
510
|
+
raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
|
|
511
|
+
confined(project_root, relative)
|
|
261
512
|
if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
|
|
262
513
|
raise RequestError("inventory exclusion reason is too short")
|
|
263
514
|
if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
|
|
264
515
|
raise RequestError("inventory exclusion lacks evidence")
|
|
265
|
-
exclusion_paths.add(
|
|
266
|
-
changed_files = ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)
|
|
267
|
-
|
|
516
|
+
exclusion_paths.add(relative)
|
|
517
|
+
changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
|
|
518
|
+
if len(changed_files) != len(request["changed_files"]):
|
|
519
|
+
raise RequestError("changed_files contains duplicates")
|
|
520
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
521
|
+
captured_deleted: set[str] = set()
|
|
522
|
+
if capture_path.is_file():
|
|
523
|
+
capture = ensure_object(load_json(capture_path), "change capture")
|
|
524
|
+
for entry in capture.get("changed_files", []):
|
|
525
|
+
if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
|
|
526
|
+
captured_deleted.add(entry.get("path"))
|
|
527
|
+
if isinstance(entry, dict) and entry.get("role") == "source":
|
|
528
|
+
captured_deleted.add(entry.get("path"))
|
|
268
529
|
for relative in changed_files:
|
|
269
|
-
confined(
|
|
270
|
-
|
|
271
|
-
|
|
530
|
+
confined(
|
|
531
|
+
project_root,
|
|
532
|
+
relative,
|
|
533
|
+
must_exist=relative not in exclusion_paths and relative not in captured_deleted,
|
|
534
|
+
)
|
|
535
|
+
raw_module_roots = request["module_roots"]
|
|
536
|
+
if not isinstance(raw_module_roots, list) or not raw_module_roots:
|
|
537
|
+
raise RequestError("module_roots must be a non-empty array")
|
|
538
|
+
module_roots, explicit_changed, module_root_files = validate_module_root_request(
|
|
539
|
+
project_root, evidence_dir, raw_module_roots, changed_files,
|
|
540
|
+
)
|
|
272
541
|
output_categories = {
|
|
273
542
|
name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
|
|
274
543
|
for name in CATEGORIES
|
|
275
544
|
}
|
|
545
|
+
validate_category_disjointness(output_categories)
|
|
276
546
|
inventoried_paths = {
|
|
277
547
|
entry["path"]
|
|
278
548
|
for entries in output_categories.values()
|
|
279
549
|
for entry in entries
|
|
280
550
|
}
|
|
281
|
-
module_root_files = enumerate_module_root_files(project_root, evidence_dir, module_roots)
|
|
282
551
|
missing_from_inventory = sorted({
|
|
283
552
|
relative
|
|
284
553
|
for files in module_root_files.values()
|
|
@@ -290,6 +559,9 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
290
559
|
"module root contains files that are neither inventoried nor excluded: "
|
|
291
560
|
+ ", ".join(missing_from_inventory[:20])
|
|
292
561
|
)
|
|
562
|
+
for root, changed_set in explicit_changed.items():
|
|
563
|
+
if changed_set and not set(changed_set) <= set(module_root_files[root]):
|
|
564
|
+
raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
|
|
293
565
|
output = {
|
|
294
566
|
"inventory_request_path": evidence_relative(evidence_dir, request_path),
|
|
295
567
|
"inventory_request_sha256": file_sha256(request_path),
|
|
@@ -297,6 +569,7 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
297
569
|
"changed_files": changed_files,
|
|
298
570
|
"module_roots": module_roots,
|
|
299
571
|
"module_root_files": module_root_files,
|
|
572
|
+
"module_root_changed_files": explicit_changed,
|
|
300
573
|
"exclusions": sorted(exclusion_paths),
|
|
301
574
|
"discovery_evidence": request["discovery_evidence"],
|
|
302
575
|
"plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
|
|
@@ -390,7 +663,7 @@ def execute_request(
|
|
|
390
663
|
isolation: dict[str, Any] | None = None,
|
|
391
664
|
selected_execution: bool = True,
|
|
392
665
|
) -> dict[str, Any]:
|
|
393
|
-
request = validate_execution_request(load_json(request_path))
|
|
666
|
+
request = validate_execution_request(load_json(request_path), plan=plan_for_execution(evidence_dir))
|
|
394
667
|
root = execution_root or project_root
|
|
395
668
|
cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
396
669
|
complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
|
@@ -501,12 +774,61 @@ def materialize_request(evidence_dir: Path, name: str, request: dict[str, Any])
|
|
|
501
774
|
return path
|
|
502
775
|
|
|
503
776
|
|
|
777
|
+
def validate_not_applicable(value: Any) -> dict[str, Any]:
|
|
778
|
+
value = ensure_object(value, "differential N/A decision")
|
|
779
|
+
required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
|
|
780
|
+
if set(value) != required:
|
|
781
|
+
raise RequestError(f"differential N/A fields must be exactly {sorted(required)}")
|
|
782
|
+
if value["reason"] not in {"new-behavior", "textual-contract"}:
|
|
783
|
+
raise RequestError("differential N/A reason must be new-behavior or textual-contract")
|
|
784
|
+
if not isinstance(value["rationale"], str) or len(value["rationale"].strip()) < 16:
|
|
785
|
+
raise RequestError("differential N/A rationale is too short")
|
|
786
|
+
ensure_string_list(value["evidence"], "differential N/A evidence", allow_empty=False)
|
|
787
|
+
ensure_string_list(value["considered_signals"], "differential N/A considered_signals", allow_empty=False)
|
|
788
|
+
if not isinstance(value["conflicts"], list):
|
|
789
|
+
raise RequestError("differential N/A conflicts must be an array")
|
|
790
|
+
if value["reason"] == "new-behavior" and "no stable pre-change observable" not in value["rationale"].lower():
|
|
791
|
+
raise RequestError("new-behavior differential N/A must explain that no stable pre-change observable exists")
|
|
792
|
+
if value["reason"] == "textual-contract" and "textual" not in value["rationale"].lower():
|
|
793
|
+
raise RequestError("textual-contract differential N/A must explain why behavior is textual")
|
|
794
|
+
return value
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
|
|
798
|
+
proof_path = evidence_dir / "differential-proof.json"
|
|
799
|
+
proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
|
|
800
|
+
if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
|
|
801
|
+
raise RequestError("differential-proof.json has invalid append target")
|
|
802
|
+
if any(item.get("behavior_id") == proof.get("behavior_id") for item in proof_record["proofs"] if isinstance(item, dict)):
|
|
803
|
+
raise RequestError(f"duplicate differential proof: {proof.get('behavior_id')}")
|
|
804
|
+
proof_record["proofs"].append(proof)
|
|
805
|
+
write_json_atomic(proof_path, proof_record)
|
|
806
|
+
|
|
807
|
+
|
|
504
808
|
def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
|
|
505
809
|
request = ensure_object(load_json(request_path), "differential request")
|
|
506
810
|
required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
|
|
811
|
+
na_required = {"request_version", "behavior_id", "method", "not_applicable"}
|
|
812
|
+
if set(request) == na_required:
|
|
813
|
+
if request["request_version"] != "1.0" or request["method"] != "not-applicable":
|
|
814
|
+
raise RequestError("invalid differential N/A request")
|
|
815
|
+
not_applicable = validate_not_applicable(request["not_applicable"])
|
|
816
|
+
proof = {
|
|
817
|
+
"behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
|
|
818
|
+
"method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
|
|
819
|
+
"differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
|
|
820
|
+
"current_tree_sha256": None, "baseline_execution_id": None, "mutation_execution_id": None,
|
|
821
|
+
"current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
|
|
822
|
+
"project_tree_before_sha256": None, "project_tree_after_sha256": None, "isolation_tree_sha256": None,
|
|
823
|
+
"mutation_apply_sha256": None, "mutation_restore_sha256": None, "not_applicable": not_applicable,
|
|
824
|
+
}
|
|
825
|
+
append_proof(evidence_dir, proof)
|
|
826
|
+
return proof
|
|
507
827
|
if set(request) != required or request["request_version"] != "1.0":
|
|
508
828
|
raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
|
|
509
|
-
execution_request = validate_execution_request(
|
|
829
|
+
execution_request = validate_execution_request(
|
|
830
|
+
request["execution_request"], plan=plan_for_execution(evidence_dir),
|
|
831
|
+
)
|
|
510
832
|
expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
|
|
511
833
|
overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
|
|
512
834
|
for relative in overlay_paths:
|
|
@@ -597,15 +919,379 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
597
919
|
"mutation_restore_sha256": mutation_restore_sha,
|
|
598
920
|
"not_applicable": None,
|
|
599
921
|
}
|
|
600
|
-
|
|
601
|
-
proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
|
|
602
|
-
if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
|
|
603
|
-
raise RequestError("differential-proof.json has invalid append target")
|
|
604
|
-
proof_record["proofs"].append(proof)
|
|
605
|
-
write_json_atomic(proof_path, proof_record)
|
|
922
|
+
append_proof(evidence_dir, proof)
|
|
606
923
|
return proof
|
|
607
924
|
|
|
608
925
|
|
|
926
|
+
def run_capture_change(
|
|
927
|
+
project_root: Path,
|
|
928
|
+
evidence_dir: Path,
|
|
929
|
+
baseline: str | None,
|
|
930
|
+
output_name: str,
|
|
931
|
+
) -> Path:
|
|
932
|
+
resolved_baseline = git_baseline(project_root, baseline)
|
|
933
|
+
patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
|
|
934
|
+
patch_path = confined(evidence_dir, "source-change.patch")
|
|
935
|
+
patch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
936
|
+
patch_path.write_bytes(patch)
|
|
937
|
+
capture = {
|
|
938
|
+
"capture_format": "prizmkit-canonical-change-v1",
|
|
939
|
+
"baseline_commit": resolved_baseline,
|
|
940
|
+
"patch_path": evidence_relative(evidence_dir, patch_path),
|
|
941
|
+
"patch_sha256": file_sha256(patch_path),
|
|
942
|
+
"changed_files": status_entries,
|
|
943
|
+
"source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
|
|
944
|
+
}
|
|
945
|
+
output = confined(evidence_dir, output_name)
|
|
946
|
+
write_json_atomic(output, capture)
|
|
947
|
+
return output
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
|
|
951
|
+
if evidence_dir.exists() and any(evidence_dir.iterdir()):
|
|
952
|
+
if (evidence_dir / "manifest.json").exists():
|
|
953
|
+
raise RequestError("evidence package is finalized; initialize a new evidence directory")
|
|
954
|
+
raise RequestError("evidence directory is not empty; initialize a new evidence directory")
|
|
955
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
956
|
+
for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
|
|
957
|
+
(evidence_dir / directory).mkdir(parents=True, exist_ok=True)
|
|
958
|
+
write_json_atomic(evidence_dir / "executions.json", [])
|
|
959
|
+
write_json_atomic(evidence_dir / "differential-proof.json", {"proofs": []})
|
|
960
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
961
|
+
"lifecycle_format": "prizmkit-canonical-lifecycle-v1",
|
|
962
|
+
"status": "initialized",
|
|
963
|
+
"baseline_commit": git_baseline(project_root, baseline),
|
|
964
|
+
"artifact_dir": None,
|
|
965
|
+
"finalized": False,
|
|
966
|
+
"immutable_identity": None,
|
|
967
|
+
})
|
|
968
|
+
output = confined(evidence_dir, output_name)
|
|
969
|
+
write_json_atomic(output, {
|
|
970
|
+
"command": "init",
|
|
971
|
+
"evidence_dir": ".",
|
|
972
|
+
"baseline_commit": git_baseline(project_root, baseline),
|
|
973
|
+
"next": "capture-change",
|
|
974
|
+
})
|
|
975
|
+
return output
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
|
|
979
|
+
path = evidence_dir / "lifecycle.json"
|
|
980
|
+
if not path.exists():
|
|
981
|
+
if required:
|
|
982
|
+
raise RequestError("evidence package is not initialized; run init first")
|
|
983
|
+
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
984
|
+
return ensure_object(load_json(path), "lifecycle")
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
|
|
988
|
+
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
989
|
+
if not lifecycle_path.exists():
|
|
990
|
+
if (evidence_dir / "manifest.json").exists():
|
|
991
|
+
raise RequestError(
|
|
992
|
+
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
993
|
+
)
|
|
994
|
+
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
995
|
+
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
996
|
+
if lifecycle.get("finalized") is True:
|
|
997
|
+
raise RequestError(
|
|
998
|
+
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
999
|
+
)
|
|
1000
|
+
return lifecycle
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def run_render_report(evidence_dir: Path, output_name: str) -> Path:
|
|
1004
|
+
manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
|
|
1005
|
+
verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
|
|
1006
|
+
validation_path = evidence_dir / "validation.json"
|
|
1007
|
+
validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
|
|
1008
|
+
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
1009
|
+
lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
|
|
1010
|
+
proofs_path = evidence_dir / "differential-proof.json"
|
|
1011
|
+
proofs = load_json(proofs_path).get("proofs", []) if proofs_path.exists() else []
|
|
1012
|
+
executions = load_json(evidence_dir / "executions.json")
|
|
1013
|
+
artifact_dir = lifecycle.get("artifact_dir")
|
|
1014
|
+
lines = [
|
|
1015
|
+
"# Test Evidence Report",
|
|
1016
|
+
"",
|
|
1017
|
+
"Derived from structured evidence",
|
|
1018
|
+
f"Evidence ID: {manifest.get('evidence_id')}",
|
|
1019
|
+
f"Verdict: {verdict.get('verdict')}",
|
|
1020
|
+
f"Validator result: {validation.get('result') if validation else 'pending'}",
|
|
1021
|
+
"sensitivity=project-controlled",
|
|
1022
|
+
"The project owns access control, retention, and upload policy.",
|
|
1023
|
+
"Mocked code-level evidence does not verify a real deployed environment.",
|
|
1024
|
+
"",
|
|
1025
|
+
"## Scope",
|
|
1026
|
+
f"- Artifact Dir: {artifact_dir or 'not provided'}",
|
|
1027
|
+
"",
|
|
1028
|
+
f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
|
|
1029
|
+
f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
|
|
1030
|
+
"The legacy test-report interface is not supported.",
|
|
1031
|
+
"",
|
|
1032
|
+
]
|
|
1033
|
+
output = confined(evidence_dir, output_name)
|
|
1034
|
+
rendered = "\n".join(lines)
|
|
1035
|
+
lifecycle_finalized = lifecycle.get("finalized") is True
|
|
1036
|
+
if lifecycle_finalized:
|
|
1037
|
+
if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
|
|
1038
|
+
raise RequestError(
|
|
1039
|
+
"evidence package is finalized and immutable; re-init a new package before changing the report"
|
|
1040
|
+
)
|
|
1041
|
+
return output
|
|
1042
|
+
write_text_atomic(output, rendered)
|
|
1043
|
+
return output
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def aggregate_inventory(entries: list[dict[str, str]]) -> str:
|
|
1047
|
+
return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
|
|
1051
|
+
tests = plan.get("tests")
|
|
1052
|
+
if not isinstance(tests, list):
|
|
1053
|
+
return
|
|
1054
|
+
generated = evidence_dir / "generated-tests"
|
|
1055
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
1056
|
+
for test in tests:
|
|
1057
|
+
if not isinstance(test, dict):
|
|
1058
|
+
continue
|
|
1059
|
+
project_path = test.get("project_path")
|
|
1060
|
+
if not isinstance(project_path, str):
|
|
1061
|
+
continue
|
|
1062
|
+
status = test.get("change_status", test.get("origin", "existing"))
|
|
1063
|
+
if status not in {"existing", "added", "modified"}:
|
|
1064
|
+
raise RequestError(f"unknown test change_status: {test.get('id')}")
|
|
1065
|
+
if status == "existing":
|
|
1066
|
+
test["snapshot_path"] = None
|
|
1067
|
+
continue
|
|
1068
|
+
source = confined(project_root, project_path, must_exist=True)
|
|
1069
|
+
destination_relative = test.get("snapshot_path")
|
|
1070
|
+
if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
|
|
1071
|
+
destination_relative = f"generated-tests/{Path(project_path).name}"
|
|
1072
|
+
destination = confined(evidence_dir, destination_relative)
|
|
1073
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1074
|
+
shutil.copy2(source, destination)
|
|
1075
|
+
test["snapshot_path"] = destination_relative
|
|
1076
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def stage_output_owner(relative: str, change_class: str) -> str:
|
|
1080
|
+
if relative in {"change-classification.json"}:
|
|
1081
|
+
return "CHANGE_CLASSIFY"
|
|
1082
|
+
if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
|
|
1083
|
+
return "SCOPE_DISCOVER"
|
|
1084
|
+
if relative in {"behavior-risk-matrix.json"}:
|
|
1085
|
+
return "CONTRACT_MODEL"
|
|
1086
|
+
if relative in {"test-plan.json"}:
|
|
1087
|
+
return "TEST_PLAN"
|
|
1088
|
+
if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
|
|
1089
|
+
return "INFRA_READY"
|
|
1090
|
+
if relative.startswith("generated-tests/"):
|
|
1091
|
+
return "TEST_BUILD"
|
|
1092
|
+
if relative in {"executions.json", "differential-proof.json"} or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
|
|
1093
|
+
return "EXECUTE_PROVE"
|
|
1094
|
+
if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
|
|
1095
|
+
return "EVIDENCE_PACKAGE"
|
|
1096
|
+
return "EVIDENCE_PACKAGE"
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
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) -> dict[str, Any]:
|
|
1100
|
+
file_paths = sorted(
|
|
1101
|
+
path.relative_to(evidence_dir).as_posix()
|
|
1102
|
+
for path in evidence_dir.rglob("*")
|
|
1103
|
+
if path.is_file() and path.name != "manifest.json"
|
|
1104
|
+
)
|
|
1105
|
+
files = [
|
|
1106
|
+
{"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
|
|
1107
|
+
for relative in file_paths
|
|
1108
|
+
]
|
|
1109
|
+
outputs_by_stage = {stage: [] for stage in STAGES}
|
|
1110
|
+
for entry in files:
|
|
1111
|
+
outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
|
|
1112
|
+
predecessor_files: list[dict[str, str]] = []
|
|
1113
|
+
stages = []
|
|
1114
|
+
for stage in STAGES:
|
|
1115
|
+
outputs = sorted(outputs_by_stage.get(stage, []))
|
|
1116
|
+
stages.append({
|
|
1117
|
+
"name": stage,
|
|
1118
|
+
"status": "complete",
|
|
1119
|
+
"input_sha256": canonical_sha256({
|
|
1120
|
+
"stage": stage,
|
|
1121
|
+
"target_hashes": target_hashes,
|
|
1122
|
+
"predecessor_outputs": sorted(predecessor_files, key=lambda item: item["path"]),
|
|
1123
|
+
}),
|
|
1124
|
+
"outputs": outputs,
|
|
1125
|
+
"not_applicable": None,
|
|
1126
|
+
})
|
|
1127
|
+
predecessor_files.extend(
|
|
1128
|
+
{"path": output, "sha256": next(item["sha256"] for item in files if item["path"] == output)}
|
|
1129
|
+
for output in outputs if output not in FINAL_RECORDS
|
|
1130
|
+
)
|
|
1131
|
+
return {
|
|
1132
|
+
"protocol_version": "1.0",
|
|
1133
|
+
"evidence_id": evidence_id,
|
|
1134
|
+
"evidence_id_inputs": identity,
|
|
1135
|
+
"sensitivity": "project-controlled",
|
|
1136
|
+
"environment_claim": "mocked-code-level-only",
|
|
1137
|
+
"change_class": change_class,
|
|
1138
|
+
"baseline_commit": baseline_commit,
|
|
1139
|
+
"working_diff_sha256": identity["working_diff_sha256"],
|
|
1140
|
+
"target_hashes": target_hashes,
|
|
1141
|
+
"stages": stages,
|
|
1142
|
+
"files": files,
|
|
1143
|
+
"final_verdict": final_verdict,
|
|
1144
|
+
"compatibility": "legacy-test-report-interface-not-supported",
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
|
|
1149
|
+
if not artifact_dir:
|
|
1150
|
+
return
|
|
1151
|
+
artifact = Path(artifact_dir)
|
|
1152
|
+
if not artifact.is_absolute() and project_root is not None:
|
|
1153
|
+
artifact = project_root / artifact
|
|
1154
|
+
artifact = artifact.resolve()
|
|
1155
|
+
if not artifact.is_dir():
|
|
1156
|
+
raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
|
|
1157
|
+
pointer = artifact / "test-report-path.txt"
|
|
1158
|
+
write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
|
|
1162
|
+
allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
|
|
1163
|
+
if set(request) - allowed:
|
|
1164
|
+
raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
|
|
1165
|
+
if request.get("request_version", "1.0") != "1.0":
|
|
1166
|
+
raise RequestError("unsupported finalize request version")
|
|
1167
|
+
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1168
|
+
if lifecycle.get("finalized") is True:
|
|
1169
|
+
raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
|
|
1170
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
1171
|
+
if not capture_path.exists():
|
|
1172
|
+
run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
|
|
1173
|
+
capture = ensure_object(load_json(capture_path), "change capture")
|
|
1174
|
+
baseline = capture.get("baseline_commit")
|
|
1175
|
+
patch_path = evidence_dir / "source-change.patch"
|
|
1176
|
+
if not isinstance(baseline, str) or not patch_path.is_file():
|
|
1177
|
+
raise RequestError("canonical change capture is incomplete")
|
|
1178
|
+
change_class = request.get("change_class", "behavior")
|
|
1179
|
+
if change_class not in {"behavior", "lightweight"}:
|
|
1180
|
+
raise RequestError("change_class must be behavior or lightweight")
|
|
1181
|
+
artifact_dir = request.get("artifact_dir")
|
|
1182
|
+
if artifact_dir is not None and not isinstance(artifact_dir, str):
|
|
1183
|
+
raise RequestError("artifact_dir must be a project-relative directory")
|
|
1184
|
+
if artifact_dir is not None:
|
|
1185
|
+
artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
|
|
1186
|
+
lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
|
|
1187
|
+
scope_relative = request.get("scope_path", "scope.json")
|
|
1188
|
+
scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
|
|
1189
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1190
|
+
environment_path = evidence_dir / request.get("environment_path", "environment.json")
|
|
1191
|
+
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
1192
|
+
plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
|
|
1193
|
+
if isinstance(plan, dict):
|
|
1194
|
+
normalize_test_snapshots(project_root, evidence_dir, plan)
|
|
1195
|
+
categories = inventory.get("categories", {})
|
|
1196
|
+
target_hashes = {
|
|
1197
|
+
category: aggregate_inventory(categories.get(category, []))
|
|
1198
|
+
for category in CATEGORIES
|
|
1199
|
+
}
|
|
1200
|
+
target_hashes["environment"] = canonical_sha256(environment)
|
|
1201
|
+
target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
|
|
1202
|
+
scope["target_hashes"] = target_hashes
|
|
1203
|
+
write_json_atomic(evidence_dir / scope_relative, scope)
|
|
1204
|
+
identity = {
|
|
1205
|
+
"baseline_commit": baseline,
|
|
1206
|
+
"working_diff_sha256": file_sha256(patch_path),
|
|
1207
|
+
"scope_sha256": canonical_sha256(scope),
|
|
1208
|
+
}
|
|
1209
|
+
evidence_id = canonical_sha256(identity)
|
|
1210
|
+
executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
|
|
1211
|
+
blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
|
|
1212
|
+
required_records = {
|
|
1213
|
+
"change-classification.json", "scope.json", "target-inventory.json", "environment.json",
|
|
1214
|
+
"source-change.patch",
|
|
1215
|
+
}
|
|
1216
|
+
if change_class == "behavior":
|
|
1217
|
+
required_records.update({
|
|
1218
|
+
"behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
|
|
1219
|
+
"differential-proof.json",
|
|
1220
|
+
})
|
|
1221
|
+
else:
|
|
1222
|
+
required_records.add("lightweight-verification.json")
|
|
1223
|
+
missing_records = sorted(
|
|
1224
|
+
relative for relative in required_records
|
|
1225
|
+
if not (evidence_dir / relative).is_file()
|
|
1226
|
+
)
|
|
1227
|
+
if missing_records:
|
|
1228
|
+
blockers.append(
|
|
1229
|
+
"required evidence records are missing: " + ", ".join(missing_records)
|
|
1230
|
+
)
|
|
1231
|
+
selected_failures = [item for item in executions if isinstance(item, dict) and item.get("selected_execution") is True and item.get("reliable") is True and item.get("exit_code") != 0]
|
|
1232
|
+
if selected_failures and not request.get("repair_scope"):
|
|
1233
|
+
blockers.append("reliable failure exists but repair_scope is not evidence-backed")
|
|
1234
|
+
verdict = "TEST_FAIL" if selected_failures and request.get("repair_scope") else ("TEST_BLOCKED" if blockers else "TEST_PASS")
|
|
1235
|
+
verdict_record: dict[str, Any] = {
|
|
1236
|
+
"verdict": verdict,
|
|
1237
|
+
"testing_domain_only": True,
|
|
1238
|
+
"authorizes_commit_or_release": False,
|
|
1239
|
+
"real_environment_validated": False,
|
|
1240
|
+
"repairs_business_defects": False,
|
|
1241
|
+
"code_evidence_replayable": verdict == "TEST_PASS",
|
|
1242
|
+
"blockers": blockers if verdict == "TEST_BLOCKED" else [],
|
|
1243
|
+
"reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
|
|
1244
|
+
}
|
|
1245
|
+
if verdict == "TEST_FAIL" and request.get("repair_scope"):
|
|
1246
|
+
verdict_record["repair_scope"] = request["repair_scope"]
|
|
1247
|
+
write_json_atomic(evidence_dir / "verdict.json", verdict_record)
|
|
1248
|
+
write_json_atomic(evidence_dir / "validation.json", {})
|
|
1249
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1250
|
+
**lifecycle,
|
|
1251
|
+
"status": "finalizing",
|
|
1252
|
+
"finalized": False,
|
|
1253
|
+
"immutable_identity": identity,
|
|
1254
|
+
})
|
|
1255
|
+
manifest = build_manifest(
|
|
1256
|
+
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1257
|
+
target_hashes=target_hashes, change_class=change_class,
|
|
1258
|
+
baseline_commit=baseline, final_verdict=verdict,
|
|
1259
|
+
)
|
|
1260
|
+
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1261
|
+
run_render_report(evidence_dir, "test-report.md")
|
|
1262
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1263
|
+
**lifecycle,
|
|
1264
|
+
"status": "finalized",
|
|
1265
|
+
"finalized": True,
|
|
1266
|
+
"immutable_identity": identity,
|
|
1267
|
+
})
|
|
1268
|
+
output = confined(evidence_dir, output_name)
|
|
1269
|
+
write_json_atomic(output, {
|
|
1270
|
+
"command": "finalize",
|
|
1271
|
+
"evidence_id": evidence_id,
|
|
1272
|
+
"manifest_path": "manifest.json",
|
|
1273
|
+
"verdict_path": "verdict.json",
|
|
1274
|
+
"report_path": "test-report.md",
|
|
1275
|
+
"validation_path": "validation.json",
|
|
1276
|
+
"next": "validate_test_evidence.py --attest",
|
|
1277
|
+
})
|
|
1278
|
+
manifest = build_manifest(
|
|
1279
|
+
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1280
|
+
target_hashes=target_hashes, change_class=change_class,
|
|
1281
|
+
baseline_commit=baseline, final_verdict=verdict,
|
|
1282
|
+
)
|
|
1283
|
+
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1284
|
+
destination = evidence_dir.parent / evidence_id
|
|
1285
|
+
if destination != evidence_dir:
|
|
1286
|
+
if destination.exists():
|
|
1287
|
+
raise RequestError(
|
|
1288
|
+
f"evidence identity already exists at {destination}; re-init a new package"
|
|
1289
|
+
)
|
|
1290
|
+
os.replace(evidence_dir, destination)
|
|
1291
|
+
write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
|
|
1292
|
+
return destination / output_name
|
|
1293
|
+
|
|
1294
|
+
|
|
609
1295
|
def run_resume(
|
|
610
1296
|
project_root: Path,
|
|
611
1297
|
evidence_dir: Path,
|
|
@@ -615,6 +1301,11 @@ def run_resume(
|
|
|
615
1301
|
) -> Path:
|
|
616
1302
|
manifest = ensure_object(load_json(manifest_path), "manifest")
|
|
617
1303
|
inventory = ensure_object(load_json(inventory_path), "inventory")
|
|
1304
|
+
lifecycle = load_lifecycle(evidence_dir)
|
|
1305
|
+
if lifecycle.get("finalized") is True:
|
|
1306
|
+
raise RequestError(
|
|
1307
|
+
"evidence package is finalized and immutable; re-init a new evidence package before resume"
|
|
1308
|
+
)
|
|
618
1309
|
categories = ensure_object(inventory.get("categories"), "inventory categories")
|
|
619
1310
|
environment_path = evidence_dir / "environment.json"
|
|
620
1311
|
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
@@ -683,6 +1374,12 @@ def main() -> int:
|
|
|
683
1374
|
parser.add_argument("--project-root", required=True)
|
|
684
1375
|
parser.add_argument("--evidence-dir", required=True)
|
|
685
1376
|
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
1377
|
+
init = subparsers.add_parser("init")
|
|
1378
|
+
init.add_argument("--baseline")
|
|
1379
|
+
init.add_argument("--output", default="init.json")
|
|
1380
|
+
capture = subparsers.add_parser("capture-change")
|
|
1381
|
+
capture.add_argument("--baseline")
|
|
1382
|
+
capture.add_argument("--output", default="change-capture.json")
|
|
686
1383
|
inventory = subparsers.add_parser("inventory")
|
|
687
1384
|
inventory.add_argument("--request", required=True)
|
|
688
1385
|
inventory.add_argument("--output", default="target-inventory.json")
|
|
@@ -692,6 +1389,11 @@ def main() -> int:
|
|
|
692
1389
|
group.add_argument("--replay-receipt")
|
|
693
1390
|
differential = subparsers.add_parser("differential")
|
|
694
1391
|
differential.add_argument("--request", required=True)
|
|
1392
|
+
finalize = subparsers.add_parser("finalize")
|
|
1393
|
+
finalize.add_argument("--request", required=True, help="semantic finalization request")
|
|
1394
|
+
finalize.add_argument("--output", default="finalize.json")
|
|
1395
|
+
report = subparsers.add_parser("render-report")
|
|
1396
|
+
report.add_argument("--output", default="test-report.md")
|
|
695
1397
|
resume = subparsers.add_parser("resume")
|
|
696
1398
|
resume.add_argument("--manifest", required=True)
|
|
697
1399
|
resume.add_argument("--inventory", required=True)
|
|
@@ -703,11 +1405,19 @@ def main() -> int:
|
|
|
703
1405
|
if not project_root.is_dir():
|
|
704
1406
|
raise RequestError(f"project root does not exist: {project_root}")
|
|
705
1407
|
evidence_dir.relative_to(project_root)
|
|
706
|
-
|
|
707
|
-
|
|
1408
|
+
if args.subcommand == "init":
|
|
1409
|
+
output = run_init(project_root, evidence_dir, args.baseline, args.output)
|
|
1410
|
+
print(f"Evidence initialized: {output}")
|
|
1411
|
+
elif args.subcommand == "capture-change":
|
|
1412
|
+
require_mutable_lifecycle(evidence_dir)
|
|
1413
|
+
output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
|
|
1414
|
+
print(f"Change captured: {output}")
|
|
1415
|
+
elif args.subcommand == "inventory":
|
|
1416
|
+
require_mutable_lifecycle(evidence_dir)
|
|
708
1417
|
output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
709
1418
|
print(f"Inventory written: {output}")
|
|
710
1419
|
elif args.subcommand == "execute":
|
|
1420
|
+
require_mutable_lifecycle(evidence_dir)
|
|
711
1421
|
if args.request:
|
|
712
1422
|
receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
713
1423
|
else:
|
|
@@ -721,9 +1431,20 @@ def main() -> int:
|
|
|
721
1431
|
receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
|
|
722
1432
|
print(json.dumps(receipt, ensure_ascii=False))
|
|
723
1433
|
elif args.subcommand == "differential":
|
|
1434
|
+
require_mutable_lifecycle(evidence_dir)
|
|
724
1435
|
proof = run_differential(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
725
1436
|
print(json.dumps(proof, ensure_ascii=False))
|
|
726
|
-
|
|
1437
|
+
elif args.subcommand == "render-report":
|
|
1438
|
+
load_lifecycle(evidence_dir)
|
|
1439
|
+
output = run_render_report(evidence_dir, args.output)
|
|
1440
|
+
print(f"Report rendered: {output}")
|
|
1441
|
+
elif args.subcommand == "finalize":
|
|
1442
|
+
load_lifecycle(evidence_dir, required=True)
|
|
1443
|
+
request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
|
|
1444
|
+
output = run_finalize(project_root, evidence_dir, request, args.output)
|
|
1445
|
+
print(f"Evidence finalized: {output}")
|
|
1446
|
+
elif args.subcommand == "resume":
|
|
1447
|
+
load_lifecycle(evidence_dir)
|
|
727
1448
|
output = run_resume(
|
|
728
1449
|
project_root, evidence_dir, request_file(evidence_dir, args.manifest),
|
|
729
1450
|
request_file(evidence_dir, args.inventory), args.output,
|