prizmkit 1.1.134 → 1.1.136
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/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit-test/SKILL.md +26 -15
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +87 -10
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +2 -0
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +4 -5
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +3 -3
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +2 -2
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +456 -61
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +142 -47
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ Usage:
|
|
|
5
5
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR init [--baseline COMMIT]
|
|
6
6
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR capture-change
|
|
7
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
|
|
8
9
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --request REQUEST
|
|
9
10
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR execute --replay-receipt RECEIPT
|
|
10
11
|
python3 build_test_evidence.py --project-root ROOT --evidence-dir DIR differential --request REQUEST
|
|
@@ -21,6 +22,7 @@ from __future__ import annotations
|
|
|
21
22
|
|
|
22
23
|
import argparse
|
|
23
24
|
import datetime as dt
|
|
25
|
+
import fnmatch
|
|
24
26
|
import hashlib
|
|
25
27
|
import json
|
|
26
28
|
import os
|
|
@@ -44,7 +46,7 @@ LAYERS = (
|
|
|
44
46
|
"affected-module-regression", "regression-ring",
|
|
45
47
|
)
|
|
46
48
|
BLOCKED_EXTERNAL_CLASSES = {"production", "unknown"}
|
|
47
|
-
|
|
49
|
+
INVENTORY_RESERVED_ROOTS = (
|
|
48
50
|
".prizmkit/state",
|
|
49
51
|
".prizmkit/test/evidence",
|
|
50
52
|
".prizmkit/" + "dev-" + "pipeline",
|
|
@@ -53,8 +55,8 @@ SNAPSHOT_VOLATILE_ROOTS = (
|
|
|
53
55
|
".codebuddy/worktrees",
|
|
54
56
|
".codex/worktrees",
|
|
55
57
|
)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
|
|
59
|
+
_IGNORED_DIR_NAMES = {".git", "__pycache__", ".pytest_cache", ".mypy_cache"}
|
|
58
60
|
FINAL_RECORDS = {"manifest.json", "validation.json", "verdict.json", "test-report.md"}
|
|
59
61
|
|
|
60
62
|
|
|
@@ -150,7 +152,7 @@ def normalize_relative(value: str) -> str:
|
|
|
150
152
|
|
|
151
153
|
def is_reserved_inventory_path(relative: str) -> bool:
|
|
152
154
|
path = Path(relative)
|
|
153
|
-
if any(part in
|
|
155
|
+
if any(part in _IGNORED_DIR_NAMES for part in path.parts):
|
|
154
156
|
return True
|
|
155
157
|
return any(path == Path(prefix) or Path(prefix) in path.parents for prefix in INVENTORY_RESERVED_ROOTS)
|
|
156
158
|
|
|
@@ -326,6 +328,49 @@ def validate_external_targets(value: Any) -> list[dict[str, Any]]:
|
|
|
326
328
|
return value
|
|
327
329
|
|
|
328
330
|
|
|
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
|
+
):
|
|
359
|
+
raise RequestError(
|
|
360
|
+
"canonical change capture is stale; rerun capture-change and inventory after repository changes"
|
|
361
|
+
)
|
|
362
|
+
return capture
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def captured_changed_paths(capture: dict[str, Any]) -> list[str]:
|
|
366
|
+
paths = {
|
|
367
|
+
item["path"]
|
|
368
|
+
for item in capture.get("changed_files", [])
|
|
369
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
370
|
+
}
|
|
371
|
+
return sorted(paths)
|
|
372
|
+
|
|
373
|
+
|
|
329
374
|
def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
|
|
330
375
|
path = evidence_dir / "test-plan.json"
|
|
331
376
|
if not path.exists():
|
|
@@ -333,13 +378,22 @@ def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
|
|
|
333
378
|
return ensure_object(load_json(path), "test plan")
|
|
334
379
|
|
|
335
380
|
|
|
381
|
+
def test_plan_entries(plan: dict[str, Any] | None) -> list[dict[str, Any]]:
|
|
382
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("tests"), list):
|
|
383
|
+
raise RequestError("builder-generated test plan is required before execution")
|
|
384
|
+
entries = [item for item in plan["tests"] if isinstance(item, dict)]
|
|
385
|
+
if len(entries) != len(plan["tests"]):
|
|
386
|
+
raise RequestError("test plan contains a non-object test entry")
|
|
387
|
+
return entries
|
|
388
|
+
|
|
389
|
+
|
|
336
390
|
def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
337
391
|
request = ensure_object(request, "execution request")
|
|
338
392
|
required = {
|
|
339
393
|
"request_version", "purpose", "command", "cwd", "environment",
|
|
340
|
-
"tool_version_commands", "layer", "
|
|
394
|
+
"tool_version_commands", "layer", "external_targets",
|
|
341
395
|
}
|
|
342
|
-
optional = {"timeout_seconds", "attempt_policy", "concurrency"}
|
|
396
|
+
optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
|
|
343
397
|
if not required <= set(request) or set(request) - required - optional:
|
|
344
398
|
raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
|
|
345
399
|
if request["request_version"] != "1.0":
|
|
@@ -357,20 +411,32 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
|
|
|
357
411
|
if not isinstance(name, str):
|
|
358
412
|
raise RequestError("tool probe names must be strings")
|
|
359
413
|
ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
414
|
+
has_ids = "test_ids" in request
|
|
415
|
+
has_paths = "test_paths" in request
|
|
416
|
+
auto_bind = request.get("auto_bind") is True
|
|
417
|
+
if not has_ids and not has_paths and not auto_bind:
|
|
418
|
+
raise RequestError("execution request must provide test_ids, test_paths, or auto_bind=true")
|
|
419
|
+
if has_ids:
|
|
420
|
+
test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
|
|
421
|
+
if len(set(test_ids)) != len(test_ids):
|
|
422
|
+
raise RequestError("execution request test_ids contains duplicates")
|
|
423
|
+
if has_paths:
|
|
424
|
+
test_paths = ensure_string_list(request["test_paths"], "test_paths", allow_empty=False)
|
|
425
|
+
if len(set(test_paths)) != len(test_paths):
|
|
426
|
+
raise RequestError("execution request test_paths contains duplicates")
|
|
427
|
+
if "auto_bind" in request and not isinstance(request["auto_bind"], bool):
|
|
428
|
+
raise RequestError("execution request auto_bind must be boolean")
|
|
363
429
|
if request["layer"] not in LAYERS and request["layer"] != "lightweight":
|
|
364
430
|
raise RequestError(f"unknown execution layer: {request['layer']}")
|
|
365
|
-
if plan is not None:
|
|
366
|
-
tests =
|
|
367
|
-
known = {item.get("id") for item in tests
|
|
368
|
-
unknown = sorted(set(test_ids) - known)
|
|
431
|
+
if plan is not None and has_ids:
|
|
432
|
+
tests = test_plan_entries(plan)
|
|
433
|
+
known = {item.get("id") for item in tests}
|
|
434
|
+
unknown = sorted(set(request["test_ids"]) - known)
|
|
369
435
|
if unknown:
|
|
370
436
|
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
371
|
-
test_map = {item.get("id"): item for item in tests
|
|
437
|
+
test_map = {item.get("id"): item for item in tests}
|
|
372
438
|
invalid_layer = sorted(
|
|
373
|
-
test_id for test_id in test_ids
|
|
439
|
+
test_id for test_id in request["test_ids"]
|
|
374
440
|
if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
|
|
375
441
|
)
|
|
376
442
|
if invalid_layer:
|
|
@@ -381,29 +447,118 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
|
|
|
381
447
|
return request
|
|
382
448
|
|
|
383
449
|
|
|
384
|
-
def
|
|
450
|
+
def normalize_command_scope_path(project_root: Path, cwd: Path, value: str) -> str | None:
|
|
451
|
+
if not isinstance(value, str) or not value or value.startswith("-"):
|
|
452
|
+
return None
|
|
453
|
+
candidate = Path(value)
|
|
454
|
+
resolved = candidate.resolve() if candidate.is_absolute() else (cwd / candidate).resolve()
|
|
385
455
|
try:
|
|
386
|
-
|
|
456
|
+
return resolved.relative_to(project_root.resolve()).as_posix()
|
|
387
457
|
except ValueError:
|
|
388
|
-
return
|
|
389
|
-
if any(part in SNAPSHOT_VOLATILE_NAMES for part in relative.parts):
|
|
390
|
-
return True
|
|
391
|
-
if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in SNAPSHOT_VOLATILE_ROOTS):
|
|
392
|
-
return True
|
|
393
|
-
resolved = candidate.resolve()
|
|
394
|
-
return any(resolved == item.resolve() or item.resolve() in resolved.parents for item in (excluded or []))
|
|
458
|
+
return None
|
|
395
459
|
|
|
396
460
|
|
|
397
|
-
def
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
461
|
+
def command_scope_test_ids(project_root: Path, request: dict[str, Any], tests: list[dict[str, Any]]) -> list[str]:
|
|
462
|
+
cwd = confined(project_root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
463
|
+
command = request["command"]
|
|
464
|
+
executable = Path(command[0]).name.lower()
|
|
465
|
+
subcommands = {item for item in command[1:] if not item.startswith("-")}
|
|
466
|
+
path_tokens: list[str] = []
|
|
467
|
+
after_separator = False
|
|
468
|
+
skip_option_value = False
|
|
469
|
+
options_with_values = {
|
|
470
|
+
"-C", "--config", "--config-file", "--project", "--package", "-p",
|
|
471
|
+
"--root", "--rootdir", "--cwd", "--testPathPattern", "--testNamePattern",
|
|
472
|
+
}
|
|
473
|
+
for token in command[1:]:
|
|
474
|
+
if skip_option_value:
|
|
475
|
+
skip_option_value = False
|
|
401
476
|
continue
|
|
402
|
-
|
|
403
|
-
|
|
477
|
+
if token == "--":
|
|
478
|
+
after_separator = True
|
|
479
|
+
continue
|
|
480
|
+
if token in options_with_values:
|
|
481
|
+
skip_option_value = True
|
|
482
|
+
continue
|
|
483
|
+
if token.startswith("-"):
|
|
484
|
+
continue
|
|
485
|
+
if token in {"test", "run", "watch", "coverage", "all", "serial", "parallel"} and not after_separator:
|
|
486
|
+
continue
|
|
487
|
+
if after_separator or token.startswith((".", "/")) or "/" in token or any(mark in token for mark in "*?[]"):
|
|
488
|
+
path_tokens.append(token)
|
|
489
|
+
|
|
490
|
+
package_wide = (
|
|
491
|
+
executable in {"pytest", "vitest", "jest", "mocha", "busted"}
|
|
492
|
+
or (executable in {"python", "python3", "py.test"} and "pytest" in command)
|
|
493
|
+
or (executable in {"npm", "npm.cmd", "pnpm", "pnpm.cmd", "yarn", "yarn.cmd"} and "test" in subcommands)
|
|
494
|
+
or (executable in {"go", "cargo", "mvn", "gradle", "gradlew"} and "test" in subcommands)
|
|
495
|
+
)
|
|
496
|
+
if not path_tokens:
|
|
497
|
+
if package_wide:
|
|
498
|
+
return sorted({item["id"] for item in tests})
|
|
499
|
+
raise RequestError(
|
|
500
|
+
"auto_bind cannot infer native test scope from command; provide test_paths or use a recognized package-wide test command"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
matched_paths: set[str] = set()
|
|
504
|
+
for token in path_tokens:
|
|
505
|
+
recursive_scope = token == "..." or token.endswith("/...")
|
|
506
|
+
scope_token = token[:-3].rstrip("/") if recursive_scope else token
|
|
507
|
+
normalized = normalize_command_scope_path(project_root, cwd, scope_token or ".")
|
|
508
|
+
if normalized is None:
|
|
509
|
+
continue
|
|
510
|
+
has_glob = any(mark in normalized for mark in "*?[]")
|
|
511
|
+
for test in tests:
|
|
512
|
+
test_path = test["project_path"]
|
|
513
|
+
within_scope = normalized in {"", "."} or test_path == normalized or path_is_below_or_equal(test_path, normalized)
|
|
514
|
+
if (has_glob and fnmatch.fnmatch(test_path, normalized)) or (recursive_scope and within_scope) or (
|
|
515
|
+
not has_glob and not recursive_scope and within_scope
|
|
516
|
+
):
|
|
517
|
+
matched_paths.add(test_path)
|
|
518
|
+
if not matched_paths:
|
|
519
|
+
raise RequestError(
|
|
520
|
+
"auto_bind command scope does not cover any prepared native test; provide test_paths explicitly"
|
|
521
|
+
)
|
|
522
|
+
return [item["id"] for item in tests if item["project_path"] in matched_paths]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def resolve_execution_test_ids(project_root: Path, evidence_dir: Path, request: dict[str, Any]) -> dict[str, Any]:
|
|
526
|
+
plan = plan_for_execution(evidence_dir)
|
|
527
|
+
if plan is None:
|
|
528
|
+
if "test_ids" not in request:
|
|
529
|
+
raise RequestError("builder-generated test plan is required before path-based or auto-bound execution")
|
|
530
|
+
return request
|
|
531
|
+
if (evidence_dir / "lifecycle.json").is_file() and not (evidence_dir / "test-preparation.json").is_file():
|
|
532
|
+
raise RequestError("canonical lifecycle requires prepare-tests before execute or differential")
|
|
533
|
+
tests = test_plan_entries(plan)
|
|
534
|
+
by_id = {item["id"]: item for item in tests}
|
|
535
|
+
by_path = {item["project_path"]: item for item in tests}
|
|
536
|
+
if "test_ids" in request:
|
|
537
|
+
resolved = list(request["test_ids"])
|
|
538
|
+
elif "test_paths" in request:
|
|
539
|
+
unknown_paths = sorted(set(request["test_paths"]) - set(by_path))
|
|
540
|
+
if unknown_paths:
|
|
541
|
+
raise RequestError(f"execution request references unknown test paths: {', '.join(unknown_paths)}")
|
|
542
|
+
resolved = [by_path[path]["id"] for path in request["test_paths"]]
|
|
543
|
+
else:
|
|
544
|
+
resolved = command_scope_test_ids(project_root, request, tests)
|
|
545
|
+
if not resolved:
|
|
546
|
+
raise RequestError("execution request resolved no prepared tests")
|
|
547
|
+
unknown = sorted(set(resolved) - set(by_id))
|
|
548
|
+
if unknown:
|
|
549
|
+
raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
|
|
550
|
+
invalid_layer = sorted(
|
|
551
|
+
test_id for test_id in resolved
|
|
552
|
+
if request["layer"] not in (by_id[test_id].get("layers") or [])
|
|
553
|
+
)
|
|
554
|
+
if invalid_layer:
|
|
555
|
+
raise RequestError(f"execution layer is not planned for test IDs: {', '.join(invalid_layer)}")
|
|
556
|
+
request["test_ids"] = resolved
|
|
557
|
+
return request
|
|
404
558
|
|
|
405
559
|
|
|
406
560
|
def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
|
|
561
|
+
project_resolved = project_root.resolve()
|
|
407
562
|
paths: set[Path] = set()
|
|
408
563
|
for pattern in patterns:
|
|
409
564
|
if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
|
|
@@ -413,7 +568,7 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
|
|
|
413
568
|
paths.add(candidate.resolve())
|
|
414
569
|
entries = []
|
|
415
570
|
for candidate in sorted(paths):
|
|
416
|
-
relative = candidate.relative_to(
|
|
571
|
+
relative = candidate.relative_to(project_resolved).as_posix()
|
|
417
572
|
if relative not in exclusions and not is_reserved_inventory_path(relative):
|
|
418
573
|
entries.append({"path": relative, "sha256": file_sha256(candidate)})
|
|
419
574
|
return entries
|
|
@@ -473,6 +628,7 @@ def enumerate_module_root_files(
|
|
|
473
628
|
evidence_dir: Path,
|
|
474
629
|
module_roots: list[str],
|
|
475
630
|
) -> dict[str, list[str]]:
|
|
631
|
+
project_resolved = project_root.resolve()
|
|
476
632
|
evidence_resolved = evidence_dir.resolve()
|
|
477
633
|
result: dict[str, list[str]] = {}
|
|
478
634
|
for relative in module_roots:
|
|
@@ -485,7 +641,7 @@ def enumerate_module_root_files(
|
|
|
485
641
|
resolved = candidate.resolve()
|
|
486
642
|
if resolved == evidence_resolved or evidence_resolved in resolved.parents:
|
|
487
643
|
continue
|
|
488
|
-
files.append(resolved.relative_to(
|
|
644
|
+
files.append(resolved.relative_to(project_resolved).as_posix())
|
|
489
645
|
result[relative] = sorted(files)
|
|
490
646
|
return result
|
|
491
647
|
|
|
@@ -548,6 +704,39 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
|
|
|
548
704
|
for entries in output_categories.values()
|
|
549
705
|
for entry in entries
|
|
550
706
|
}
|
|
707
|
+
capture_path = evidence_dir / "change-capture.json"
|
|
708
|
+
if capture_path.is_file():
|
|
709
|
+
capture = load_change_capture(project_root, evidence_dir, require_fresh=True)
|
|
710
|
+
captured_paths = set(captured_changed_paths(capture))
|
|
711
|
+
if captured_paths != set(changed_files):
|
|
712
|
+
missing = sorted(captured_paths - set(changed_files))
|
|
713
|
+
extra = sorted(set(changed_files) - captured_paths)
|
|
714
|
+
details = []
|
|
715
|
+
if missing:
|
|
716
|
+
details.append("missing from inventory request: " + ", ".join(missing))
|
|
717
|
+
if extra:
|
|
718
|
+
details.append("not present in canonical capture: " + ", ".join(extra))
|
|
719
|
+
raise RequestError("inventory changed_files must exactly match canonical capture (" + "; ".join(details) + ")")
|
|
720
|
+
captured_deleted = {
|
|
721
|
+
item["path"]
|
|
722
|
+
for item in capture.get("changed_files", [])
|
|
723
|
+
if isinstance(item, dict)
|
|
724
|
+
and isinstance(item.get("path"), str)
|
|
725
|
+
and (str(item.get("status", "")).strip() == "D" or item.get("role") == "source")
|
|
726
|
+
}
|
|
727
|
+
unaccounted = sorted(
|
|
728
|
+
captured_paths
|
|
729
|
+
- inventoried_paths
|
|
730
|
+
- exclusion_paths
|
|
731
|
+
- captured_deleted
|
|
732
|
+
)
|
|
733
|
+
if unaccounted:
|
|
734
|
+
raise RequestError(
|
|
735
|
+
"canonical changed files are neither inventoried nor explicitly excluded: "
|
|
736
|
+
+ ", ".join(unaccounted)
|
|
737
|
+
)
|
|
738
|
+
elif (evidence_dir / "lifecycle.json").is_file():
|
|
739
|
+
raise RequestError("canonical lifecycle requires capture-change before inventory")
|
|
551
740
|
missing_from_inventory = sorted({
|
|
552
741
|
relative
|
|
553
742
|
for files in module_root_files.values()
|
|
@@ -663,7 +852,12 @@ def execute_request(
|
|
|
663
852
|
isolation: dict[str, Any] | None = None,
|
|
664
853
|
selected_execution: bool = True,
|
|
665
854
|
) -> dict[str, Any]:
|
|
666
|
-
|
|
855
|
+
request_value = load_json(request_path)
|
|
856
|
+
original_test_ids = list(request_value["test_ids"]) if isinstance(request_value, dict) and isinstance(request_value.get("test_ids"), list) else None
|
|
857
|
+
request = validate_execution_request(request_value, plan=plan_for_execution(evidence_dir))
|
|
858
|
+
request = resolve_execution_test_ids(project_root, evidence_dir, request)
|
|
859
|
+
if original_test_ids != request["test_ids"]:
|
|
860
|
+
write_json_atomic(request_path, request)
|
|
667
861
|
root = execution_root or project_root
|
|
668
862
|
cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
|
|
669
863
|
complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
|
|
@@ -747,14 +941,28 @@ def copy_project_tree(project_root: Path, destination: Path, evidence_dir: Path)
|
|
|
747
941
|
ignored: set[str] = set()
|
|
748
942
|
for name in names:
|
|
749
943
|
relative = directory_relative / name
|
|
750
|
-
|
|
751
|
-
|
|
944
|
+
if any(part in _IGNORED_DIR_NAMES for part in relative.parts):
|
|
945
|
+
ignored.add(name)
|
|
946
|
+
continue
|
|
947
|
+
if any(relative == Path(prefix) or Path(prefix) in relative.parents for prefix in INVENTORY_RESERVED_ROOTS):
|
|
948
|
+
ignored.add(name)
|
|
949
|
+
elif (project_root / relative).resolve() == evidence_dir.resolve() or evidence_dir.resolve() in (project_root / relative).resolve().parents:
|
|
752
950
|
ignored.add(name)
|
|
753
951
|
return ignored
|
|
754
952
|
|
|
755
953
|
shutil.copytree(project_root, destination, ignore=ignore)
|
|
756
954
|
|
|
757
955
|
|
|
956
|
+
def _iso_tree_sha256(root: Path) -> str:
|
|
957
|
+
"""Hash an isolated temp tree (not the live project)."""
|
|
958
|
+
entries: list[dict[str, str]] = []
|
|
959
|
+
for candidate in sorted(root.rglob("*")):
|
|
960
|
+
if not candidate.is_file():
|
|
961
|
+
continue
|
|
962
|
+
entries.append({"path": candidate.relative_to(root).as_posix(), "sha256": file_sha256(candidate)})
|
|
963
|
+
return canonical_sha256(entries)
|
|
964
|
+
|
|
965
|
+
|
|
758
966
|
def git_archive_baseline(project_root: Path, baseline_commit: str, destination: Path) -> None:
|
|
759
967
|
result = subprocess.run(
|
|
760
968
|
["git", "-C", str(project_root), "archive", "--format=tar", baseline_commit],
|
|
@@ -807,7 +1015,7 @@ def append_proof(evidence_dir: Path, proof: dict[str, Any]) -> None:
|
|
|
807
1015
|
|
|
808
1016
|
def run_differential(project_root: Path, evidence_dir: Path, request_path: Path) -> dict[str, Any]:
|
|
809
1017
|
request = ensure_object(load_json(request_path), "differential request")
|
|
810
|
-
required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "
|
|
1018
|
+
required = {"request_version", "behavior_id", "method", "execution_request", "baseline_commit", "mutation_patch_path", "test_overlay_paths", "expected_failure_signals"}
|
|
811
1019
|
na_required = {"request_version", "behavior_id", "method", "not_applicable"}
|
|
812
1020
|
if set(request) == na_required:
|
|
813
1021
|
if request["request_version"] != "1.0" or request["method"] != "not-applicable":
|
|
@@ -817,25 +1025,28 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
817
1025
|
"behavior_id": request["behavior_id"], "classification": "NOT_APPLICABLE",
|
|
818
1026
|
"method": None, "differential_request_path": evidence_relative(evidence_dir, request_path),
|
|
819
1027
|
"differential_request_sha256": file_sha256(request_path), "baseline_commit": None,
|
|
820
|
-
"
|
|
1028
|
+
"baseline_execution_id": None, "mutation_execution_id": None,
|
|
821
1029
|
"current_execution_id": None, "failure_reason_matched": False, "cleanup_succeeded": True,
|
|
822
|
-
"
|
|
823
|
-
"
|
|
1030
|
+
"isolation_tree_sha256": None, "mutation_apply_sha256": None, "mutation_restore_sha256": None,
|
|
1031
|
+
"not_applicable": not_applicable,
|
|
824
1032
|
}
|
|
825
1033
|
append_proof(evidence_dir, proof)
|
|
826
1034
|
return proof
|
|
827
1035
|
if set(request) != required or request["request_version"] != "1.0":
|
|
828
1036
|
raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
|
|
829
|
-
execution_request =
|
|
830
|
-
|
|
1037
|
+
execution_request = resolve_execution_test_ids(
|
|
1038
|
+
project_root,
|
|
1039
|
+
evidence_dir,
|
|
1040
|
+
validate_execution_request(
|
|
1041
|
+
request["execution_request"], plan=plan_for_execution(evidence_dir),
|
|
1042
|
+
),
|
|
831
1043
|
)
|
|
1044
|
+
request["execution_request"] = execution_request
|
|
1045
|
+
write_json_atomic(request_path, request)
|
|
832
1046
|
expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
|
|
833
1047
|
overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
|
|
834
1048
|
for relative in overlay_paths:
|
|
835
1049
|
confined(project_root, relative, must_exist=True)
|
|
836
|
-
project_before = tree_sha256(project_root, [evidence_dir])
|
|
837
|
-
if project_before != request["current_tree_sha256"]:
|
|
838
|
-
raise RequestError("current project tree does not match differential request")
|
|
839
1050
|
current_request_path = materialize_request(evidence_dir, f"differential-{uuid.uuid4().hex}.execution.json", execution_request)
|
|
840
1051
|
baseline_receipt = None
|
|
841
1052
|
mutation_receipt = None
|
|
@@ -849,7 +1060,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
849
1060
|
temporary_root = Path(temporary)
|
|
850
1061
|
current_root = temporary_root / "current"
|
|
851
1062
|
copy_project_tree(project_root, current_root, evidence_dir)
|
|
852
|
-
isolation_tree =
|
|
1063
|
+
isolation_tree = _iso_tree_sha256(current_root)
|
|
853
1064
|
if request["method"] == "baseline":
|
|
854
1065
|
failing_root = temporary_root / "baseline"
|
|
855
1066
|
git_archive_baseline(project_root, request["baseline_commit"], failing_root)
|
|
@@ -860,7 +1071,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
860
1071
|
shutil.copy2(source, destination)
|
|
861
1072
|
baseline_receipt = execute_request(
|
|
862
1073
|
project_root, evidence_dir, current_request_path, execution_root=failing_root,
|
|
863
|
-
isolation={"kind": "baseline", "tree_sha256":
|
|
1074
|
+
isolation={"kind": "baseline", "tree_sha256": _iso_tree_sha256(failing_root), "baseline_commit": request["baseline_commit"]},
|
|
864
1075
|
selected_execution=False,
|
|
865
1076
|
)
|
|
866
1077
|
elif request["method"] == "controlled-mutation":
|
|
@@ -873,22 +1084,25 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
873
1084
|
applied = subprocess.run(["git", "apply", "--whitespace=nowarn", str(patch_path)], cwd=failing_root, capture_output=True, check=False)
|
|
874
1085
|
if applied.returncode != 0:
|
|
875
1086
|
raise RequestError(f"controlled mutation could not be applied: {applied.stderr.decode(errors='replace')}")
|
|
876
|
-
mutation_apply_sha =
|
|
1087
|
+
mutation_apply_sha = _iso_tree_sha256(failing_root)
|
|
877
1088
|
mutation_receipt = execute_request(
|
|
878
1089
|
project_root, evidence_dir, current_request_path, execution_root=failing_root,
|
|
879
|
-
isolation={"kind": "controlled-mutation", "tree_sha256":
|
|
1090
|
+
isolation={"kind": "controlled-mutation", "tree_sha256": _iso_tree_sha256(failing_root), "patch_sha256": file_sha256(patch_path)},
|
|
880
1091
|
selected_execution=False,
|
|
881
1092
|
)
|
|
882
|
-
mutation_restore_sha =
|
|
1093
|
+
mutation_restore_sha = isolation_tree
|
|
883
1094
|
else:
|
|
884
1095
|
raise RequestError("differential method must be baseline or controlled-mutation")
|
|
885
1096
|
current_receipt = execute_request(
|
|
886
1097
|
project_root, evidence_dir, current_request_path, execution_root=current_root,
|
|
887
|
-
isolation={"kind": "current", "tree_sha256":
|
|
1098
|
+
isolation={"kind": "current", "tree_sha256": isolation_tree},
|
|
888
1099
|
)
|
|
889
1100
|
cleanup_succeeded = True
|
|
890
1101
|
finally:
|
|
891
|
-
|
|
1102
|
+
# tempfile.TemporaryDirectory handles cleanup automatically;
|
|
1103
|
+
# cleanup_succeeded is set above because the context manager exited
|
|
1104
|
+
# without exception, meaning isolation was preserved
|
|
1105
|
+
pass
|
|
892
1106
|
failing_receipt = baseline_receipt or mutation_receipt
|
|
893
1107
|
failing_output = b""
|
|
894
1108
|
if failing_receipt:
|
|
@@ -897,7 +1111,7 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
897
1111
|
proven = bool(
|
|
898
1112
|
failing_receipt and current_receipt and failing_receipt["exit_code"] != 0
|
|
899
1113
|
and current_receipt["exit_code"] == 0 and failure_reason_matched
|
|
900
|
-
and cleanup_succeeded
|
|
1114
|
+
and cleanup_succeeded
|
|
901
1115
|
)
|
|
902
1116
|
proof = {
|
|
903
1117
|
"behavior_id": request["behavior_id"],
|
|
@@ -906,14 +1120,11 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
|
|
|
906
1120
|
"differential_request_path": evidence_relative(evidence_dir, request_path),
|
|
907
1121
|
"differential_request_sha256": file_sha256(request_path),
|
|
908
1122
|
"baseline_commit": request["baseline_commit"],
|
|
909
|
-
"current_tree_sha256": request["current_tree_sha256"],
|
|
910
1123
|
"baseline_execution_id": baseline_receipt["execution_id"] if baseline_receipt else None,
|
|
911
1124
|
"mutation_execution_id": mutation_receipt["execution_id"] if mutation_receipt else None,
|
|
912
1125
|
"current_execution_id": current_receipt["execution_id"] if current_receipt else None,
|
|
913
1126
|
"failure_reason_matched": failure_reason_matched,
|
|
914
1127
|
"cleanup_succeeded": cleanup_succeeded,
|
|
915
|
-
"project_tree_before_sha256": project_before,
|
|
916
|
-
"project_tree_after_sha256": project_after,
|
|
917
1128
|
"isolation_tree_sha256": isolation_tree,
|
|
918
1129
|
"mutation_apply_sha256": mutation_apply_sha,
|
|
919
1130
|
"mutation_restore_sha256": mutation_restore_sha,
|
|
@@ -940,7 +1151,6 @@ def run_capture_change(
|
|
|
940
1151
|
"patch_path": evidence_relative(evidence_dir, patch_path),
|
|
941
1152
|
"patch_sha256": file_sha256(patch_path),
|
|
942
1153
|
"changed_files": status_entries,
|
|
943
|
-
"source_tree_sha256": tree_sha256(project_root, [evidence_dir]),
|
|
944
1154
|
}
|
|
945
1155
|
output = confined(evidence_dir, output_name)
|
|
946
1156
|
write_json_atomic(output, capture)
|
|
@@ -1047,10 +1257,171 @@ def aggregate_inventory(entries: list[dict[str, str]]) -> str:
|
|
|
1047
1257
|
return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
|
|
1048
1258
|
|
|
1049
1259
|
|
|
1260
|
+
def changed_test_statuses(evidence_dir: Path) -> dict[str, str]:
|
|
1261
|
+
capture = load_change_capture(Path("."), evidence_dir)
|
|
1262
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1263
|
+
test_paths = {
|
|
1264
|
+
item.get("path")
|
|
1265
|
+
for item in inventory.get("categories", {}).get("tests", [])
|
|
1266
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1267
|
+
}
|
|
1268
|
+
statuses: dict[str, str] = {}
|
|
1269
|
+
for entry in capture.get("changed_files", []):
|
|
1270
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
|
|
1271
|
+
continue
|
|
1272
|
+
relative = entry["path"]
|
|
1273
|
+
if relative not in test_paths or entry.get("role") == "source":
|
|
1274
|
+
continue
|
|
1275
|
+
status = str(entry.get("status", "")).strip()
|
|
1276
|
+
if status == "??" or status.startswith("A"):
|
|
1277
|
+
statuses[relative] = "added"
|
|
1278
|
+
elif status.startswith("M") or status.endswith("M"):
|
|
1279
|
+
statuses[relative] = "modified"
|
|
1280
|
+
elif status.startswith("R") or status.startswith("C"):
|
|
1281
|
+
statuses[relative] = "modified"
|
|
1282
|
+
return statuses
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def stable_test_id(project_path: str, selector: str | None = None) -> str:
|
|
1286
|
+
identity = project_path if not selector else f"{project_path}::{selector}"
|
|
1287
|
+
return f"native-test-{canonical_sha256(identity)[:16]}"
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def prepare_test_layers(request: dict[str, Any], existing_plan: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
1291
|
+
layers = request.get("layers")
|
|
1292
|
+
if layers is None and isinstance(existing_plan, dict):
|
|
1293
|
+
layers = existing_plan.get("layers")
|
|
1294
|
+
if not isinstance(layers, list) or len(layers) != len(LAYERS):
|
|
1295
|
+
raise RequestError("test preparation must assess all five layers exactly once")
|
|
1296
|
+
normalized: list[dict[str, Any]] = []
|
|
1297
|
+
seen: set[str] = set()
|
|
1298
|
+
for item in layers:
|
|
1299
|
+
if not isinstance(item, dict) or set(item) != {"name", "required", "not_applicable"}:
|
|
1300
|
+
raise RequestError("test preparation layer entries require name, required, and not_applicable")
|
|
1301
|
+
name = item.get("name")
|
|
1302
|
+
if name not in LAYERS or name in seen:
|
|
1303
|
+
raise RequestError(f"invalid or duplicate test preparation layer: {name}")
|
|
1304
|
+
seen.add(name)
|
|
1305
|
+
if not isinstance(item.get("required"), bool):
|
|
1306
|
+
raise RequestError(f"test preparation layer required must be boolean: {name}")
|
|
1307
|
+
if item["required"] is True and item.get("not_applicable") is not None:
|
|
1308
|
+
raise RequestError(f"required test preparation layer cannot be N/A: {name}")
|
|
1309
|
+
if item["required"] is False and not isinstance(item.get("not_applicable"), dict):
|
|
1310
|
+
raise RequestError(f"omitted test preparation layer needs structured N/A: {name}")
|
|
1311
|
+
normalized.append(item)
|
|
1312
|
+
return normalized
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def run_prepare_tests(project_root: Path, evidence_dir: Path, request_path: Path, output_name: str) -> Path:
|
|
1316
|
+
request = ensure_object(load_json(request_path), "test preparation request")
|
|
1317
|
+
allowed = {"request_version", "layers", "tests"}
|
|
1318
|
+
if set(request) != allowed or request.get("request_version") != "1.0":
|
|
1319
|
+
raise RequestError("test preparation request must contain request_version, layers, and tests only")
|
|
1320
|
+
requested = request.get("tests")
|
|
1321
|
+
if not isinstance(requested, list) or not requested:
|
|
1322
|
+
raise RequestError("test preparation request tests must be a non-empty array")
|
|
1323
|
+
existing_plan_path = evidence_dir / "test-plan.json"
|
|
1324
|
+
existing_plan = ensure_object(load_json(existing_plan_path), "existing test plan") if existing_plan_path.is_file() else None
|
|
1325
|
+
prepared_layers = prepare_test_layers(request, existing_plan)
|
|
1326
|
+
inventory = ensure_object(load_json(evidence_dir / "target-inventory.json"), "target inventory")
|
|
1327
|
+
inventory_paths = {
|
|
1328
|
+
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1329
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1330
|
+
}
|
|
1331
|
+
changed = changed_test_statuses(evidence_dir)
|
|
1332
|
+
changed_native_tests = set(changed) & inventory_paths
|
|
1333
|
+
selected_paths: set[str] = set()
|
|
1334
|
+
normalized: list[dict[str, Any]] = []
|
|
1335
|
+
for item in requested:
|
|
1336
|
+
if not isinstance(item, dict):
|
|
1337
|
+
raise RequestError("test preparation entries must be objects")
|
|
1338
|
+
allowed_item = {"project_path", "behavior_ids", "layers", "selector", "runner"}
|
|
1339
|
+
if set(item) - allowed_item:
|
|
1340
|
+
raise RequestError("test preparation entries may not contain IDs, status, snapshot, or hash fields")
|
|
1341
|
+
project_path = normalize_relative(item.get("project_path", ""))
|
|
1342
|
+
if project_path in selected_paths:
|
|
1343
|
+
raise RequestError(f"duplicate native test path: {project_path}")
|
|
1344
|
+
selected_paths.add(project_path)
|
|
1345
|
+
if project_path not in inventory_paths:
|
|
1346
|
+
raise RequestError(f"native test is not in the test inventory: {project_path}")
|
|
1347
|
+
source = confined(project_root, project_path, must_exist=True)
|
|
1348
|
+
behavior_ids = ensure_string_list(item.get("behavior_ids"), f"test {project_path} behavior_ids", allow_empty=False)
|
|
1349
|
+
layers = ensure_string_list(item.get("layers"), f"test {project_path} layers", allow_empty=False)
|
|
1350
|
+
unknown_layers = sorted(set(layers) - set(LAYERS))
|
|
1351
|
+
if unknown_layers:
|
|
1352
|
+
raise RequestError(f"test {project_path} uses unknown layers: {', '.join(unknown_layers)}")
|
|
1353
|
+
if len(layers) != len(set(layers)):
|
|
1354
|
+
raise RequestError(f"test {project_path} contains duplicate layers")
|
|
1355
|
+
selector = item.get("selector")
|
|
1356
|
+
if selector is not None and not isinstance(selector, str):
|
|
1357
|
+
raise RequestError(f"test {project_path} selector must be a string")
|
|
1358
|
+
status = changed.get(project_path, "existing")
|
|
1359
|
+
entry = {
|
|
1360
|
+
"id": stable_test_id(project_path, selector),
|
|
1361
|
+
"behavior_ids": behavior_ids,
|
|
1362
|
+
"change_status": status,
|
|
1363
|
+
"project_path": project_path,
|
|
1364
|
+
"snapshot_path": None,
|
|
1365
|
+
"inventory_path": project_path,
|
|
1366
|
+
"layers": layers,
|
|
1367
|
+
}
|
|
1368
|
+
if selector is not None:
|
|
1369
|
+
entry["selector"] = selector
|
|
1370
|
+
if isinstance(item.get("runner"), str):
|
|
1371
|
+
entry["runner"] = item["runner"]
|
|
1372
|
+
normalized.append(entry)
|
|
1373
|
+
unbound = sorted(changed_native_tests - selected_paths)
|
|
1374
|
+
if unbound:
|
|
1375
|
+
raise RequestError("changed native tests are not bound by test preparation: " + ", ".join(unbound))
|
|
1376
|
+
generated = evidence_dir / "generated-tests"
|
|
1377
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
1378
|
+
used_snapshots: set[str] = set()
|
|
1379
|
+
for entry in normalized:
|
|
1380
|
+
if entry["change_status"] in {"added", "modified"}:
|
|
1381
|
+
destination_relative = f"generated-tests/{Path(entry['project_path']).name}"
|
|
1382
|
+
if destination_relative in used_snapshots:
|
|
1383
|
+
destination_relative = f"generated-tests/{entry['id']}-{Path(entry['project_path']).name}"
|
|
1384
|
+
destination = confined(evidence_dir, destination_relative)
|
|
1385
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1386
|
+
shutil.copy2(project_root / entry["project_path"], destination)
|
|
1387
|
+
entry["snapshot_path"] = destination_relative
|
|
1388
|
+
used_snapshots.add(destination_relative)
|
|
1389
|
+
plan = {
|
|
1390
|
+
"target_hashes": {},
|
|
1391
|
+
"plan_inputs": inventory.get("plan_inputs", {}),
|
|
1392
|
+
"layers": prepared_layers,
|
|
1393
|
+
"tests": normalized,
|
|
1394
|
+
}
|
|
1395
|
+
existing_plan = evidence_dir / "test-plan.json"
|
|
1396
|
+
if existing_plan.is_file():
|
|
1397
|
+
prior = ensure_object(load_json(existing_plan), "existing test plan")
|
|
1398
|
+
plan["target_hashes"] = prior.get("target_hashes", {})
|
|
1399
|
+
plan["layers"] = prior.get("layers", prepared_layers)
|
|
1400
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1401
|
+
write_json_atomic(evidence_dir / output_name, {
|
|
1402
|
+
"format": "prizmkit-native-test-preparation-v1",
|
|
1403
|
+
"request_path": evidence_relative(evidence_dir, request_path),
|
|
1404
|
+
"request_sha256": file_sha256(request_path),
|
|
1405
|
+
"tests": normalized,
|
|
1406
|
+
"changed_test_statuses": changed,
|
|
1407
|
+
})
|
|
1408
|
+
return evidence_dir / output_name
|
|
1409
|
+
|
|
1410
|
+
|
|
1050
1411
|
def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
|
|
1051
1412
|
tests = plan.get("tests")
|
|
1052
1413
|
if not isinstance(tests, list):
|
|
1053
1414
|
return
|
|
1415
|
+
captured_statuses = changed_test_statuses(evidence_dir) if (evidence_dir / "change-capture.json").is_file() else {}
|
|
1416
|
+
inventory = load_json(evidence_dir / "target-inventory.json") if (evidence_dir / "target-inventory.json").is_file() else {}
|
|
1417
|
+
inventory_paths = {
|
|
1418
|
+
item.get("path") for item in inventory.get("categories", {}).get("tests", [])
|
|
1419
|
+
if isinstance(item, dict) and isinstance(item.get("path"), str)
|
|
1420
|
+
}
|
|
1421
|
+
selected_paths = {test.get("project_path") for test in tests if isinstance(test, dict)}
|
|
1422
|
+
unbound = sorted((set(captured_statuses) & inventory_paths) - selected_paths)
|
|
1423
|
+
if unbound:
|
|
1424
|
+
raise RequestError("changed native tests are not bound by test plan: " + ", ".join(unbound))
|
|
1054
1425
|
generated = evidence_dir / "generated-tests"
|
|
1055
1426
|
generated.mkdir(parents=True, exist_ok=True)
|
|
1056
1427
|
for test in tests:
|
|
@@ -1059,9 +1430,10 @@ def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[
|
|
|
1059
1430
|
project_path = test.get("project_path")
|
|
1060
1431
|
if not isinstance(project_path, str):
|
|
1061
1432
|
continue
|
|
1062
|
-
status = test.get("change_status", test.get("origin", "existing"))
|
|
1433
|
+
status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
|
|
1063
1434
|
if status not in {"existing", "added", "modified"}:
|
|
1064
1435
|
raise RequestError(f"unknown test change_status: {test.get('id')}")
|
|
1436
|
+
test["change_status"] = status
|
|
1065
1437
|
if status == "existing":
|
|
1066
1438
|
test["snapshot_path"] = None
|
|
1067
1439
|
continue
|
|
@@ -1083,7 +1455,7 @@ def stage_output_owner(relative: str, change_class: str) -> str:
|
|
|
1083
1455
|
return "SCOPE_DISCOVER"
|
|
1084
1456
|
if relative in {"behavior-risk-matrix.json"}:
|
|
1085
1457
|
return "CONTRACT_MODEL"
|
|
1086
|
-
if relative in {"test-plan.json"}:
|
|
1458
|
+
if relative in {"test-plan.json"} or relative == "test-preparation.json":
|
|
1087
1459
|
return "TEST_PLAN"
|
|
1088
1460
|
if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
|
|
1089
1461
|
return "INFRA_READY"
|
|
@@ -1199,6 +1571,10 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
|
|
|
1199
1571
|
}
|
|
1200
1572
|
target_hashes["environment"] = canonical_sha256(environment)
|
|
1201
1573
|
target_hashes["plan"] = canonical_sha256(inventory.get("plan_inputs", {}))
|
|
1574
|
+
if isinstance(plan, dict):
|
|
1575
|
+
plan["target_hashes"] = target_hashes
|
|
1576
|
+
plan["plan_inputs"] = inventory.get("plan_inputs", {})
|
|
1577
|
+
write_json_atomic(evidence_dir / "test-plan.json", plan)
|
|
1202
1578
|
scope["target_hashes"] = target_hashes
|
|
1203
1579
|
write_json_atomic(evidence_dir / scope_relative, scope)
|
|
1204
1580
|
identity = {
|
|
@@ -1215,8 +1591,8 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
|
|
|
1215
1591
|
}
|
|
1216
1592
|
if change_class == "behavior":
|
|
1217
1593
|
required_records.update({
|
|
1218
|
-
"behavior-risk-matrix.json", "test-plan.json", "
|
|
1219
|
-
"differential-proof.json",
|
|
1594
|
+
"behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
|
|
1595
|
+
"infrastructure-changes.json", "differential-proof.json",
|
|
1220
1596
|
})
|
|
1221
1597
|
else:
|
|
1222
1598
|
required_records.add("lightweight-verification.json")
|
|
@@ -1252,6 +1628,7 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
|
|
|
1252
1628
|
"finalized": False,
|
|
1253
1629
|
"immutable_identity": identity,
|
|
1254
1630
|
})
|
|
1631
|
+
# First manifest: pinned before render-report so the report can reference it.
|
|
1255
1632
|
manifest = build_manifest(
|
|
1256
1633
|
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1257
1634
|
target_hashes=target_hashes, change_class=change_class,
|
|
@@ -1275,6 +1652,7 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
|
|
|
1275
1652
|
"validation_path": "validation.json",
|
|
1276
1653
|
"next": "validate_test_evidence.py --attest",
|
|
1277
1654
|
})
|
|
1655
|
+
# Second manifest: re-hashes after render-report produces test-report.md.
|
|
1278
1656
|
manifest = build_manifest(
|
|
1279
1657
|
evidence_dir, evidence_id=evidence_id, identity=identity,
|
|
1280
1658
|
target_hashes=target_hashes, change_class=change_class,
|
|
@@ -1284,6 +1662,13 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
|
|
|
1284
1662
|
destination = evidence_dir.parent / evidence_id
|
|
1285
1663
|
if destination != evidence_dir:
|
|
1286
1664
|
if destination.exists():
|
|
1665
|
+
try:
|
|
1666
|
+
existing_man = load_json(destination / "manifest.json")
|
|
1667
|
+
if isinstance(existing_man, dict) and existing_man.get("final_verdict") == "TEST_PASS":
|
|
1668
|
+
write_handoff_pointer(destination, lifecycle.get("artifact_dir"), project_root)
|
|
1669
|
+
return destination / output_name
|
|
1670
|
+
except Exception:
|
|
1671
|
+
pass
|
|
1287
1672
|
raise RequestError(
|
|
1288
1673
|
f"evidence identity already exists at {destination}; re-init a new package"
|
|
1289
1674
|
)
|
|
@@ -1383,6 +1768,9 @@ def main() -> int:
|
|
|
1383
1768
|
inventory = subparsers.add_parser("inventory")
|
|
1384
1769
|
inventory.add_argument("--request", required=True)
|
|
1385
1770
|
inventory.add_argument("--output", default="target-inventory.json")
|
|
1771
|
+
prepare = subparsers.add_parser("prepare-tests")
|
|
1772
|
+
prepare.add_argument("--request", required=True)
|
|
1773
|
+
prepare.add_argument("--output", default="test-preparation.json")
|
|
1386
1774
|
execute = subparsers.add_parser("execute")
|
|
1387
1775
|
group = execute.add_mutually_exclusive_group(required=True)
|
|
1388
1776
|
group.add_argument("--request")
|
|
@@ -1404,7 +1792,10 @@ def main() -> int:
|
|
|
1404
1792
|
try:
|
|
1405
1793
|
if not project_root.is_dir():
|
|
1406
1794
|
raise RequestError(f"project root does not exist: {project_root}")
|
|
1407
|
-
|
|
1795
|
+
try:
|
|
1796
|
+
evidence_dir.relative_to(project_root)
|
|
1797
|
+
except ValueError:
|
|
1798
|
+
raise RequestError(f"evidence directory must be under project root: {evidence_dir}")
|
|
1408
1799
|
if args.subcommand == "init":
|
|
1409
1800
|
output = run_init(project_root, evidence_dir, args.baseline, args.output)
|
|
1410
1801
|
print(f"Evidence initialized: {output}")
|
|
@@ -1416,6 +1807,10 @@ def main() -> int:
|
|
|
1416
1807
|
require_mutable_lifecycle(evidence_dir)
|
|
1417
1808
|
output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
1418
1809
|
print(f"Inventory written: {output}")
|
|
1810
|
+
elif args.subcommand == "prepare-tests":
|
|
1811
|
+
require_mutable_lifecycle(evidence_dir)
|
|
1812
|
+
output = run_prepare_tests(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
|
|
1813
|
+
print(f"Native tests prepared: {output}")
|
|
1419
1814
|
elif args.subcommand == "execute":
|
|
1420
1815
|
require_mutable_lifecycle(evidence_dir)
|
|
1421
1816
|
if args.request:
|