prizmkit 1.1.131 → 1.1.135
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/assets/skill-subagent-integration.md +11 -6
- 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/runners.py +104 -17
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +3 -2
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +15 -13
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +3 -13
- package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +5 -4
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +4 -4
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +9 -9
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -7
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -3
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-commit-report.md +2 -2
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-manual-verification.md +5 -10
- package/bundled/dev-pipeline/templates/sections/bugfix-phase-review.md +5 -5
- package/bundled/dev-pipeline/templates/sections/bugfix-session-status.md +1 -1
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +1 -1
- package/bundled/dev-pipeline/templates/sections/headless-commit-authorization.md +2 -3
- 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-review-agent.md +5 -5
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +5 -5
- package/bundled/dev-pipeline/templates/sections/refactor-phase-commit-report.md +1 -1
- package/bundled/dev-pipeline/templates/sections/refactor-phase-review.md +5 -5
- 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_generate_bootstrap_prompt.py +39 -26
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +7 -10
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +3 -3
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +156 -5
- package/bundled/dev-pipeline/tests/test_unified_cli.py +34 -7
- package/bundled/skills/_metadata.json +3 -3
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-code-review/SKILL.md +22 -6
- package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +243 -0
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +41 -0
- package/bundled/skills/prizmkit-code-review/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +93 -1
- package/bundled/skills/prizmkit-committer/SKILL.md +6 -13
- package/bundled/skills/prizmkit-committer/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-implement/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-plan/SKILL.md +17 -3
- 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 +3 -3
- package/bundled/skills/prizmkit-retrospective/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-test/SKILL.md +26 -3
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +147 -14
- package/bundled/skills/prizmkit-test/assets/evidence-manifest.schema.json +1 -1
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +15 -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/test-generation-steps.md +5 -2
- package/bundled/skills/prizmkit-test/references/trusted-evidence-execution.md +15 -0
- package/bundled/skills/prizmkit-test/references/workflow-state-protocol.md +3 -3
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +1144 -27
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +336 -18
- package/bundled/skills/prizmkit-workflow/SKILL.md +1 -1
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +3 -3
- package/package.json +1 -1
|
@@ -2,10 +2,15 @@
|
|
|
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
|
|
8
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR prepare-tests --request REQUEST
|
|
6
9
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
|
|
7
10
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
|
|
8
11
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
|
|
12
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR finalize --request REQUEST
|
|
13
|
+
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR render-report
|
|
9
14
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR resume --manifest MANIFEST --inventory INVENTORY
|
|
10
15
|
|
|
11
16
|
Only locator arguments and evidence mechanics are fixed. Test commands, working directories,
|
|
@@ -17,6 +22,7 @@ from __future__ import annotations
|
|
|
17
22
|
|
|
18
23
|
import argparse
|
|
19
24
|
import datetime as dt
|
|
25
|
+
import fnmatch
|
|
20
26
|
import hashlib
|
|
21
27
|
import json
|
|
22
28
|
import os
|
|
@@ -35,13 +41,23 @@ STAGES = [
|
|
|
35
41
|
"EVIDENCE_VALIDATE",
|
|
36
42
|
]
|
|
37
43
|
CATEGORIES = ("source", "tests", "contracts", "lockfiles")
|
|
44
|
+
LAYERS = (
|
|
45
|
+
"focused", "module-component", "contract-integration",
|
|
46
|
+
"affected-module-regression", "regression-ring",
|
|
47
|
+
)
|
|
38
48
|
BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
39
49
|
SNAPSHOT_VOLATILE_ROOTS = (
|
|
40
50
|
".prizmkit/state",
|
|
41
51
|
".prizmkit/test/evidence",
|
|
52
|
+
".prizmkit/" + "dev-" + "pipeline",
|
|
42
53
|
".claude/worktrees",
|
|
54
|
+
".agents/worktrees",
|
|
55
|
+
".codebuddy/worktrees",
|
|
56
|
+
".codex/worktrees",
|
|
43
57
|
)
|
|
44
|
-
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__"}
|
|
58
|
+
SNAPSHOT_VOLATILE_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
|
|
59
|
+
INVENTORY_RESERVED_ROOTS = SNAPSHOT_VOLATILE_ROOTS
|
|
60
|
+
FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
|
|
45
61
|
|
|
46
62
|
|
|
47
63
|
class RequestError(Exception):
|
|
@@ -127,6 +143,167 @@ def ensure_string_list(value: Any, label: str, *, allow_empty: bool = True) -> l
|
|
|
127
143
|
return value
|
|
128
144
|
|
|
129
145
|
|
|
146
|
+
def normalize_relative(value: str) -> str:
|
|
147
|
+
normalized = Path(value).as_posix()
|
|
148
|
+
if normalized == "." or normalized.startswith("../") or "/../" in normalized:
|
|
149
|
+
raise RequestError(f"path is not project-relative: {value}")
|
|
150
|
+
return normalized
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def is_reserved_inventory_path(relative: str) -> bool:
|
|
154
|
+
path = Path(relative)
|
|
155
|
+
if any(part in SNAPSHOT_VOLATILE_NAMES for part in path.parts):
|
|
156
|
+
return True
|
|
157
|
+
return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def project_git(project_root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[bytes]:
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
["git", "-C", str(project_root), *args],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
check=False,
|
|
165
|
+
)
|
|
166
|
+
if check and result.returncode != 0:
|
|
167
|
+
raise RequestError(f"git {' '.join(args)} failed: {result.stderr.decode(errors='replace')}")
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def git_baseline(project_root: Path, requested: str | None = None) -> str:
|
|
172
|
+
baseline = requested or "HEAD"
|
|
173
|
+
result = project_git(project_root, "rev-parse", "--verify", baseline)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
raise RequestError(f"baseline commit is not resolvable: {baseline}")
|
|
176
|
+
return result.stdout.decode().strip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def git_status_entries(project_root: Path) -> list[dict[str, Any]]:
|
|
180
|
+
result = project_git(project_root, "status", "--porcelain=v1", "-z", "--untracked-files=all", check=True)
|
|
181
|
+
raw = result.stdout
|
|
182
|
+
entries: list[dict[str, Any]] = []
|
|
183
|
+
tokens = raw.split(b"\0")
|
|
184
|
+
index = 0
|
|
185
|
+
while index < len(tokens):
|
|
186
|
+
token = tokens[index]
|
|
187
|
+
index += 1
|
|
188
|
+
if not token:
|
|
189
|
+
continue
|
|
190
|
+
status = token[:2].decode(errors="replace")
|
|
191
|
+
path = token[3:].decode(errors="replace")
|
|
192
|
+
old_path = None
|
|
193
|
+
if "R" in status or "C" in status:
|
|
194
|
+
if index >= len(tokens):
|
|
195
|
+
raise RequestError("git status returned an incomplete rename/copy record")
|
|
196
|
+
old_path = tokens[index].decode(errors="replace")
|
|
197
|
+
index += 1
|
|
198
|
+
relative = normalize_relative(path)
|
|
199
|
+
if not is_reserved_inventory_path(relative):
|
|
200
|
+
record: dict[str, Any] = {"path": relative, "status": status, "old_path": old_path}
|
|
201
|
+
entries.append(record)
|
|
202
|
+
if old_path is not None:
|
|
203
|
+
old_relative = normalize_relative(old_path)
|
|
204
|
+
if not is_reserved_inventory_path(old_relative):
|
|
205
|
+
entries.append({"path": old_relative, "status": status, "old_path": old_relative, "role": "source"})
|
|
206
|
+
return sorted(entries, key=lambda item: (item["path"], item.get("old_path") or ""))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def git_diff_entries(project_root: Path, baseline: str) -> list[dict[str, Any]]:
|
|
210
|
+
result = project_git(
|
|
211
|
+
project_root, "diff", "--name-status", "-z", "--find-renames", "--find-copies",
|
|
212
|
+
baseline, "--", ".",
|
|
213
|
+
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
214
|
+
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
215
|
+
check=True,
|
|
216
|
+
)
|
|
217
|
+
tokens = result.stdout.split(b"\0")
|
|
218
|
+
entries: list[dict[str, Any]] = []
|
|
219
|
+
index = 0
|
|
220
|
+
while index < len(tokens):
|
|
221
|
+
status_token = tokens[index].decode(errors="replace")
|
|
222
|
+
index += 1
|
|
223
|
+
if not status_token:
|
|
224
|
+
continue
|
|
225
|
+
if index >= len(tokens):
|
|
226
|
+
raise RequestError("git diff returned an incomplete changed-file record")
|
|
227
|
+
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
228
|
+
index += 1
|
|
229
|
+
status = status_token[0]
|
|
230
|
+
old_path = None
|
|
231
|
+
if status in {"R", "C"}:
|
|
232
|
+
if index >= len(tokens):
|
|
233
|
+
raise RequestError("git diff returned an incomplete rename/copy record")
|
|
234
|
+
old_path = first
|
|
235
|
+
first = normalize_relative(tokens[index].decode(errors="replace"))
|
|
236
|
+
index += 1
|
|
237
|
+
if not is_reserved_inventory_path(first):
|
|
238
|
+
entries.append({"path": first, "status": status, "old_path": old_path})
|
|
239
|
+
if old_path and not is_reserved_inventory_path(old_path):
|
|
240
|
+
entries.append({"path": old_path, "status": status, "old_path": old_path, "role": "source"})
|
|
241
|
+
return entries
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def merge_changed_file_entries(
|
|
245
|
+
baseline_entries: list[dict[str, Any]],
|
|
246
|
+
working_entries: list[dict[str, Any]],
|
|
247
|
+
) -> list[dict[str, Any]]:
|
|
248
|
+
merged: dict[tuple[str, str | None, str | None], dict[str, Any]] = {}
|
|
249
|
+
for entry in baseline_entries + working_entries:
|
|
250
|
+
key = (entry["path"], entry.get("old_path"), entry.get("role"))
|
|
251
|
+
existing = merged.get(key)
|
|
252
|
+
if existing is None:
|
|
253
|
+
merged[key] = dict(entry)
|
|
254
|
+
continue
|
|
255
|
+
statuses = {existing.get("status", "").strip(), entry.get("status", "").strip()}
|
|
256
|
+
if "D" in statuses:
|
|
257
|
+
existing["status"] = "D"
|
|
258
|
+
elif "R" in statuses:
|
|
259
|
+
existing["status"] = "R"
|
|
260
|
+
elif "C" in statuses:
|
|
261
|
+
existing["status"] = "C"
|
|
262
|
+
elif statuses:
|
|
263
|
+
existing["status"] = "M"
|
|
264
|
+
return sorted(merged.values(), key=lambda item: (item["path"], item.get("old_path") or "", item.get("role") or ""))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def canonical_change_patch(project_root: Path, baseline: str) -> tuple[bytes, list[dict[str, Any]]]:
|
|
268
|
+
status_entries = git_status_entries(project_root)
|
|
269
|
+
baseline_entries = git_diff_entries(project_root, baseline)
|
|
270
|
+
status_entries = merge_changed_file_entries(baseline_entries, status_entries)
|
|
271
|
+
tracked = project_git(
|
|
272
|
+
project_root, "diff", "--binary", "--full-index", "--find-renames", "--find-copies",
|
|
273
|
+
baseline, "--", ".",
|
|
274
|
+
":!.prizmkit/state/**", ":!.prizmkit/test/evidence/**", ":!.prizmkit/" + "dev-" + "pipeline/**",
|
|
275
|
+
":!.claude/worktrees/**", ":!.agents/worktrees/**", ":!.codebuddy/worktrees/**", ":!.codex/worktrees/**",
|
|
276
|
+
)
|
|
277
|
+
if tracked.returncode != 0:
|
|
278
|
+
raise RequestError(f"cannot capture git diff: {tracked.stderr.decode(errors='replace')}")
|
|
279
|
+
chunks = [tracked.stdout]
|
|
280
|
+
untracked = [
|
|
281
|
+
item["path"] for item in status_entries
|
|
282
|
+
if item["status"].strip() == "??" and item.get("old_path") is None
|
|
283
|
+
]
|
|
284
|
+
for relative in sorted(untracked):
|
|
285
|
+
candidate = confined(project_root, relative, must_exist=True)
|
|
286
|
+
if candidate.is_file():
|
|
287
|
+
result = subprocess.run(
|
|
288
|
+
["git", "diff", "--no-index", "--binary", "/dev/null", "--", relative],
|
|
289
|
+
cwd=project_root,
|
|
290
|
+
capture_output=True,
|
|
291
|
+
check=False,
|
|
292
|
+
)
|
|
293
|
+
if result.returncode not in (0, 1):
|
|
294
|
+
raise RequestError(f"cannot capture untracked file: {relative}")
|
|
295
|
+
chunks.append(result.stdout)
|
|
296
|
+
patch = b"".join(chunk for chunk in chunks if chunk)
|
|
297
|
+
return patch, status_entries
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def write_text_atomic(path: Path, value: str) -> None:
|
|
301
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
303
|
+
temporary.write_text(value, encoding="utf-8")
|
|
304
|
+
os.replace(temporary, path)
|
|
305
|
+
|
|
306
|
+
|
|
130
307
|
def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
131
308
|
if not isinstance(value, list):
|
|
132
309
|
raise RequestError("external_targets must be an array")
|
|
@@ -151,13 +328,73 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
|
151
328
|
return value
|
|
152
329
|
|
|
153
330
|
|
|
154
|
-
def
|
|
331
|
+
def load_change_capture(
|
|
332
|
+
project_root: Path,
|
|
333
|
+
evidence_dir: Path,
|
|
334
|
+
*,
|
|
335
|
+
require_fresh: bool = False,
|
|
336
|
+
) -> dict[str, Any]:
|
|
337
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
338
|
+
if not capture_path.is_file():
|
|
339
|
+
raise RequestError("canonical change capture is required; run capture-change first")
|
|
340
|
+
capture = ensure_object(load_json(capture_path), "change capture")
|
|
341
|
+
baseline = capture.get("baseline_commit")
|
|
342
|
+
patch_path = evidence_dir / "source-change.patch"
|
|
343
|
+
if (
|
|
344
|
+
capture.get("capture_format") != "prizmkit-canonical-change-v1"
|
|
345
|
+
or not isinstance(baseline, str)
|
|
346
|
+
or not patch_path.is_file()
|
|
347
|
+
or capture.get("patch_path") != "source-change.patch"
|
|
348
|
+
or capture.get("patch_sha256") != file_sha256(patch_path)
|
|
349
|
+
or not isinstance(capture.get("changed_files"), list)
|
|
350
|
+
):
|
|
351
|
+
raise RequestError("canonical change capture is incomplete or inconsistent")
|
|
352
|
+
if require_fresh:
|
|
353
|
+
live_patch, live_entries = canonical_change_patch(project_root, baseline)
|
|
354
|
+
live_patch_sha256 = hashlib.sha256(live_patch).hexdigest()
|
|
355
|
+
if (
|
|
356
|
+
live_patch_sha256 != capture["patch_sha256"]
|
|
357
|
+
or live_entries != capture["changed_files"]
|
|
358
|
+
or tree_sha256(project_root, [evidence_dir]) != capture.get("source_tree_sha256")
|
|
359
|
+
):
|
|
360
|
+
raise RequestError(
|
|
361
|
+
"canonical change capture is stale; rerun capture-change and inventory after repository changes"
|
|
362
|
+
)
|
|
363
|
+
return capture
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
|
|
367
|
+
paths = {
|
|
368
|
+
item["path"]
|
|
369
|
+
for item in capture.get("changed_files", [])
|
|
370
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
371
|
+
}
|
|
372
|
+
return sorted(paths)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
|
|
376
|
+
path = evidence_dir / "test-plan.json"
|
|
377
|
+
if not path.exists():
|
|
378
|
+
return None
|
|
379
|
+
return ensure_object(load_json(path), "test plan")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
383
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
|
|
384
|
+
raise RequestError("builder-generated test plan is required before execution")
|
|
385
|
+
entries = [item for item in plan["tests"] if isinstance(item, dict)]
|
|
386
|
+
if len(entries) != len(plan["tests"]):
|
|
387
|
+
raise RequestError("test plan contains a non-object test entry")
|
|
388
|
+
return entries
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
155
392
|
request = ensure_object(request, "execution request")
|
|
156
393
|
required = {
|
|
157
394
|
"request_version", "purpose", "command", "cwd", "environment",
|
|
158
|
-
"tool_version_commands", "layer", "
|
|
395
|
+
"tool_version_commands", "layer", "external_targets",
|
|
159
396
|
}
|
|
160
|
-
optional = {"timeout_seconds", "attempt_policy", "concurrency"}
|
|
397
|
+
optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
|
|
161
398
|
if not required <= set(request) or set(request) - required - optional:
|
|
162
399
|
raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
|
|
163
400
|
if request["request_version"] != "1.0":
|
|
@@ -175,13 +412,152 @@ def validate_execution_request(request: Any) -> dict[str, Any]:
|
|
|
175
412
|
if not isinstance(name, str):
|
|
176
413
|
raise RequestError("tool probe names must be strings")
|
|
177
414
|
ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
|
|
178
|
-
|
|
415
|
+
has_ids = "test_ids" in request
|
|
416
|
+
has_paths = "test_paths" in request
|
|
417
|
+
auto_bind = request.get("auto_bind") is True
|
|
418
|
+
if not has_ids and not has_paths and not auto_bind:
|
|
419
|
+
raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
|
|
420
|
+
if has_ids:
|
|
421
|
+
test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
|
|
422
|
+
if len(set(test_ids)) != len(test_ids):
|
|
423
|
+
raise RequestError("execution request test_ids contains duplicates")
|
|
424
|
+
if has_paths:
|
|
425
|
+
test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
|
|
426
|
+
if len(set(test_paths)) != len(test_paths):
|
|
427
|
+
raise RequestError("execution request test_paths contains duplicates")
|
|
428
|
+
if "auto_bind" in request and not isinstance(request["auto_bind"], bool):
|
|
429
|
+
raise RequestError("execution request auto_bind must be boolean")
|
|
430
|
+
if request["layer"] not in LAYERS and request["layer"] != "lightweight":
|
|
431
|
+
raise RequestError(f"unknown execution layer: {request['layer']}")
|
|
432
|
+
if plan is not None and has_ids:
|
|
433
|
+
tests = test_plan_entries(plan)
|
|
434
|
+
known = {item.get("id") for item in tests}
|
|
435
|
+
unknown = sorted(set(request["test_ids"]) - known)
|
|
436
|
+
if unknown:
|
|
437
|
+
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
438
|
+
test_map = {item.get("id"): item for item in tests}
|
|
439
|
+
invalid_layer = sorted(
|
|
440
|
+
test_id for test_id in request["test_ids"]
|
|
441
|
+
if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
|
|
442
|
+
)
|
|
443
|
+
if invalid_layer:
|
|
444
|
+
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
179
445
|
validate_external_targets(request["external_targets"])
|
|
180
446
|
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
447
|
raise RequestError("timeout_seconds must be a positive number")
|
|
182
448
|
return request
|
|
183
449
|
|
|
184
450
|
|
|
451
|
+
def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
|
|
452
|
+
if not isinstance(value, str) or not value or value.startswith("-"):
|
|
453
|
+
return None
|
|
454
|
+
candidate = Path(value)
|
|
455
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
|
|
456
|
+
try:
|
|
457
|
+
return resolved.relative_to(project_root.resolve()).as_posix()
|
|
458
|
+
except ValueError:
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
|
|
463
|
+
cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
464
|
+
command = request["command"]
|
|
465
|
+
executable = Path(command[0]).name.lower()
|
|
466
|
+
subcommands = {item for item in command[1:] if not item.startswith("-")}
|
|
467
|
+
path_tokens: list[str] = []
|
|
468
|
+
after_separator = False
|
|
469
|
+
skip_option_value = False
|
|
470
|
+
options_with_values = {
|
|
471
|
+
"-C", "--config", "--config-file", "--project", "--package", "-p",
|
|
472
|
+
"--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
|
|
473
|
+
}
|
|
474
|
+
for token in command[1:]:
|
|
475
|
+
if skip_option_value:
|
|
476
|
+
skip_option_value = False
|
|
477
|
+
continue
|
|
478
|
+
if token == "--":
|
|
479
|
+
after_separator = True
|
|
480
|
+
continue
|
|
481
|
+
if token in options_with_values:
|
|
482
|
+
skip_option_value = True
|
|
483
|
+
continue
|
|
484
|
+
if token.startswith("-"):
|
|
485
|
+
continue
|
|
486
|
+
if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
|
|
487
|
+
continue
|
|
488
|
+
if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
|
|
489
|
+
path_tokens.append(token)
|
|
490
|
+
|
|
491
|
+
package_wide = (
|
|
492
|
+
executable in {"pytest", "vitest", "jest", "mocha", "busted"}
|
|
493
|
+
or (executable in {"python", "python3", "py.test"} and "pytest" in command)
|
|
494
|
+
or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
|
|
495
|
+
or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
|
|
496
|
+
)
|
|
497
|
+
if not path_tokens:
|
|
498
|
+
if package_wide:
|
|
499
|
+
return sorted({item["id"] for item in tests})
|
|
500
|
+
raise RequestError(
|
|
501
|
+
"auto_bind cannot infer native test scope from command; provide test_paths or use a recognized package-wide test command"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
matched_paths: set[str] = set()
|
|
505
|
+
for token in path_tokens:
|
|
506
|
+
recursive_scope = token == "..." or token.endswith("/...")
|
|
507
|
+
scope_token = token[:-3].rstrip("/") if recursive_scope else token
|
|
508
|
+
normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
|
|
509
|
+
if normalized is None:
|
|
510
|
+
continue
|
|
511
|
+
has_glob = any(mark in normalized for mark in "*?[]")
|
|
512
|
+
for test in tests:
|
|
513
|
+
test_path = test["project_path"]
|
|
514
|
+
within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
|
|
515
|
+
if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
|
|
516
|
+
not has_glob and not recursive_scope and within_scope
|
|
517
|
+
):
|
|
518
|
+
matched_paths.add(test_path)
|
|
519
|
+
if not matched_paths:
|
|
520
|
+
raise RequestError(
|
|
521
|
+
"auto_bind command scope does not cover any prepared native test; provide test_paths explicitly"
|
|
522
|
+
)
|
|
523
|
+
return [item["id"] for item in tests if item["project_path"] in matched_paths]
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
|
|
527
|
+
plan = plan_for_execution(evidence_dir)
|
|
528
|
+
if plan is None:
|
|
529
|
+
if "test_ids" not in request:
|
|
530
|
+
raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
|
|
531
|
+
return request
|
|
532
|
+
if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
|
|
533
|
+
raise RequestError("canonical lifecycle requires prepare-tests before execute or differential")
|
|
534
|
+
tests = test_plan_entries(plan)
|
|
535
|
+
by_id = {item["id"]: item for item in tests}
|
|
536
|
+
by_path = {item["project_path"]: item for item in tests}
|
|
537
|
+
if "test_ids" in request:
|
|
538
|
+
resolved = list(request["test_ids"])
|
|
539
|
+
elif "test_paths" in request:
|
|
540
|
+
unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
|
|
541
|
+
if unknown_paths:
|
|
542
|
+
raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
|
|
543
|
+
resolved = [by_path[path]["id"] for path in request["test_paths"]]
|
|
544
|
+
else:
|
|
545
|
+
resolved = command_scope_test_ids(project_root, request, tests)
|
|
546
|
+
if not resolved:
|
|
547
|
+
raise RequestError("execution request resolved no prepared tests")
|
|
548
|
+
unknown = sorted(set(resolved) - set(by_id))
|
|
549
|
+
if unknown:
|
|
550
|
+
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
551
|
+
invalid_layer = sorted(
|
|
552
|
+
test_id for test_id in resolved
|
|
553
|
+
if request["layer"] not in (by_id[test_id].get("layers") or [])
|
|
554
|
+
)
|
|
555
|
+
if invalid_layer:
|
|
556
|
+
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
557
|
+
request["test_ids"] = resolved
|
|
558
|
+
return request
|
|
559
|
+
|
|
560
|
+
|
|
185
561
|
def is_volatile_snapshot_path(root: Path, candidate: Path, excluded: list[Path] | None = None) -> bool:
|
|
186
562
|
try:
|
|
187
563
|
relative = candidate.relative_to(root)
|
|
@@ -205,6 +581,7 @@ def tree_sha256(root: Path, excluded: list[Path] | None = None) -> str:
|
|
|
205
581
|
|
|
206
582
|
|
|
207
583
|
def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
|
|
584
|
+
project_resolved = project_root.resolve()
|
|
208
585
|
paths: set[Path] = set()
|
|
209
586
|
for pattern in patterns:
|
|
210
587
|
if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
|
|
@@ -214,17 +591,67 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
|
|
|
214
591
|
paths.add(candidate.resolve())
|
|
215
592
|
entries = []
|
|
216
593
|
for candidate in sorted(paths):
|
|
217
|
-
relative = candidate.relative_to(
|
|
218
|
-
if relative not in exclusions:
|
|
594
|
+
relative = candidate.relative_to(project_resolved).as_posix()
|
|
595
|
+
if relative not in exclusions and not is_reserved_inventory_path(relative):
|
|
219
596
|
entries.append({"path": relative, "sha256": file_sha256(candidate)})
|
|
220
597
|
return entries
|
|
221
598
|
|
|
222
599
|
|
|
600
|
+
def validate_category_disjointness(output_categories: dict[str, list[dict[str, str]]]) -> None:
|
|
601
|
+
membership: dict[str, str] = {}
|
|
602
|
+
for category, entries in output_categories.items():
|
|
603
|
+
for entry in entries:
|
|
604
|
+
path = entry["path"]
|
|
605
|
+
previous = membership.get(path)
|
|
606
|
+
if previous is not None:
|
|
607
|
+
raise RequestError(f"inventory path appears in multiple categories: {path} ({previous}, {category})")
|
|
608
|
+
membership[path] = category
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def validate_module_root_request(
|
|
612
|
+
project_root: Path,
|
|
613
|
+
evidence_dir: Path,
|
|
614
|
+
module_roots: list[Any],
|
|
615
|
+
changed_files: list[str],
|
|
616
|
+
) -> tuple[list[str], dict[str, list[str]], dict[str, list[str]]]:
|
|
617
|
+
normalized_roots: list[str] = []
|
|
618
|
+
explicit_changed: dict[str, list[str]] = {}
|
|
619
|
+
for item in module_roots:
|
|
620
|
+
if isinstance(item, str):
|
|
621
|
+
relative = normalize_relative(item)
|
|
622
|
+
changed_set: list[str] = []
|
|
623
|
+
elif isinstance(item, dict) and set(item) <= {"path", "changed_files"} and isinstance(item.get("path"), str):
|
|
624
|
+
relative = normalize_relative(item["path"])
|
|
625
|
+
changed_set = ensure_string_list(item.get("changed_files", []), f"module root {relative} changed_files")
|
|
626
|
+
else:
|
|
627
|
+
raise RequestError("module_roots entries must be paths or {path, changed_files} objects")
|
|
628
|
+
confined(project_root, relative, must_exist=True)
|
|
629
|
+
if relative in normalized_roots:
|
|
630
|
+
raise RequestError(f"duplicate module root: {relative}")
|
|
631
|
+
for changed in changed_set:
|
|
632
|
+
changed = normalize_relative(changed)
|
|
633
|
+
if not path_is_below_or_equal(changed, relative):
|
|
634
|
+
raise RequestError(f"module root changed file lies outside root: {changed}")
|
|
635
|
+
if changed not in changed_files:
|
|
636
|
+
raise RequestError(f"module root changed file is not in changed_files: {changed}")
|
|
637
|
+
normalized_roots.append(relative)
|
|
638
|
+
explicit_changed[relative] = sorted(changed_set)
|
|
639
|
+
enumerated = enumerate_module_root_files(project_root, evidence_dir, normalized_roots)
|
|
640
|
+
return normalized_roots, explicit_changed, enumerated
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def path_is_below_or_equal(path: str, root: str) -> bool:
|
|
644
|
+
normalized_path = Path(path)
|
|
645
|
+
normalized_root = Path(root)
|
|
646
|
+
return normalized_path == normalized_root or normalized_root in normalized_path.parents
|
|
647
|
+
|
|
648
|
+
|
|
223
649
|
def enumerate_module_root_files(
|
|
224
650
|
project_root: Path,
|
|
225
651
|
evidence_dir: Path,
|
|
226
652
|
module_roots: list[str],
|
|
227
653
|
) -> dict[str, list[str]]:
|
|
654
|
+
project_resolved = project_root.resolve()
|
|
228
655
|
evidence_resolved = evidence_dir.resolve()
|
|
229
656
|
result: dict[str, list[str]] = {}
|
|
230
657
|
for relative in module_roots:
|
|
@@ -237,7 +664,7 @@ def enumerate_module_root_files(
|
|
|
237
664
|
resolved = candidate.resolve()
|
|
238
665
|
if resolved == evidence_resolved or evidence_resolved in resolved.parents:
|
|
239
666
|
continue
|
|
240
|
-
files.append(resolved.relative_to(
|
|
667
|
+
files.append(resolved.relative_to(project_resolved).as_posix())
|
|
241
668
|
result[relative] = sorted(files)
|
|
242
669
|
return result
|
|
243
670
|
|
|
@@ -257,28 +684,82 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
257
684
|
for exclusion in exclusions:
|
|
258
685
|
if not isinstance(exclusion, dict) or set(exclusion) != {"path", "reason", "evidence"}:
|
|
259
686
|
raise RequestError("each inventory exclusion requires path, reason, and evidence")
|
|
260
|
-
|
|
687
|
+
relative = normalize_relative(exclusion["path"])
|
|
688
|
+
if is_reserved_inventory_path(relative):
|
|
689
|
+
raise RequestError(f"reserved inventory path must be automatically excluded: {relative}")
|
|
690
|
+
confined(project_root, relative)
|
|
261
691
|
if not isinstance(exclusion["reason"], str) or len(exclusion["reason"].strip()) < 8:
|
|
262
692
|
raise RequestError("inventory exclusion reason is too short")
|
|
263
693
|
if not isinstance(exclusion["evidence"], list) or not exclusion["evidence"]:
|
|
264
694
|
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
|
-
|
|
695
|
+
exclusion_paths.add(relative)
|
|
696
|
+
changed_files = sorted({normalize_relative(item) for item in ensure_string_list(request["changed_files"], "changed_files", allow_empty=False)})
|
|
697
|
+
if len(changed_files) != len(request["changed_files"]):
|
|
698
|
+
raise RequestError("changed_files contains duplicates")
|
|
699
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
700
|
+
captured_deleted: set[str] = set()
|
|
701
|
+
if capture_path.is_file():
|
|
702
|
+
capture = ensure_object(load_json(capture_path), "change capture")
|
|
703
|
+
for entry in capture.get("changed_files", []):
|
|
704
|
+
if isinstance(entry, dict) and entry.get("status", "").strip() == "D":
|
|
705
|
+
captured_deleted.add(entry.get("path"))
|
|
706
|
+
if isinstance(entry, dict) and entry.get("role") == "source":
|
|
707
|
+
captured_deleted.add(entry.get("path"))
|
|
268
708
|
for relative in changed_files:
|
|
269
|
-
confined(
|
|
270
|
-
|
|
271
|
-
|
|
709
|
+
confined(
|
|
710
|
+
project_root,
|
|
711
|
+
relative,
|
|
712
|
+
must_exist=relative not in exclusion_paths and relative not in captured_deleted,
|
|
713
|
+
)
|
|
714
|
+
raw_module_roots = request["module_roots"]
|
|
715
|
+
if not isinstance(raw_module_roots, list) or not raw_module_roots:
|
|
716
|
+
raise RequestError("module_roots must be a non-empty array")
|
|
717
|
+
module_roots, explicit_changed, module_root_files = validate_module_root_request(
|
|
718
|
+
project_root, evidence_dir, raw_module_roots, changed_files,
|
|
719
|
+
)
|
|
272
720
|
output_categories = {
|
|
273
721
|
name: collect_matches(project_root, ensure_string_list(categories[name], f"category {name}"), exclusion_paths)
|
|
274
722
|
for name in CATEGORIES
|
|
275
723
|
}
|
|
724
|
+
validate_category_disjointness(output_categories)
|
|
276
725
|
inventoried_paths = {
|
|
277
726
|
entry["path"]
|
|
278
727
|
for entries in output_categories.values()
|
|
279
728
|
for entry in entries
|
|
280
729
|
}
|
|
281
|
-
|
|
730
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
731
|
+
if capture_path.is_file():
|
|
732
|
+
capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
|
|
733
|
+
captured_paths = set(captured_changed_paths(capture))
|
|
734
|
+
if captured_paths != set(changed_files):
|
|
735
|
+
missing = sorted(captured_paths - set(changed_files))
|
|
736
|
+
extra = sorted(set(changed_files) - captured_paths)
|
|
737
|
+
details = []
|
|
738
|
+
if missing:
|
|
739
|
+
details.append("missing from inventory request: " + ", ".join(missing))
|
|
740
|
+
if extra:
|
|
741
|
+
details.append("not present in canonical capture: " + ", ".join(extra))
|
|
742
|
+
raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
|
|
743
|
+
captured_deleted = {
|
|
744
|
+
item["path"]
|
|
745
|
+
for item in capture.get("changed_files", [])
|
|
746
|
+
if isinstance(item, dict)
|
|
747
|
+
and isinstance(item.get("path"), str)
|
|
748
|
+
and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
|
|
749
|
+
}
|
|
750
|
+
unaccounted = sorted(
|
|
751
|
+
captured_paths
|
|
752
|
+
- inventoried_paths
|
|
753
|
+
- exclusion_paths
|
|
754
|
+
- captured_deleted
|
|
755
|
+
)
|
|
756
|
+
if unaccounted:
|
|
757
|
+
raise RequestError(
|
|
758
|
+
"canonical changed files are neither inventoried nor explicitly excluded: "
|
|
759
|
+
+ ", ".join(unaccounted)
|
|
760
|
+
)
|
|
761
|
+
elif (evidence_dir / "lifecycle.json").is_file():
|
|
762
|
+
raise RequestError("canonical lifecycle requires capture-change before inventory")
|
|
282
763
|
missing_from_inventory = sorted({
|
|
283
764
|
relative
|
|
284
765
|
for files in module_root_files.values()
|
|
@@ -290,6 +771,9 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
290
771
|
"module root contains files that are neither inventoried nor excluded: "
|
|
291
772
|
+ ", ".join(missing_from_inventory[:20])
|
|
292
773
|
)
|
|
774
|
+
for root, changed_set in explicit_changed.items():
|
|
775
|
+
if changed_set and not set(changed_set) <= set(module_root_files[root]):
|
|
776
|
+
raise RequestError(f"module root changed_files contains a path outside enumerated root: {root}")
|
|
293
777
|
output = {
|
|
294
778
|
"inventory_request_path": evidence_relative(evidence_dir, request_path),
|
|
295
779
|
"inventory_request_sha256": file_sha256(request_path),
|
|
@@ -297,6 +781,7 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
297
781
|
"changed_files": changed_files,
|
|
298
782
|
"module_roots": module_roots,
|
|
299
783
|
"module_root_files": module_root_files,
|
|
784
|
+
"module_root_changed_files": explicit_changed,
|
|
300
785
|
"exclusions": sorted(exclusion_paths),
|
|
301
786
|
"discovery_evidence": request["discovery_evidence"],
|
|
302
787
|
"plan_inputs": ensure_object(request["plan_inputs"], "plan_inputs"),
|
|
@@ -390,7 +875,12 @@ def execute_request(
|
|
|
390
875
|
isolation: dict[str, Any] | None = None,
|
|
391
876
|
selected_execution: bool = True,
|
|
392
877
|
) -> dict[str, Any]:
|
|
393
|
-
|
|
878
|
+
request_value = load_json(request_path)
|
|
879
|
+
original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
|
|
880
|
+
request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
|
|
881
|
+
request = resolve_execution_test_ids(project_root, evidence_dir, request)
|
|
882
|
+
if original_test_ids != request["test_ids"]:
|
|
883
|
+
write_json_atomic(request_path, request)
|
|
394
884
|
root = execution_root or project_root
|
|
395
885
|
cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
396
886
|
complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
|
@@ -501,12 +991,67 @@ def materialize_request(evidence_dir: Path, name: str, request: dict[str, Any])
|
|
|
501
991
|
return path
|
|
502
992
|
|
|
503
993
|
|
|
994
|
+
def validate_not_applicable(value: Any) -> dict[str, Any]:
|
|
995
|
+
value = ensure_object(value, "differential N/A decision")
|
|
996
|
+
required = {"reason", "rationale", "evidence", "considered_signals", "conflicts"}
|
|
997
|
+
if set(value) != required:
|
|
998
|
+
raise RequestError(f"differential N/A fields must be exactly {sorted(required)}")
|
|
999
|
+
if value["reason"] not in {"new-behavior", "textual-contract"}:
|
|
1000
|
+
raise RequestError("differential N/A reason must be new-behavior or textual-contract")
|
|
1001
|
+
if not isinstance(value["rationale"], str) or len(value["rationale"].strip()) < 16:
|
|
1002
|
+
raise RequestError("differential N/A rationale is too short")
|
|
1003
|
+
ensure_string_list(value["evidence"], "differential N/A evidence", allow_empty=False)
|
|
1004
|
+
ensure_string_list(value["considered_signals"], "differential N/A considered_signals", allow_empty=False)
|
|
1005
|
+
if not isinstance(value["conflicts"], list):
|
|
1006
|
+
raise RequestError("differential N/A conflicts must be an array")
|
|
1007
|
+
if value["reason"] == "new-behavior" and "no stable pre-change observable" not in value["rationale"].lower():
|
|
1008
|
+
raise RequestError("new-behavior differential N/A must explain that no stable pre-change observable exists")
|
|
1009
|
+
if value["reason"] == "textual-contract" and "textual" not in value["rationale"].lower():
|
|
1010
|
+
raise RequestError("textual-contract differential N/A must explain why behavior is textual")
|
|
1011
|
+
return value
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
|
|
1015
|
+
proof_path = evidence_dir / "differential-proof.json"
|
|
1016
|
+
proof_record = load_json(proof_path) if proof_path.exists() else {"proofs": []}
|
|
1017
|
+
if not isinstance(proof_record, dict) or not isinstance(proof_record.get("proofs"), list):
|
|
1018
|
+
raise RequestError("differential-proof.json has invalid append target")
|
|
1019
|
+
if any(item.get("behavior_id") == proof.get("behavior_id") for item in proof_record["proofs"] if isinstance(item, dict)):
|
|
1020
|
+
raise RequestError(f"duplicate differential proof: {proof.get('behavior_id')}")
|
|
1021
|
+
proof_record["proofs"].append(proof)
|
|
1022
|
+
write_json_atomic(proof_path, proof_record)
|
|
1023
|
+
|
|
1024
|
+
|
|
504
1025
|
def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
|
|
505
1026
|
request = ensure_object(load_json(request_path), "differential request")
|
|
506
1027
|
required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "current_tree_sha256", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
|
|
1028
|
+
na_required = {"request_version", "behavior_id", "method", "not_applicable"}
|
|
1029
|
+
if set(request) == na_required:
|
|
1030
|
+
if request["request_version"] != "1.0" or request["method"] != "not-applicable":
|
|
1031
|
+
raise RequestError("invalid differential N/A request")
|
|
1032
|
+
not_applicable = validate_not_applicable(request["not_applicable"])
|
|
1033
|
+
proof = {
|
|
1034
|
+
"behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
|
|
1035
|
+
"method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
|
|
1036
|
+
"differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
|
|
1037
|
+
"current_tree_sha256": None, "baseline_execution_id": None, "mutation_execution_id": None,
|
|
1038
|
+
"current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
|
|
1039
|
+
"project_tree_before_sha256": None, "project_tree_after_sha256": None, "isolation_tree_sha256": None,
|
|
1040
|
+
"mutation_apply_sha256": None, "mutation_restore_sha256": None, "not_applicable": not_applicable,
|
|
1041
|
+
}
|
|
1042
|
+
append_proof(evidence_dir, proof)
|
|
1043
|
+
return proof
|
|
507
1044
|
if set(request) != required or request["request_version"] != "1.0":
|
|
508
1045
|
raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
|
|
509
|
-
execution_request =
|
|
1046
|
+
execution_request = resolve_execution_test_ids(
|
|
1047
|
+
project_root,
|
|
1048
|
+
evidence_dir,
|
|
1049
|
+
validate_execution_request(
|
|
1050
|
+
request["execution_request"], plan=plan_for_execution(evidence_dir),
|
|
1051
|
+
),
|
|
1052
|
+
)
|
|
1053
|
+
request["execution_request"] = execution_request
|
|
1054
|
+
write_json_atomic(request_path, request)
|
|
510
1055
|
expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
|
|
511
1056
|
overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
|
|
512
1057
|
for relative in overlay_paths:
|
|
@@ -597,15 +1142,545 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
597
1142
|
"mutation_restore_sha256": mutation_restore_sha,
|
|
598
1143
|
"not_applicable": None,
|
|
599
1144
|
}
|
|
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)
|
|
1145
|
+
append_proof(evidence_dir, proof)
|
|
606
1146
|
return proof
|
|
607
1147
|
|
|
608
1148
|
|
|
1149
|
+
def run_capture_change(
|
|
1150
|
+
project_root: Path,
|
|
1151
|
+
evidence_dir: Path,
|
|
1152
|
+
baseline: str | None,
|
|
1153
|
+
output_name: str,
|
|
1154
|
+
) -> Path:
|
|
1155
|
+
resolved_baseline = git_baseline(project_root, baseline)
|
|
1156
|
+
patch, status_entries = canonical_change_patch(project_root, resolved_baseline)
|
|
1157
|
+
patch_path = confined(evidence_dir, "source-change.patch")
|
|
1158
|
+
patch_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1159
|
+
patch_path.write_bytes(patch)
|
|
1160
|
+
capture = {
|
|
1161
|
+
"capture_format": "prizmkit-canonical-change-v1",
|
|
1162
|
+
"baseline_commit": resolved_baseline,
|
|
1163
|
+
"patch_path": evidence_relative(evidence_dir, patch_path),
|
|
1164
|
+
"patch_sha256": file_sha256(patch_path),
|
|
1165
|
+
"changed_files": status_entries,
|
|
1166
|
+
"source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
|
|
1167
|
+
}
|
|
1168
|
+
output = confined(evidence_dir, output_name)
|
|
1169
|
+
write_json_atomic(output, capture)
|
|
1170
|
+
return output
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def run_init(project_root: Path, evidence_dir: Path, baseline: str | None, output_name: str) -> Path:
|
|
1174
|
+
if evidence_dir.exists() and any(evidence_dir.iterdir()):
|
|
1175
|
+
if (evidence_dir / "manifest.json").exists():
|
|
1176
|
+
raise RequestError("evidence package is finalized; initialize a new evidence directory")
|
|
1177
|
+
raise RequestError("evidence directory is not empty; initialize a new evidence directory")
|
|
1178
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
1179
|
+
for directory in ("requests", "receipts", "raw", "generated-tests", "contracts", "runner"):
|
|
1180
|
+
(evidence_dir / directory).mkdir(parents=True, exist_ok=True)
|
|
1181
|
+
write_json_atomic(evidence_dir / "executions.json", [])
|
|
1182
|
+
write_json_atomic(evidence_dir / "differential-proof.json", {"proofs": []})
|
|
1183
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1184
|
+
"lifecycle_format": "prizmkit-canonical-lifecycle-v1",
|
|
1185
|
+
"status": "initialized",
|
|
1186
|
+
"baseline_commit": git_baseline(project_root, baseline),
|
|
1187
|
+
"artifact_dir": None,
|
|
1188
|
+
"finalized": False,
|
|
1189
|
+
"immutable_identity": None,
|
|
1190
|
+
})
|
|
1191
|
+
output = confined(evidence_dir, output_name)
|
|
1192
|
+
write_json_atomic(output, {
|
|
1193
|
+
"command": "init",
|
|
1194
|
+
"evidence_dir": ".",
|
|
1195
|
+
"baseline_commit": git_baseline(project_root, baseline),
|
|
1196
|
+
"next": "capture-change",
|
|
1197
|
+
})
|
|
1198
|
+
return output
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def load_lifecycle(evidence_dir: Path, *, required: bool = False) -> dict[str, Any]:
|
|
1202
|
+
path = evidence_dir / "lifecycle.json"
|
|
1203
|
+
if not path.exists():
|
|
1204
|
+
if required:
|
|
1205
|
+
raise RequestError("evidence package is not initialized; run init first")
|
|
1206
|
+
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
1207
|
+
return ensure_object(load_json(path), "lifecycle")
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def require_mutable_lifecycle(evidence_dir: Path) -> dict[str, Any]:
|
|
1211
|
+
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
1212
|
+
if not lifecycle_path.exists():
|
|
1213
|
+
if (evidence_dir / "manifest.json").exists():
|
|
1214
|
+
raise RequestError(
|
|
1215
|
+
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
1216
|
+
)
|
|
1217
|
+
return {"lifecycle_format": "legacy-compatible", "status": "legacy"}
|
|
1218
|
+
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1219
|
+
if lifecycle.get("finalized") is True:
|
|
1220
|
+
raise RequestError(
|
|
1221
|
+
"evidence package is finalized and immutable; re-init a new evidence package"
|
|
1222
|
+
)
|
|
1223
|
+
return lifecycle
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def run_render_report(evidence_dir: Path, output_name: str) -> Path:
|
|
1227
|
+
manifest = ensure_object(load_json(evidence_dir / "manifest.json"), "manifest")
|
|
1228
|
+
verdict = ensure_object(load_json(evidence_dir / "verdict.json"), "verdict")
|
|
1229
|
+
validation_path = evidence_dir / "validation.json"
|
|
1230
|
+
validation = ensure_object(load_json(validation_path), "validation") if validation_path.exists() else None
|
|
1231
|
+
lifecycle_path = evidence_dir / "lifecycle.json"
|
|
1232
|
+
lifecycle = ensure_object(load_json(lifecycle_path), "lifecycle") if lifecycle_path.exists() else {}
|
|
1233
|
+
proofs_path = evidence_dir / "differential-proof.json"
|
|
1234
|
+
proofs = load_json(proofs_path).get("proofs", []) if proofs_path.exists() else []
|
|
1235
|
+
executions = load_json(evidence_dir / "executions.json")
|
|
1236
|
+
artifact_dir = lifecycle.get("artifact_dir")
|
|
1237
|
+
lines = [
|
|
1238
|
+
"# Test Evidence Report",
|
|
1239
|
+
"",
|
|
1240
|
+
"Derived from structured evidence",
|
|
1241
|
+
f"Evidence ID: {manifest.get('evidence_id')}",
|
|
1242
|
+
f"Verdict: {verdict.get('verdict')}",
|
|
1243
|
+
f"Validator result: {validation.get('result') if validation else 'pending'}",
|
|
1244
|
+
"sensitivity=project-controlled",
|
|
1245
|
+
"The project owns access control, retention, and upload policy.",
|
|
1246
|
+
"Mocked code-level evidence does not verify a real deployed environment.",
|
|
1247
|
+
"",
|
|
1248
|
+
"## Scope",
|
|
1249
|
+
f"- Artifact Dir: {artifact_dir or 'not provided'}",
|
|
1250
|
+
"",
|
|
1251
|
+
f"Execution receipts: {len(executions) if isinstance(executions, list) else 0}",
|
|
1252
|
+
f"Differential proofs: {len(proofs) if isinstance(proofs, list) else 0}",
|
|
1253
|
+
"The legacy test-report interface is not supported.",
|
|
1254
|
+
"",
|
|
1255
|
+
]
|
|
1256
|
+
output = confined(evidence_dir, output_name)
|
|
1257
|
+
rendered = "\n".join(lines)
|
|
1258
|
+
lifecycle_finalized = lifecycle.get("finalized") is True
|
|
1259
|
+
if lifecycle_finalized:
|
|
1260
|
+
if not output.is_file() or output.read_text(encoding="utf-8") != rendered:
|
|
1261
|
+
raise RequestError(
|
|
1262
|
+
"evidence package is finalized and immutable; re-init a new package before changing the report"
|
|
1263
|
+
)
|
|
1264
|
+
return output
|
|
1265
|
+
write_text_atomic(output, rendered)
|
|
1266
|
+
return output
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def aggregate_inventory(entries: list[dict[str, str]]) -> str:
|
|
1270
|
+
return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
|
|
1274
|
+
capture = load_change_capture(Path("."), evidence_dir)
|
|
1275
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1276
|
+
test_paths = {
|
|
1277
|
+
item.get("path")
|
|
1278
|
+
for item in inventory.get("categories", {}).get("tests", [])
|
|
1279
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1280
|
+
}
|
|
1281
|
+
statuses: dict[str, str] = {}
|
|
1282
|
+
for entry in capture.get("changed_files", []):
|
|
1283
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
1284
|
+
continue
|
|
1285
|
+
relative = entry["path"]
|
|
1286
|
+
if relative not in test_paths or entry.get("role") == "source":
|
|
1287
|
+
continue
|
|
1288
|
+
status = str(entry.get("status", "")).strip()
|
|
1289
|
+
if status == "??" or status.startswith("A"):
|
|
1290
|
+
statuses[relative] = "added"
|
|
1291
|
+
elif status.startswith("M") or status.endswith("M"):
|
|
1292
|
+
statuses[relative] = "modified"
|
|
1293
|
+
elif status.startswith("R") or status.startswith("C"):
|
|
1294
|
+
statuses[relative] = "modified"
|
|
1295
|
+
return statuses
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def stable_test_id(project_path: str, selector: str | None = None) -> str:
|
|
1299
|
+
identity = project_path if not selector else f"{project_path}::{selector}"
|
|
1300
|
+
return f"native-test-{canonical_sha256(identity)[:16]}"
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
1304
|
+
layers = request.get("layers")
|
|
1305
|
+
if layers is None and isinstance(existing_plan, dict):
|
|
1306
|
+
layers = existing_plan.get("layers")
|
|
1307
|
+
if not isinstance(layers, list) or len(layers) != len(LAYERS):
|
|
1308
|
+
raise RequestError("test preparation must assess all five layers exactly once")
|
|
1309
|
+
normalized: list[dict[str, Any]] = []
|
|
1310
|
+
seen: set[str] = set()
|
|
1311
|
+
for item in layers:
|
|
1312
|
+
if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
|
|
1313
|
+
raise RequestError("test preparation layer entries require name, required, and not_applicable")
|
|
1314
|
+
name = item.get("name")
|
|
1315
|
+
if name not in LAYERS or name in seen:
|
|
1316
|
+
raise RequestError(f"invalid or duplicate test preparation layer: {name}")
|
|
1317
|
+
seen.add(name)
|
|
1318
|
+
if not isinstance(item.get("required"), bool):
|
|
1319
|
+
raise RequestError(f"test preparation layer required must be boolean: {name}")
|
|
1320
|
+
if item["required"] is True and item.get("not_applicable") is not None:
|
|
1321
|
+
raise RequestError(f"required test preparation layer cannot be N/A: {name}")
|
|
1322
|
+
if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
|
|
1323
|
+
raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
|
|
1324
|
+
normalized.append(item)
|
|
1325
|
+
return normalized
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
|
|
1329
|
+
request = ensure_object(load_json(request_path), "test preparation request")
|
|
1330
|
+
allowed = {"request_version", "layers", "tests"}
|
|
1331
|
+
if set(request) != allowed or request.get("request_version") != "1.0":
|
|
1332
|
+
raise RequestError("test preparation request must contain request_version, layers, and tests only")
|
|
1333
|
+
requested = request.get("tests")
|
|
1334
|
+
if not isinstance(requested, list) or not requested:
|
|
1335
|
+
raise RequestError("test preparation request tests must be a non-empty array")
|
|
1336
|
+
existing_plan_path = evidence_dir / "test-plan.json"
|
|
1337
|
+
existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
|
|
1338
|
+
prepared_layers = prepare_test_layers(request, existing_plan)
|
|
1339
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1340
|
+
inventory_paths = {
|
|
1341
|
+
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1342
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1343
|
+
}
|
|
1344
|
+
changed = changed_test_statuses(evidence_dir)
|
|
1345
|
+
changed_native_tests = set(changed) & inventory_paths
|
|
1346
|
+
selected_paths: set[str] = set()
|
|
1347
|
+
normalized: list[dict[str, Any]] = []
|
|
1348
|
+
for item in requested:
|
|
1349
|
+
if not isinstance(item, dict):
|
|
1350
|
+
raise RequestError("test preparation entries must be objects")
|
|
1351
|
+
allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
|
|
1352
|
+
if set(item) - allowed_item:
|
|
1353
|
+
raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
|
|
1354
|
+
project_path = normalize_relative(item.get("project_path", ""))
|
|
1355
|
+
if project_path in selected_paths:
|
|
1356
|
+
raise RequestError(f"duplicate native test path: {project_path}")
|
|
1357
|
+
selected_paths.add(project_path)
|
|
1358
|
+
if project_path not in inventory_paths:
|
|
1359
|
+
raise RequestError(f"native test is not in the test inventory: {project_path}")
|
|
1360
|
+
source = confined(project_root, project_path, must_exist=True)
|
|
1361
|
+
behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
|
|
1362
|
+
layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
|
|
1363
|
+
unknown_layers = sorted(set(layers) - set(LAYERS))
|
|
1364
|
+
if unknown_layers:
|
|
1365
|
+
raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
|
|
1366
|
+
if len(layers) != len(set(layers)):
|
|
1367
|
+
raise RequestError(f"test {project_path} contains duplicate layers")
|
|
1368
|
+
selector = item.get("selector")
|
|
1369
|
+
if selector is not None and not isinstance(selector, str):
|
|
1370
|
+
raise RequestError(f"test {project_path} selector must be a string")
|
|
1371
|
+
status = changed.get(project_path, "existing")
|
|
1372
|
+
entry = {
|
|
1373
|
+
"id": stable_test_id(project_path, selector),
|
|
1374
|
+
"behavior_ids": behavior_ids,
|
|
1375
|
+
"change_status": status,
|
|
1376
|
+
"project_path": project_path,
|
|
1377
|
+
"snapshot_path": None,
|
|
1378
|
+
"inventory_path": project_path,
|
|
1379
|
+
"layers": layers,
|
|
1380
|
+
}
|
|
1381
|
+
if selector is not None:
|
|
1382
|
+
entry["selector"] = selector
|
|
1383
|
+
if isinstance(item.get("runner"), str):
|
|
1384
|
+
entry["runner"] = item["runner"]
|
|
1385
|
+
normalized.append(entry)
|
|
1386
|
+
unbound = sorted(changed_native_tests - selected_paths)
|
|
1387
|
+
if unbound:
|
|
1388
|
+
raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
|
|
1389
|
+
generated = evidence_dir / "generated-tests"
|
|
1390
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
1391
|
+
used_snapshots: set[str] = set()
|
|
1392
|
+
for entry in normalized:
|
|
1393
|
+
if entry["change_status"] in {"added", "modified"}:
|
|
1394
|
+
destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
|
|
1395
|
+
if destination_relative in used_snapshots:
|
|
1396
|
+
destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
|
|
1397
|
+
destination = confined(evidence_dir, destination_relative)
|
|
1398
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1399
|
+
shutil.copy2(project_root / entry["project_path"], destination)
|
|
1400
|
+
entry["snapshot_path"] = destination_relative
|
|
1401
|
+
used_snapshots.add(destination_relative)
|
|
1402
|
+
plan = {
|
|
1403
|
+
"target_hashes": {},
|
|
1404
|
+
"plan_inputs": inventory.get("plan_inputs", {}),
|
|
1405
|
+
"layers": prepared_layers,
|
|
1406
|
+
"tests": normalized,
|
|
1407
|
+
}
|
|
1408
|
+
existing_plan = evidence_dir / "test-plan.json"
|
|
1409
|
+
if existing_plan.is_file():
|
|
1410
|
+
prior = ensure_object(load_json(existing_plan), "existing test plan")
|
|
1411
|
+
plan["target_hashes"] = prior.get("target_hashes", {})
|
|
1412
|
+
plan["layers"] = prior.get("layers", prepared_layers)
|
|
1413
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1414
|
+
write_json_atomic(evidence_dir / output_name, {
|
|
1415
|
+
"format": "prizmkit-native-test-preparation-v1",
|
|
1416
|
+
"request_path": evidence_relative(evidence_dir, request_path),
|
|
1417
|
+
"request_sha256": file_sha256(request_path),
|
|
1418
|
+
"tests": normalized,
|
|
1419
|
+
"changed_test_statuses": changed,
|
|
1420
|
+
})
|
|
1421
|
+
return evidence_dir / output_name
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
|
|
1425
|
+
tests = plan.get("tests")
|
|
1426
|
+
if not isinstance(tests, list):
|
|
1427
|
+
return
|
|
1428
|
+
captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
|
|
1429
|
+
inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
|
|
1430
|
+
inventory_paths = {
|
|
1431
|
+
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1432
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1433
|
+
}
|
|
1434
|
+
selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
|
|
1435
|
+
unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
|
|
1436
|
+
if unbound:
|
|
1437
|
+
raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
|
|
1438
|
+
generated = evidence_dir / "generated-tests"
|
|
1439
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
1440
|
+
for test in tests:
|
|
1441
|
+
if not isinstance(test, dict):
|
|
1442
|
+
continue
|
|
1443
|
+
project_path = test.get("project_path")
|
|
1444
|
+
if not isinstance(project_path, str):
|
|
1445
|
+
continue
|
|
1446
|
+
status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
|
|
1447
|
+
if status not in {"existing", "added", "modified"}:
|
|
1448
|
+
raise RequestError(f"unknown test change_status: {test.get('id')}")
|
|
1449
|
+
test["change_status"] = status
|
|
1450
|
+
if status == "existing":
|
|
1451
|
+
test["snapshot_path"] = None
|
|
1452
|
+
continue
|
|
1453
|
+
source = confined(project_root, project_path, must_exist=True)
|
|
1454
|
+
destination_relative = test.get("snapshot_path")
|
|
1455
|
+
if not isinstance(destination_relative, str) or not destination_relative.startswith("generated-tests/"):
|
|
1456
|
+
destination_relative = f"generated-tests/{Path(project_path).name}"
|
|
1457
|
+
destination = confined(evidence_dir, destination_relative)
|
|
1458
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1459
|
+
shutil.copy2(source, destination)
|
|
1460
|
+
test["snapshot_path"] = destination_relative
|
|
1461
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def stage_output_owner(relative: str, change_class: str) -> str:
|
|
1465
|
+
if relative in {"change-classification.json"}:
|
|
1466
|
+
return "CHANGE_CLASSIFY"
|
|
1467
|
+
if relative in {"scope.json", "target-inventory.json", "environment.json", "source-change.patch", "change-capture.json"} or relative.startswith("requests/"):
|
|
1468
|
+
return "SCOPE_DISCOVER"
|
|
1469
|
+
if relative in {"behavior-risk-matrix.json"}:
|
|
1470
|
+
return "CONTRACT_MODEL"
|
|
1471
|
+
if relative in {"test-plan.json"} or relative == "test-preparation.json":
|
|
1472
|
+
return "TEST_PLAN"
|
|
1473
|
+
if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
|
|
1474
|
+
return "INFRA_READY"
|
|
1475
|
+
if relative.startswith("generated-tests/"):
|
|
1476
|
+
return "TEST_BUILD"
|
|
1477
|
+
if relative in {"executions.json", "differential-proof.json"} or relative.startswith("receipts/") or relative.startswith("raw/") or relative.startswith("runner/"):
|
|
1478
|
+
return "EXECUTE_PROVE"
|
|
1479
|
+
if relative in FINAL_RECORDS or relative == "lifecycle.json" or relative == "init.json" or relative == "finalize.json":
|
|
1480
|
+
return "EVIDENCE_PACKAGE"
|
|
1481
|
+
return "EVIDENCE_PACKAGE"
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
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]:
|
|
1485
|
+
file_paths = sorted(
|
|
1486
|
+
path.relative_to(evidence_dir).as_posix()
|
|
1487
|
+
for path in evidence_dir.rglob("*")
|
|
1488
|
+
if path.is_file() and path.name != "manifest.json"
|
|
1489
|
+
)
|
|
1490
|
+
files = [
|
|
1491
|
+
{"path": relative, "sha256": file_sha256(evidence_dir / relative), "produced_by": stage_output_owner(relative, change_class)}
|
|
1492
|
+
for relative in file_paths
|
|
1493
|
+
]
|
|
1494
|
+
outputs_by_stage = {stage: [] for stage in STAGES}
|
|
1495
|
+
for entry in files:
|
|
1496
|
+
outputs_by_stage.setdefault(entry["produced_by"], []).append(entry["path"])
|
|
1497
|
+
predecessor_files: list[dict[str, str]] = []
|
|
1498
|
+
stages = []
|
|
1499
|
+
for stage in STAGES:
|
|
1500
|
+
outputs = sorted(outputs_by_stage.get(stage, []))
|
|
1501
|
+
stages.append({
|
|
1502
|
+
"name": stage,
|
|
1503
|
+
"status": "complete",
|
|
1504
|
+
"input_sha256": canonical_sha256({
|
|
1505
|
+
"stage": stage,
|
|
1506
|
+
"target_hashes": target_hashes,
|
|
1507
|
+
"predecessor_outputs": sorted(predecessor_files, key=lambda item: item["path"]),
|
|
1508
|
+
}),
|
|
1509
|
+
"outputs": outputs,
|
|
1510
|
+
"not_applicable": None,
|
|
1511
|
+
})
|
|
1512
|
+
predecessor_files.extend(
|
|
1513
|
+
{"path": output, "sha256": next(item["sha256"] for item in files if item["path"] == output)}
|
|
1514
|
+
for output in outputs if output not in FINAL_RECORDS
|
|
1515
|
+
)
|
|
1516
|
+
return {
|
|
1517
|
+
"protocol_version": "1.0",
|
|
1518
|
+
"evidence_id": evidence_id,
|
|
1519
|
+
"evidence_id_inputs": identity,
|
|
1520
|
+
"sensitivity": "project-controlled",
|
|
1521
|
+
"environment_claim": "mocked-code-level-only",
|
|
1522
|
+
"change_class": change_class,
|
|
1523
|
+
"baseline_commit": baseline_commit,
|
|
1524
|
+
"working_diff_sha256": identity["working_diff_sha256"],
|
|
1525
|
+
"target_hashes": target_hashes,
|
|
1526
|
+
"stages": stages,
|
|
1527
|
+
"files": files,
|
|
1528
|
+
"final_verdict": final_verdict,
|
|
1529
|
+
"compatibility": "legacy-test-report-interface-not-supported",
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def write_handoff_pointer(evidence_dir: Path, artifact_dir: str | None, project_root: Path | None = None) -> None:
|
|
1534
|
+
if not artifact_dir:
|
|
1535
|
+
return
|
|
1536
|
+
artifact = Path(artifact_dir)
|
|
1537
|
+
if not artifact.is_absolute() and project_root is not None:
|
|
1538
|
+
artifact = project_root / artifact
|
|
1539
|
+
artifact = artifact.resolve()
|
|
1540
|
+
if not artifact.is_dir():
|
|
1541
|
+
raise RequestError(f"artifact_dir does not exist: {artifact_dir}")
|
|
1542
|
+
pointer = artifact / "test-report-path.txt"
|
|
1543
|
+
write_text_atomic(pointer, str((evidence_dir / "test-report.md").resolve()) + "\n")
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any], output_name: str) -> Path:
|
|
1547
|
+
allowed = {"request_version", "change_class", "baseline_commit", "scope_path", "environment_path", "artifact_dir", "blockers", "repair_scope"}
|
|
1548
|
+
if set(request) - allowed:
|
|
1549
|
+
raise RequestError(f"finalize request contains unsupported fields: {sorted(set(request) - allowed)}")
|
|
1550
|
+
if request.get("request_version", "1.0") != "1.0":
|
|
1551
|
+
raise RequestError("unsupported finalize request version")
|
|
1552
|
+
lifecycle = load_lifecycle(evidence_dir, required=True)
|
|
1553
|
+
if lifecycle.get("finalized") is True:
|
|
1554
|
+
raise RequestError("evidence identity is finalized; re-init a new package before changing scope or source")
|
|
1555
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
1556
|
+
if not capture_path.exists():
|
|
1557
|
+
run_capture_change(project_root, evidence_dir, request.get("baseline_commit"), "change-capture.json")
|
|
1558
|
+
capture = ensure_object(load_json(capture_path), "change capture")
|
|
1559
|
+
baseline = capture.get("baseline_commit")
|
|
1560
|
+
patch_path = evidence_dir / "source-change.patch"
|
|
1561
|
+
if not isinstance(baseline, str) or not patch_path.is_file():
|
|
1562
|
+
raise RequestError("canonical change capture is incomplete")
|
|
1563
|
+
change_class = request.get("change_class", "behavior")
|
|
1564
|
+
if change_class not in {"behavior", "lightweight"}:
|
|
1565
|
+
raise RequestError("change_class must be behavior or lightweight")
|
|
1566
|
+
artifact_dir = request.get("artifact_dir")
|
|
1567
|
+
if artifact_dir is not None and not isinstance(artifact_dir, str):
|
|
1568
|
+
raise RequestError("artifact_dir must be a project-relative directory")
|
|
1569
|
+
if artifact_dir is not None:
|
|
1570
|
+
artifact = confined(project_root, artifact_dir, must_exist=True, directory=True)
|
|
1571
|
+
lifecycle["artifact_dir"] = artifact.relative_to(project_root).as_posix()
|
|
1572
|
+
scope_relative = request.get("scope_path", "scope.json")
|
|
1573
|
+
scope = ensure_object(load_json(request_file(evidence_dir, scope_relative)), "scope")
|
|
1574
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1575
|
+
environment_path = evidence_dir / request.get("environment_path", "environment.json")
|
|
1576
|
+
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
1577
|
+
plan = load_json(evidence_dir / "test-plan.json") if (evidence_dir / "test-plan.json").exists() else None
|
|
1578
|
+
if isinstance(plan, dict):
|
|
1579
|
+
normalize_test_snapshots(project_root, evidence_dir, plan)
|
|
1580
|
+
categories = inventory.get("categories", {})
|
|
1581
|
+
target_hashes = {
|
|
1582
|
+
category: aggregate_inventory(categories.get(category, []))
|
|
1583
|
+
for category in CATEGORIES
|
|
1584
|
+
}
|
|
1585
|
+
target_hashes["environment"] = canonical_sha256(environment)
|
|
1586
|
+
target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
|
|
1587
|
+
if isinstance(plan, dict):
|
|
1588
|
+
plan["target_hashes"] = target_hashes
|
|
1589
|
+
plan["plan_inputs"] = inventory.get("plan_inputs", {})
|
|
1590
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1591
|
+
scope["target_hashes"] = target_hashes
|
|
1592
|
+
write_json_atomic(evidence_dir / scope_relative, scope)
|
|
1593
|
+
identity = {
|
|
1594
|
+
"baseline_commit": baseline,
|
|
1595
|
+
"working_diff_sha256": file_sha256(patch_path),
|
|
1596
|
+
"scope_sha256": canonical_sha256(scope),
|
|
1597
|
+
}
|
|
1598
|
+
evidence_id = canonical_sha256(identity)
|
|
1599
|
+
executions = load_json(evidence_dir / "executions.json") if (evidence_dir / "executions.json").exists() else []
|
|
1600
|
+
blockers = list(request.get("blockers", [])) if isinstance(request.get("blockers", []), list) else []
|
|
1601
|
+
required_records = {
|
|
1602
|
+
"change-classification.json", "scope.json", "target-inventory.json", "environment.json",
|
|
1603
|
+
"source-change.patch",
|
|
1604
|
+
}
|
|
1605
|
+
if change_class == "behavior":
|
|
1606
|
+
required_records.update({
|
|
1607
|
+
"behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
|
|
1608
|
+
"infrastructure-changes.json", "differential-proof.json",
|
|
1609
|
+
})
|
|
1610
|
+
else:
|
|
1611
|
+
required_records.add("lightweight-verification.json")
|
|
1612
|
+
missing_records = sorted(
|
|
1613
|
+
relative for relative in required_records
|
|
1614
|
+
if not (evidence_dir / relative).is_file()
|
|
1615
|
+
)
|
|
1616
|
+
if missing_records:
|
|
1617
|
+
blockers.append(
|
|
1618
|
+
"required evidence records are missing: " + ", ".join(missing_records)
|
|
1619
|
+
)
|
|
1620
|
+
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]
|
|
1621
|
+
if selected_failures and not request.get("repair_scope"):
|
|
1622
|
+
blockers.append("reliable failure exists but repair_scope is not evidence-backed")
|
|
1623
|
+
verdict = "TEST_FAIL" if selected_failures and request.get("repair_scope") else ("TEST_BLOCKED" if blockers else "TEST_PASS")
|
|
1624
|
+
verdict_record: dict[str, Any] = {
|
|
1625
|
+
"verdict": verdict,
|
|
1626
|
+
"testing_domain_only": True,
|
|
1627
|
+
"authorizes_commit_or_release": False,
|
|
1628
|
+
"real_environment_validated": False,
|
|
1629
|
+
"repairs_business_defects": False,
|
|
1630
|
+
"code_evidence_replayable": verdict == "TEST_PASS",
|
|
1631
|
+
"blockers": blockers if verdict == "TEST_BLOCKED" else [],
|
|
1632
|
+
"reproduced_failures": [item["execution_id"] for item in selected_failures] if verdict == "TEST_FAIL" else [],
|
|
1633
|
+
}
|
|
1634
|
+
if verdict == "TEST_FAIL" and request.get("repair_scope"):
|
|
1635
|
+
verdict_record["repair_scope"] = request["repair_scope"]
|
|
1636
|
+
write_json_atomic(evidence_dir / "verdict.json", verdict_record)
|
|
1637
|
+
write_json_atomic(evidence_dir / "validation.json", {})
|
|
1638
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1639
|
+
**lifecycle,
|
|
1640
|
+
"status": "finalizing",
|
|
1641
|
+
"finalized": False,
|
|
1642
|
+
"immutable_identity": identity,
|
|
1643
|
+
})
|
|
1644
|
+
manifest = build_manifest(
|
|
1645
|
+
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1646
|
+
target_hashes=target_hashes, change_class=change_class,
|
|
1647
|
+
baseline_commit=baseline, final_verdict=verdict,
|
|
1648
|
+
)
|
|
1649
|
+
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1650
|
+
run_render_report(evidence_dir, "test-report.md")
|
|
1651
|
+
write_json_atomic(evidence_dir / "lifecycle.json", {
|
|
1652
|
+
**lifecycle,
|
|
1653
|
+
"status": "finalized",
|
|
1654
|
+
"finalized": True,
|
|
1655
|
+
"immutable_identity": identity,
|
|
1656
|
+
})
|
|
1657
|
+
output = confined(evidence_dir, output_name)
|
|
1658
|
+
write_json_atomic(output, {
|
|
1659
|
+
"command": "finalize",
|
|
1660
|
+
"evidence_id": evidence_id,
|
|
1661
|
+
"manifest_path": "manifest.json",
|
|
1662
|
+
"verdict_path": "verdict.json",
|
|
1663
|
+
"report_path": "test-report.md",
|
|
1664
|
+
"validation_path": "validation.json",
|
|
1665
|
+
"next": "validate_test_evidence.py --attest",
|
|
1666
|
+
})
|
|
1667
|
+
manifest = build_manifest(
|
|
1668
|
+
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1669
|
+
target_hashes=target_hashes, change_class=change_class,
|
|
1670
|
+
baseline_commit=baseline, final_verdict=verdict,
|
|
1671
|
+
)
|
|
1672
|
+
write_json_atomic(evidence_dir / "manifest.json", manifest)
|
|
1673
|
+
destination = evidence_dir.parent / evidence_id
|
|
1674
|
+
if destination != evidence_dir:
|
|
1675
|
+
if destination.exists():
|
|
1676
|
+
raise RequestError(
|
|
1677
|
+
f"evidence identity already exists at {destination}; re-init a new package"
|
|
1678
|
+
)
|
|
1679
|
+
os.replace(evidence_dir, destination)
|
|
1680
|
+
write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
|
|
1681
|
+
return destination / output_name
|
|
1682
|
+
|
|
1683
|
+
|
|
609
1684
|
def run_resume(
|
|
610
1685
|
project_root: Path,
|
|
611
1686
|
evidence_dir: Path,
|
|
@@ -615,6 +1690,11 @@ def run_resume(
|
|
|
615
1690
|
) -> Path:
|
|
616
1691
|
manifest = ensure_object(load_json(manifest_path), "manifest")
|
|
617
1692
|
inventory = ensure_object(load_json(inventory_path), "inventory")
|
|
1693
|
+
lifecycle = load_lifecycle(evidence_dir)
|
|
1694
|
+
if lifecycle.get("finalized") is True:
|
|
1695
|
+
raise RequestError(
|
|
1696
|
+
"evidence package is finalized and immutable; re-init a new evidence package before resume"
|
|
1697
|
+
)
|
|
618
1698
|
categories = ensure_object(inventory.get("categories"), "inventory categories")
|
|
619
1699
|
environment_path = evidence_dir / "environment.json"
|
|
620
1700
|
environment = load_json(environment_path) if environment_path.exists() else {}
|
|
@@ -683,15 +1763,29 @@ def main() -> int:
|
|
|
683
1763
|
parser.add_argument("--project-root", required=True)
|
|
684
1764
|
parser.add_argument("--evidence-dir", required=True)
|
|
685
1765
|
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
1766
|
+
init = subparsers.add_parser("init")
|
|
1767
|
+
init.add_argument("--baseline")
|
|
1768
|
+
init.add_argument("--output", default="init.json")
|
|
1769
|
+
capture = subparsers.add_parser("capture-change")
|
|
1770
|
+
capture.add_argument("--baseline")
|
|
1771
|
+
capture.add_argument("--output", default="change-capture.json")
|
|
686
1772
|
inventory = subparsers.add_parser("inventory")
|
|
687
1773
|
inventory.add_argument("--request", required=True)
|
|
688
1774
|
inventory.add_argument("--output", default="target-inventory.json")
|
|
1775
|
+
prepare = subparsers.add_parser("prepare-tests")
|
|
1776
|
+
prepare.add_argument("--request", required=True)
|
|
1777
|
+
prepare.add_argument("--output", default="test-preparation.json")
|
|
689
1778
|
execute = subparsers.add_parser("execute")
|
|
690
1779
|
group = execute.add_mutually_exclusive_group(required=True)
|
|
691
1780
|
group.add_argument("--request")
|
|
692
1781
|
group.add_argument("--replay-receipt")
|
|
693
1782
|
differential = subparsers.add_parser("differential")
|
|
694
1783
|
differential.add_argument("--request", required=True)
|
|
1784
|
+
finalize = subparsers.add_parser("finalize")
|
|
1785
|
+
finalize.add_argument("--request", required=True, help="semantic finalization request")
|
|
1786
|
+
finalize.add_argument("--output", default="finalize.json")
|
|
1787
|
+
report = subparsers.add_parser("render-report")
|
|
1788
|
+
report.add_argument("--output", default="test-report.md")
|
|
695
1789
|
resume = subparsers.add_parser("resume")
|
|
696
1790
|
resume.add_argument("--manifest", required=True)
|
|
697
1791
|
resume.add_argument("--inventory", required=True)
|
|
@@ -703,11 +1797,23 @@ def main() -> int:
|
|
|
703
1797
|
if not project_root.is_dir():
|
|
704
1798
|
raise RequestError(f"project root does not exist: {project_root}")
|
|
705
1799
|
evidence_dir.relative_to(project_root)
|
|
706
|
-
|
|
707
|
-
|
|
1800
|
+
if args.subcommand == "init":
|
|
1801
|
+
output = run_init(project_root, evidence_dir, args.baseline, args.output)
|
|
1802
|
+
print(f"Evidence initialized: {output}")
|
|
1803
|
+
elif args.subcommand == "capture-change":
|
|
1804
|
+
require_mutable_lifecycle(evidence_dir)
|
|
1805
|
+
output = run_capture_change(project_root, evidence_dir, args.baseline, args.output)
|
|
1806
|
+
print(f"Change captured: {output}")
|
|
1807
|
+
elif args.subcommand == "inventory":
|
|
1808
|
+
require_mutable_lifecycle(evidence_dir)
|
|
708
1809
|
output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
709
1810
|
print(f"Inventory written: {output}")
|
|
1811
|
+
elif args.subcommand == "prepare-tests":
|
|
1812
|
+
require_mutable_lifecycle(evidence_dir)
|
|
1813
|
+
output = run_prepare_tests(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
1814
|
+
print(f"Native tests prepared: {output}")
|
|
710
1815
|
elif args.subcommand == "execute":
|
|
1816
|
+
require_mutable_lifecycle(evidence_dir)
|
|
711
1817
|
if args.request:
|
|
712
1818
|
receipt = execute_request(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
713
1819
|
else:
|
|
@@ -721,9 +1827,20 @@ def main() -> int:
|
|
|
721
1827
|
receipt = execute_request(project_root, evidence_dir, original_request, replay_of=prior.get("execution_id"))
|
|
722
1828
|
print(json.dumps(receipt, ensure_ascii=False))
|
|
723
1829
|
elif args.subcommand == "differential":
|
|
1830
|
+
require_mutable_lifecycle(evidence_dir)
|
|
724
1831
|
proof = run_differential(project_root, evidence_dir, request_file(evidence_dir, args.request))
|
|
725
1832
|
print(json.dumps(proof, ensure_ascii=False))
|
|
726
|
-
|
|
1833
|
+
elif args.subcommand == "render-report":
|
|
1834
|
+
load_lifecycle(evidence_dir)
|
|
1835
|
+
output = run_render_report(evidence_dir, args.output)
|
|
1836
|
+
print(f"Report rendered: {output}")
|
|
1837
|
+
elif args.subcommand == "finalize":
|
|
1838
|
+
load_lifecycle(evidence_dir, required=True)
|
|
1839
|
+
request = ensure_object(load_json(request_file(evidence_dir, args.request)), "finalize request")
|
|
1840
|
+
output = run_finalize(project_root, evidence_dir, request, args.output)
|
|
1841
|
+
print(f"Evidence finalized: {output}")
|
|
1842
|
+
elif args.subcommand == "resume":
|
|
1843
|
+
load_lifecycle(evidence_dir)
|
|
727
1844
|
output = run_resume(
|
|
728
1845
|
project_root, evidence_dir, request_file(evidence_dir, args.manifest),
|
|
729
1846
|
request_file(evidence_dir, args.inventory), args.output,
|