prizmkit 1.1.134 → 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.134",
3
- "bundledAt": "2026-07-19T00:56:53.854Z",
4
- "bundledFrom": "09d7b00"
2
+ "frameworkVersion": "1.1.135",
3
+ "bundledAt": "2026-07-19T07:16:08.201Z",
4
+ "bundledFrom": "9dfbbdb"
5
5
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.134",
2
+ "version": "1.1.135",
3
3
  "skills": {
4
4
  "prizmkit": {
5
5
  "description": "Framework introduction and navigation for the formal single-requirement lifecycle, project initialization, Prizm docs, and independent deployment.",
@@ -20,23 +20,22 @@ Fix only the protocol mechanisms needed for safe, replayable evidence: project/e
20
20
  The Main Agent supplies semantic requests only; it does not author authoritative package records. Initialize and drive one evidence directory through the builder in this order:
21
21
 
22
22
  ```text
23
- init → capture-change → inventory → execute/differential → finalize → render-report → validate --attest → resume
23
+ init → capture-change → inventory → prepare-tests → execute/differential → finalize → render-report → validate --attest → resume
24
24
  ```
25
25
 
26
26
  Use `${SKILL_DIR}/scripts/build_test_evidence.py` for every lifecycle command:
27
27
 
28
28
  - `init`: creates the package directories and immutable baseline identity.
29
- - `capture-change`: captures the complete canonical change, including untracked, deleted, renamed, and copied files; never substitute plain `git diff`.
30
- - `inventory`: hashes current source/tests/contracts/lockfiles, rejects category overlap, excludes runtime-only paths, and validates directory roots plus explicit changed-file sets.
31
- - `execute`: runs native project commands and owns `executions.json`, `receipts/`, and `raw/`.
29
+ - `capture-change`: captures the complete canonical change, including untracked, deleted, renamed, and copied files; never substitute plain `git diff`. `inventory` then requires its changed-file set to match the capture exactly and rejects stale captures or unaccounted live paths.
30
+ - `inventory`: hashes current source/tests/contracts/lockfiles, rejects category overlap, excludes runtime-only paths, and validates directory roots plus the complete capture-derived changed-file set.
31
+ - `prepare-tests`: discovers repository-native test files from the canonical capture and inventory, generates stable builder-owned test IDs, derives existing/added/modified status, and snapshots only added/modified live test sources.
32
+ - `execute`: runs native project commands and owns `executions.json`, `receipts/`, and `raw/`. For `auto_bind=true`, the builder resolves recognized package-wide commands or explicit file/directory/glob arguments to the prepared tests they cover; ambiguous commands are blocked and must use `test_paths`.
32
33
  - `differential`: records executable proof or a mechanically validated `NOT_APPLICABLE` decision only for genuinely new behavior or textual-contract behavior.
33
34
  - `finalize`: owns manifest, stage hashes, verdict, generated-test snapshots, and evidence identity.
34
35
  - `render-report`: derives `test-report.md`; it is never authoritative.
35
36
  - `resume`: preserves prior receipts and refuses to rebind an identity-changing package; create a new package after scope/source identity changes.
36
37
 
37
- Before testing, inspect and reuse repository-native tests and their runner configuration. Existing tests are the default. Add or modify a project-native test only after recording a concrete behavior gap. Classify each planned test as `existing`, `added`, or `modified`; `generated-tests/` is empty for unchanged existing tests and contains only snapshots of added/modified native test sources.
38
-
39
- `test-plan.json` is the only source of test IDs. Execution requests may reference only IDs declared there, and the builder rejects unknown IDs or layer mappings. Assess all five layers explicitly, but set `required=true` only when risk requires it; every omitted layer needs structured N/A evidence. The same binding applies test ↔ behavior ↔ execution ↔ differential proof.
38
+ `test-plan.json` is the only source of builder-generated test IDs. Execution requests may provide native `test_paths`, `auto_bind=true`, or legacy IDs; the builder resolves paths to IDs before execution and rejects unknown/unbound tests or layer mappings. Assess all five layers explicitly, but set `required=true` only when risk requires it; every omitted layer needs structured N/A evidence. The same binding applies test ↔ behavior ↔ execution ↔ differential proof.
40
39
 
41
40
  The builder/validator, not the Main Agent, produces or updates `manifest.json`, `executions.json`, `receipts/*`, `validation.json`, `verdict.json`, `test-report.md`, and `source-change.patch`. `validation.json` is produced only by `validate_test_evidence.py --attest`; a package is not a `TEST_PASS` until the second strict validation succeeds.
42
41
 
@@ -149,7 +148,7 @@ Coverage metrics are diagnostic signals only and never substitute for this matri
149
148
 
150
149
  ### 4. TEST_PLAN
151
150
 
152
- Write `test-plan.json`. Select necessary unit, module/component, contract, integration, and code-level E2E tests by risk. Assess the ordered execution layers:
151
+ Write the semantic preparation request under `requests/` and invoke `prepare-tests`; do not hand-author the resulting `test-plan.json`. Provide only repository-native `project_path` values, behavior mappings, selectors/runner hints, and the five layer decisions. The builder derives test IDs, status, snapshots, and hashes from inventory and canonical Git capture. Assess the ordered execution layers:
153
152
 
154
153
  1. focused;
155
154
  2. module/component;
@@ -175,7 +174,7 @@ If a valid test reveals a business/contract defect, do not fix production behavi
175
174
 
176
175
  ### 7. EXECUTE_PROVE
177
176
 
178
- Run required layers in plan order through `${SKILL_DIR}/scripts/build_test_evidence.py execute`. The model selects project-native argv, cwd, timeout, attempts, concurrency, layer, environment classification, and tool probes in schema-shaped requests. The runner performs the actual process execution and appends complete environment/tool versions, raw stdout/stderr hashes, request hash, runner hash, unique receipt, and receipt-chain binding to `executions.json`. Do not accept caller-authored execution JSON and do not retry until green.
177
+ Run required layers in plan order through `${SKILL_DIR}/scripts/build_test_evidence.py execute`. Use `test_paths` or `auto_bind=true` for new requests; the builder resolves and persists canonical test IDs before the native command runs. The model selects project-native argv, cwd, timeout, attempts, concurrency, layer, environment classification, and tool probes in schema-shaped requests. The runner performs the actual process execution and appends complete environment/tool versions, raw stdout/stderr hashes, request hash, runner hash, unique receipt, and receipt-chain binding to `executions.json`. Do not accept caller-authored execution JSON and do not retry until green.
179
178
 
180
179
  Differentially prove each added/changed necessary behavior test:
181
180
 
@@ -215,6 +215,32 @@
215
215
  "plan_inputs": { "type": "object", "minProperties": 1 }
216
216
  }
217
217
  },
218
+ "changeCapture": {
219
+ "type": "object",
220
+ "additionalProperties": false,
221
+ "required": ["capture_format", "baseline_commit", "patch_path", "patch_sha256", "changed_files", "source_tree_sha256"],
222
+ "properties": {
223
+ "capture_format": { "const": "prizmkit-canonical-change-v1" },
224
+ "baseline_commit": { "type": "string", "minLength": 1 },
225
+ "patch_path": { "$ref": "#/$defs/path" },
226
+ "patch_sha256": { "$ref": "#/$defs/sha256" },
227
+ "changed_files": {
228
+ "type": "array",
229
+ "items": {
230
+ "type": "object",
231
+ "additionalProperties": false,
232
+ "required": ["path", "status", "old_path"],
233
+ "properties": {
234
+ "path": { "$ref": "#/$defs/path" },
235
+ "status": { "type": "string", "minLength": 1 },
236
+ "old_path": { "type": ["string", "null"] },
237
+ "role": { "const": "source" }
238
+ }
239
+ }
240
+ },
241
+ "source_tree_sha256": { "$ref": "#/$defs/sha256" }
242
+ }
243
+ },
218
244
  "testPlan": {
219
245
  "type": "object",
220
246
  "additionalProperties": false,
@@ -250,16 +276,68 @@
250
276
  "project_path": { "$ref": "#/$defs/path" },
251
277
  "snapshot_path": { "type": ["string", "null"] },
252
278
  "inventory_path": { "$ref": "#/$defs/path" },
253
- "layers": { "$ref": "#/$defs/stringArray" }
279
+ "layers": { "$ref": "#/$defs/stringArray" },
280
+ "selector": { "type": "string", "minLength": 1 },
281
+ "runner": { "type": "string", "minLength": 1 }
254
282
  }
255
283
  }
256
284
  }
257
285
  }
258
286
  },
287
+ "testPreparationRequest": {
288
+ "type": "object",
289
+ "additionalProperties": false,
290
+ "required": ["request_version", "layers", "tests"],
291
+ "properties": {
292
+ "request_version": { "const": "1.0" },
293
+ "layers": {
294
+ "type": "array",
295
+ "minItems": 5,
296
+ "items": {
297
+ "type": "object",
298
+ "additionalProperties": false,
299
+ "required": ["name", "required", "not_applicable"],
300
+ "properties": {
301
+ "name": { "enum": ["focused", "module-component", "contract-integration", "affected-module-regression", "regression-ring"] },
302
+ "required": { "type": "boolean" },
303
+ "not_applicable": { "type": ["object", "null"] }
304
+ }
305
+ }
306
+ },
307
+ "tests": {
308
+ "type": "array",
309
+ "minItems": 1,
310
+ "items": {
311
+ "type": "object",
312
+ "additionalProperties": false,
313
+ "required": ["project_path", "behavior_ids", "layers"],
314
+ "properties": {
315
+ "project_path": { "$ref": "#/$defs/path" },
316
+ "behavior_ids": { "$ref": "#/$defs/stringArray" },
317
+ "layers": { "$ref": "#/$defs/stringArray" },
318
+ "selector": { "type": "string", "minLength": 1 },
319
+ "runner": { "type": "string", "minLength": 1 }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ },
325
+ "testPreparation": {
326
+ "type": "object",
327
+ "additionalProperties": false,
328
+ "required": ["format", "request_path", "request_sha256", "tests", "changed_test_statuses"],
329
+ "properties": {
330
+ "format": { "const": "prizmkit-native-test-preparation-v1" },
331
+ "request_path": { "$ref": "#/$defs/path" },
332
+ "request_sha256": { "$ref": "#/$defs/sha256" },
333
+ "tests": { "type": "array", "minItems": 1, "items": { "type": "object" } },
334
+ "changed_test_statuses": { "type": "object", "additionalProperties": { "enum": ["added", "modified"] } }
335
+ }
336
+ },
259
337
  "executionRequest": {
260
338
  "type": "object",
261
339
  "additionalProperties": false,
262
- "required": ["request_version", "purpose", "command", "cwd", "environment", "tool_version_commands", "layer", "test_ids", "external_targets"],
340
+ "required": ["request_version", "purpose", "command", "cwd", "environment", "tool_version_commands", "layer", "external_targets"],
263
341
  "properties": {
264
342
  "request_version": { "const": "1.0" },
265
343
  "purpose": { "type": "string", "minLength": 1 },
@@ -269,6 +347,8 @@
269
347
  "tool_version_commands": { "type": "object" },
270
348
  "layer": { "enum": ["focused", "module-component", "contract-integration", "affected-module-regression", "regression-ring", "lightweight"] },
271
349
  "test_ids": { "type": "array", "items": { "type": "string", "minLength": 1 } },
350
+ "test_paths": { "type": "array", "items": { "$ref": "#/$defs/path" } },
351
+ "auto_bind": { "type": "boolean" },
272
352
  "external_targets": { "type": "array", "items": { "$ref": "#/$defs/externalTarget" } },
273
353
  "timeout_seconds": { "type": "number", "exclusiveMinimum": 0 },
274
354
  "attempt_policy": { "type": "object" },
@@ -294,6 +374,8 @@
294
374
  "tool_versions": { "type": "object" },
295
375
  "layer": { "enum": ["focused", "module-component", "contract-integration", "affected-module-regression", "regression-ring", "lightweight"] },
296
376
  "test_ids": { "type": "array", "items": { "type": "string", "minLength": 1 } },
377
+ "test_paths": { "type": "array", "items": { "$ref": "#/$defs/path" } },
378
+ "auto_bind": { "type": "boolean" },
297
379
  "external_targets": { "type": "array", "items": { "$ref": "#/$defs/externalTarget" } },
298
380
  "exit_code": { "type": "integer" },
299
381
  "stdout_path": { "$ref": "#/$defs/path" },
@@ -4,6 +4,7 @@
4
4
  "init",
5
5
  "capture-change",
6
6
  "inventory",
7
+ "prepare-tests",
7
8
  "execute",
8
9
  "differential",
9
10
  "finalize",
@@ -18,6 +19,7 @@
18
19
  "environment": "environment.json",
19
20
  "behavior_risk_matrix": "behavior-risk-matrix.json",
20
21
  "test_plan": "test-plan.json",
22
+ "test_preparation": "test-preparation.json",
21
23
  "infrastructure_changes": "infrastructure-changes.json",
22
24
  "differential_proof": "differential-proof.json",
23
25
  "executions": "executions.json",
@@ -25,9 +25,9 @@ Hashes and validator receipts provide byte-level binding and drift detection, no
25
25
 
26
26
  ## Existing Tests and Package Finalization
27
27
 
28
- The evidence package is an audit of repository behavior, not a request to invent a parallel test suite. Discover and execute the smallest relevant native tests before adding anything. `generated-tests/` is a snapshot area for added or modified repository-native tests only; it may be empty when existing tests cover the behavior.
28
+ The evidence package is an audit of repository behavior, not a request to invent a parallel test suite. Discover and execute the smallest relevant native tests before adding anything. Add, modify, or delete tests only in the target repository. The builder's `prepare-tests` command derives stable test IDs and change status from canonical Git capture, and snapshots only added or modified repository-native tests. `generated-tests/` is an evidence archive, never an execution workspace; it may be empty when existing tests cover the behavior.
29
29
 
30
- The canonical package lifecycle is `init → capture-change → inventory → execute/differential → finalize → render-report → validate --attest → resume`. Builder commands own `source-change.patch`, receipts, raw outputs, manifest, verdict, and report. The validator alone owns `validation.json`. Evidence identity is derived from the resolved baseline, canonical patch hash, and scope hash; identity-changing resume creates a new package instead of rewriting the old directory.
30
+ The canonical package lifecycle is `init → capture-change → inventory → prepare-tests → execute/differential → finalize → render-report → validate --attest → resume`. Builder commands own `source-change.patch`, native test preparation, receipts, raw outputs, manifest, verdict, and report. The validator alone owns `validation.json`. Inventory must consume a fresh capture and exactly preserve its complete changed-file set. Canonical behavior packages require a schema-valid `test-preparation.json` bound byte-for-byte to the builder-generated `test-plan.json` and its hashed semantic request. Evidence identity is derived from the resolved baseline, canonical patch hash, and scope hash; identity-changing resume creates a new package instead of rewriting the old directory.
31
31
 
32
32
 
33
33
  Run exactly these states in order:
@@ -6,7 +6,7 @@ Load this reference when writing an inventory, execution, differential, resume,
6
6
 
7
7
  Start every new package with `init`, then run builder-owned `capture-change` before semantic inventory. `capture-change` is the only producer of `source-change.patch`; it records tracked, untracked, deleted, renamed, and copied paths against the resolved baseline. The Main Agent must not create or edit the patch.
8
8
 
9
- Use existing project-native tests first. A test plan must identify each test as `existing`, `added`, or `modified`; only the latter two may receive a `generated-tests/` snapshot. Existing tests are executed from their repository paths and do not need generated snapshots. `test-plan.json` is the sole authority for test IDs, and execution requests with unknown IDs are rejected by the builder.
9
+ Use existing project-native tests first. The Main Agent supplies only semantic test paths, behavior mappings, selectors, runner hints, and layer decisions. Run `prepare-tests` after inventory: it derives stable file/test-unit IDs and existing/added/modified status from canonical capture, snapshots added/modified repository tests, and writes normalized `test-plan.json`. The Main Agent must not hand-author IDs, status, snapshot paths, hashes, or receipt fields. Inventory `changed_files` must exactly match the fresh canonical capture, and every live captured path must be inventoried or explicitly excluded. Execution requests may use `test_paths`, `auto_bind: true`, or legacy IDs; `auto_bind` resolves only recognized package-wide commands or explicit file/directory/glob command scope, while ambiguous commands must provide `test_paths`. The builder records canonical test IDs before running. Every changed native test must be bound, and package-wide commands must not silently hide added or modified tests.
10
10
 
11
11
  `finalize` creates the manifest, stage dependency hashes, verdict, evidence identity, and deterministic report inputs. `render-report` only renders the derived Markdown view. The validator alone creates `validation.json` during `--attest`; never hand-author a validation attestation or rebind a finalized package after identity inputs change.
12
12
 
@@ -4,7 +4,7 @@ Use this reference during `INFRA_READY`, `TEST_BUILD`, and `EXECUTE_PROVE`. The
4
4
 
5
5
  ## Existing-Test-First Rule
6
6
 
7
- Before creating a test, inspect the repository's native test files, runner configuration, and existing behavior assertions. Execute the smallest relevant existing tests first. Add or modify a repository-native test only when a concrete behavior gap is identified and recorded in the test plan. Mark each test `existing`, `added`, or `modified`; only `added` and `modified` tests are copied into `generated-tests/`. An unchanged existing test has no generated snapshot.
7
+ Before creating a test, inspect the repository's native test files, runner configuration, and existing behavior assertions. Execute the smallest relevant existing tests first. Add, modify, or delete a repository-native test only when a concrete behavior gap is identified and recorded in the semantic preparation request. Supply project paths and behavior/layer mappings only; `prepare-tests` derives IDs, status, snapshots, and hashes from the repository and canonical Git capture. An unchanged existing test has no generated snapshot, and `generated-tests/` is never an execution workspace.
8
8
 
9
9
 
10
10
  For `scope=this-change`, changed files locate the affected module; they do not restrict tests to changed lines. Fill observable behavior/risk gaps across the whole affected module and execute its Regression Ring. Unrelated modules remain out of scope unless they are direct callers, consumers, shared contracts, or state dependencies.
@@ -32,7 +32,7 @@ For every behavior row:
32
32
  1. Read the public interface, contract source, preconditions, inputs/boundaries, outputs, side effects, transitions, and errors.
33
33
  2. Inspect existing test assertions—not names—to find proven cells.
34
34
  3. Assess all eight risks: functional, boundary, permission, concurrency, idempotency, time, dependency, and consumer.
35
- 4. For every applicable risk, create a distinct test ID and expected execution layer.
35
+ 4. For every applicable risk, map one or more builder-generated test units and expected execution layer; do not invent a separate AI-maintained ID for every test function.
36
36
  5. For every non-applicable risk, record a concrete rationale tied to this behavior.
37
37
  6. Keep unresolved truth or untestable necessary behavior explicit; it blocks rather than becoming guessed expected behavior.
38
38
 
@@ -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
@@ -326,6 +328,50 @@ 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
+ 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
+
329
375
  def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
330
376
  path = evidence_dir / "test-plan.json"
331
377
  if not path.exists():
@@ -333,13 +379,22 @@ def plan_for_execution(evidence_dir: Path) -> dict[str, Any] | None:
333
379
  return ensure_object(load_json(path), "test plan")
334
380
 
335
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
+
336
391
  def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = None) -> dict[str, Any]:
337
392
  request = ensure_object(request, "execution request")
338
393
  required = {
339
394
  "request_version", "purpose", "command", "cwd", "environment",
340
- "tool_version_commands", "layer", "test_ids", "external_targets",
395
+ "tool_version_commands", "layer", "external_targets",
341
396
  }
342
- optional = {"timeout_seconds", "attempt_policy", "concurrency"}
397
+ optional = {"timeout_seconds", "attempt_policy", "concurrency", "test_ids", "test_paths", "auto_bind"}
343
398
  if not required <= set(request) or set(request) - required - optional:
344
399
  raise RequestError(f"execution request fields must contain {sorted(required)} and only supported optional fields")
345
400
  if request["request_version"] != "1.0":
@@ -357,20 +412,32 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
357
412
  if not isinstance(name, str):
358
413
  raise RequestError("tool probe names must be strings")
359
414
  ensure_string_list(probe, f"tool probe {name}", allow_empty=False)
360
- test_ids = ensure_string_list(request["test_ids"], "test_ids", allow_empty=False)
361
- if len(set(test_ids)) != len(test_ids):
362
- raise RequestError("execution request test_ids contains duplicates")
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")
363
430
  if request["layer"] not in LAYERS and request["layer"] != "lightweight":
364
431
  raise RequestError(f"unknown execution layer: {request['layer']}")
365
- if plan is not None:
366
- tests = plan.get("tests") if isinstance(plan, dict) else None
367
- known = {item.get("id") for item in tests if isinstance(item, dict)} if isinstance(tests, list) else set()
368
- unknown = sorted(set(test_ids) - known)
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)
369
436
  if unknown:
370
437
  raise RequestError(f"execution request references unknown test IDs: {', '.join(unknown)}")
371
- test_map = {item.get("id"): item for item in tests if isinstance(item, dict)} if isinstance(tests, list) else {}
438
+ test_map = {item.get("id"): item for item in tests}
372
439
  invalid_layer = sorted(
373
- test_id for test_id in test_ids
440
+ test_id for test_id in request["test_ids"]
374
441
  if request["layer"] not in (test_map.get(test_id, {}).get("layers") or [])
375
442
  )
376
443
  if invalid_layer:
@@ -381,6 +448,116 @@ def validate_execution_request(request: Any, *, plan: dict[str, Any] | None = No
381
448
  return request
382
449
 
383
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
+
384
561
  def is_volatile_snapshot_path(root: Path, candidate: Path, excluded: list[Path] | None = None) -> bool:
385
562
  try:
386
563
  relative = candidate.relative_to(root)
@@ -404,6 +581,7 @@ def tree_sha256(root: Path, excluded: list[Path] | None = None) -> str:
404
581
 
405
582
 
406
583
  def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str]) -> list[dict[str, str]]:
584
+ project_resolved = project_root.resolve()
407
585
  paths: set[Path] = set()
408
586
  for pattern in patterns:
409
587
  if Path(pattern).is_absolute() or ".." in Path(pattern).parts:
@@ -413,7 +591,7 @@ def collect_matches(project_root: Path, patterns: list[str], exclusions: set[str
413
591
  paths.add(candidate.resolve())
414
592
  entries = []
415
593
  for candidate in sorted(paths):
416
- relative = candidate.relative_to(project_root).as_posix()
594
+ relative = candidate.relative_to(project_resolved).as_posix()
417
595
  if relative not in exclusions and not is_reserved_inventory_path(relative):
418
596
  entries.append({"path": relative, "sha256": file_sha256(candidate)})
419
597
  return entries
@@ -473,6 +651,7 @@ def enumerate_module_root_files(
473
651
  evidence_dir: Path,
474
652
  module_roots: list[str],
475
653
  ) -> dict[str, list[str]]:
654
+ project_resolved = project_root.resolve()
476
655
  evidence_resolved = evidence_dir.resolve()
477
656
  result: dict[str, list[str]] = {}
478
657
  for relative in module_roots:
@@ -485,7 +664,7 @@ def enumerate_module_root_files(
485
664
  resolved = candidate.resolve()
486
665
  if resolved == evidence_resolved or evidence_resolved in resolved.parents:
487
666
  continue
488
- files.append(resolved.relative_to(project_root).as_posix())
667
+ files.append(resolved.relative_to(project_resolved).as_posix())
489
668
  result[relative] = sorted(files)
490
669
  return result
491
670
 
@@ -548,6 +727,39 @@ def run_inventory(project_root: Path, evidence_dir: Path, request_path: Path, ou
548
727
  for entries in output_categories.values()
549
728
  for entry in entries
550
729
  }
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")
551
763
  missing_from_inventory = sorted({
552
764
  relative
553
765
  for files in module_root_files.values()
@@ -663,7 +875,12 @@ def execute_request(
663
875
  isolation: dict[str, Any] | None = None,
664
876
  selected_execution: bool = True,
665
877
  ) -> dict[str, Any]:
666
- request = validate_execution_request(load_json(request_path), plan=plan_for_execution(evidence_dir))
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)
667
884
  root = execution_root or project_root
668
885
  cwd = confined(root, request["cwd"] or ".", must_exist=True, directory=True)
669
886
  complete_environment = {"PATH": os.environ.get("PATH", os.defpath)}
@@ -826,9 +1043,15 @@ def run_differential(project_root: Path, evidence_dir: Path, request_path: Path)
826
1043
  return proof
827
1044
  if set(request) != required or request["request_version"] != "1.0":
828
1045
  raise RequestError(f"differential request fields must be exactly {sorted(required)} with version 1.0")
829
- execution_request = validate_execution_request(
830
- request["execution_request"], plan=plan_for_execution(evidence_dir),
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
+ ),
831
1052
  )
1053
+ request["execution_request"] = execution_request
1054
+ write_json_atomic(request_path, request)
832
1055
  expected_failure_signals = ensure_string_list(request["expected_failure_signals"], "expected_failure_signals", allow_empty=False)
833
1056
  overlay_paths = ensure_string_list(request["test_overlay_paths"], "test_overlay_paths")
834
1057
  for relative in overlay_paths:
@@ -1047,10 +1270,171 @@ def aggregate_inventory(entries: list[dict[str, str]]) -> str:
1047
1270
  return canonical_sha256(sorted(entries, key=lambda item: item["path"]))
1048
1271
 
1049
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
+
1050
1424
  def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[str, Any]) -> None:
1051
1425
  tests = plan.get("tests")
1052
1426
  if not isinstance(tests, list):
1053
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))
1054
1438
  generated = evidence_dir / "generated-tests"
1055
1439
  generated.mkdir(parents=True, exist_ok=True)
1056
1440
  for test in tests:
@@ -1059,9 +1443,10 @@ def normalize_test_snapshots(project_root: Path, evidence_dir: Path, plan: dict[
1059
1443
  project_path = test.get("project_path")
1060
1444
  if not isinstance(project_path, str):
1061
1445
  continue
1062
- status = test.get("change_status", test.get("origin", "existing"))
1446
+ status = captured_statuses.get(project_path, "existing") if captured_statuses else test.get("change_status", test.get("origin", "existing"))
1063
1447
  if status not in {"existing", "added", "modified"}:
1064
1448
  raise RequestError(f"unknown test change_status: {test.get('id')}")
1449
+ test["change_status"] = status
1065
1450
  if status == "existing":
1066
1451
  test["snapshot_path"] = None
1067
1452
  continue
@@ -1083,7 +1468,7 @@ def stage_output_owner(relative: str, change_class: str) -> str:
1083
1468
  return "SCOPE_DISCOVER"
1084
1469
  if relative in {"behavior-risk-matrix.json"}:
1085
1470
  return "CONTRACT_MODEL"
1086
- if relative in {"test-plan.json"}:
1471
+ if relative in {"test-plan.json"} or relative == "test-preparation.json":
1087
1472
  return "TEST_PLAN"
1088
1473
  if relative in {"infrastructure-changes.json"} or relative.startswith("contracts/"):
1089
1474
  return "INFRA_READY"
@@ -1199,6 +1584,10 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1199
1584
  }
1200
1585
  target_hashes["environment"] = canonical_sha256(environment)
1201
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)
1202
1591
  scope["target_hashes"] = target_hashes
1203
1592
  write_json_atomic(evidence_dir / scope_relative, scope)
1204
1593
  identity = {
@@ -1215,8 +1604,8 @@ def run_finalize(project_root: Path, evidence_dir: Path, request: dict[str, Any]
1215
1604
  }
1216
1605
  if change_class == "behavior":
1217
1606
  required_records.update({
1218
- "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
1219
- "differential-proof.json",
1607
+ "behavior-risk-matrix.json", "test-plan.json", "test-preparation.json",
1608
+ "infrastructure-changes.json", "differential-proof.json",
1220
1609
  })
1221
1610
  else:
1222
1611
  required_records.add("lightweight-verification.json")
@@ -1383,6 +1772,9 @@ def main() -> int:
1383
1772
  inventory = subparsers.add_parser("inventory")
1384
1773
  inventory.add_argument("--request", required=True)
1385
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")
1386
1778
  execute = subparsers.add_parser("execute")
1387
1779
  group = execute.add_mutually_exclusive_group(required=True)
1388
1780
  group.add_argument("--request")
@@ -1416,6 +1808,10 @@ def main() -> int:
1416
1808
  require_mutable_lifecycle(evidence_dir)
1417
1809
  output = run_inventory(project_root, evidence_dir, request_file(evidence_dir, args.request), args.output)
1418
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}")
1419
1815
  elif args.subcommand == "execute":
1420
1816
  require_mutable_lifecycle(evidence_dir)
1421
1817
  if args.request:
@@ -45,8 +45,8 @@ COMMON_RECORDS = {
45
45
  "source-change.patch", "test-report.md",
46
46
  }
47
47
  BEHAVIOR_RECORDS = {
48
- "behavior-risk-matrix.json", "test-plan.json", "infrastructure-changes.json",
49
- "differential-proof.json",
48
+ "behavior-risk-matrix.json", "test-plan.json",
49
+ "infrastructure-changes.json", "differential-proof.json",
50
50
  }
51
51
  LIGHTWEIGHT_RECORDS = {"lightweight-verification.json"}
52
52
  SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
@@ -776,7 +776,9 @@ def validate_requests_and_receipts(
776
776
  if not isinstance(request, dict):
777
777
  errors.append(f"request must be an object: {relative}")
778
778
  continue
779
- if request.get("method") == "not-applicable":
779
+ if set(request) == {"request_version", "layers", "tests"}:
780
+ validate_definition(request, "testPreparationRequest", records_schema, relative, errors)
781
+ elif request.get("method") == "not-applicable":
780
782
  validate_definition(request, "differentialRequest", records_schema, relative, errors)
781
783
  not_applicable = request.get("not_applicable")
782
784
  if isinstance(not_applicable, dict):
@@ -935,13 +937,103 @@ def validate_execution_request_semantics(
935
937
  environment = value.get("environment")
936
938
  require(isinstance(environment, dict) and all(isinstance(key, str) and isinstance(item, str) for key, item in environment.items()), f"{location}: environment must contain complete string values", errors)
937
939
  test_ids = value.get("test_ids")
938
- require(isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids), f"{location}: test_ids must be string IDs", errors)
940
+ test_paths = value.get("test_paths")
941
+ auto_bind = value.get("auto_bind") is True
942
+ require(
943
+ (isinstance(test_ids, list) and all(isinstance(item, str) and item for item in test_ids))
944
+ or (isinstance(test_paths, list) and all(isinstance(item, str) and item for item in test_paths))
945
+ or auto_bind,
946
+ f"{location}: execution must provide test_ids, test_paths, or auto_bind",
947
+ errors,
948
+ )
939
949
  if receipt:
940
950
  require(type(value.get("selected_execution")) is bool, f"{location}: selected_execution must be boolean", errors)
941
951
  require(type(value.get("reliable")) is bool, f"{location}: reliable must be boolean", errors)
942
952
  require(type(value.get("exit_code")) is int and not isinstance(value.get("exit_code"), bool), f"{location}: exit_code must be an integer", errors)
943
953
 
944
954
 
955
+ def changed_test_statuses(root: Path) -> dict[str, str]:
956
+ capture_path = root / "change-capture.json"
957
+ if not capture_path.is_file():
958
+ return {}
959
+ try:
960
+ capture = load_json(capture_path)
961
+ except EvidenceError:
962
+ return {}
963
+ statuses: dict[str, str] = {}
964
+ for item in capture.get("changed_files", []) if isinstance(capture, dict) else []:
965
+ if not isinstance(item, dict) or not isinstance(item.get("path"), str) or item.get("role") == "source":
966
+ continue
967
+ status = str(item.get("status", "")).strip()
968
+ if status == "??" or status.startswith("A"):
969
+ statuses[item["path"]] = "added"
970
+ elif status.startswith("M") or status.endswith("M") or status.startswith(("R", "C")):
971
+ statuses[item["path"]] = "modified"
972
+ return statuses
973
+
974
+
975
+ def validate_canonical_capture(
976
+ root: Path,
977
+ project_root: Path,
978
+ capture: dict[str, Any],
979
+ inventory: dict[str, Any],
980
+ scope: dict[str, Any],
981
+ errors: list[str],
982
+ ) -> None:
983
+ patch_relative = capture.get("patch_path")
984
+ patch = safe_path(root, patch_relative, errors, "canonical change patch") if isinstance(patch_relative, str) else None
985
+ require(patch_relative == "source-change.patch", "canonical capture patch_path must be source-change.patch", errors)
986
+ if patch is not None:
987
+ require(file_sha256(patch) == capture.get("patch_sha256"), "canonical capture patch hash mismatch", errors)
988
+ require(
989
+ capture.get("source_tree_sha256") == tree_sha256(project_root, root),
990
+ "canonical change capture source tree is stale",
991
+ errors,
992
+ )
993
+ captured_paths = {
994
+ item.get("path")
995
+ for item in capture.get("changed_files", [])
996
+ if isinstance(item, dict) and isinstance(item.get("path"), str)
997
+ }
998
+ inventory_changed = set(inventory.get("changed_files", [])) if isinstance(inventory.get("changed_files"), list) else set()
999
+ scope_changed = set(scope.get("changed_files", [])) if isinstance(scope.get("changed_files"), list) else set()
1000
+ require(captured_paths == inventory_changed, "canonical capture and inventory changed_files do not match", errors)
1001
+ require(captured_paths == scope_changed, "canonical capture and scope changed_files do not match", errors)
1002
+
1003
+
1004
+ def validate_test_preparation(
1005
+ root: Path,
1006
+ project_root: Path,
1007
+ preparation: dict[str, Any],
1008
+ plan: dict[str, Any],
1009
+ inventory_by_category: dict[str, dict[str, str]],
1010
+ entry_map: dict[str, dict[str, Any]],
1011
+ records_schema: dict[str, Any],
1012
+ errors: list[str],
1013
+ ) -> None:
1014
+ validate_definition(preparation, "testPreparation", records_schema, "test-preparation", errors)
1015
+ request_relative = preparation.get("request_path")
1016
+ request_path = require_manifest_entry(request_relative, entry_map, root, errors, "test preparation request") if isinstance(request_relative, str) else None
1017
+ if request_path is not None:
1018
+ require(file_sha256(request_path) == preparation.get("request_sha256"), "test preparation request hash mismatch", errors)
1019
+ prepared_tests = preparation.get("tests")
1020
+ require(prepared_tests == plan.get("tests"), "test preparation tests do not match test plan", errors)
1021
+ statuses = preparation.get("changed_test_statuses")
1022
+ require(isinstance(statuses, dict), "test preparation changed_test_statuses must be an object", errors)
1023
+ if not isinstance(statuses, dict):
1024
+ return
1025
+ for relative, status in statuses.items():
1026
+ require(relative in inventory_by_category.get("tests", {}), f"test preparation status is not in test inventory: {relative}", errors)
1027
+ require(status in {"added", "modified"}, f"test preparation has invalid changed test status: {relative}", errors)
1028
+ for test in prepared_tests if isinstance(prepared_tests, list) else []:
1029
+ if not isinstance(test, dict):
1030
+ continue
1031
+ relative = test.get("project_path")
1032
+ if isinstance(relative, str):
1033
+ expected = statuses.get(relative, "existing")
1034
+ require(test.get("change_status") == expected, f"test preparation status mismatch: {relative}", errors)
1035
+
1036
+
945
1037
  def validate_plan_and_scope_bindings(
946
1038
  root: Path,
947
1039
  project_root: Path,
@@ -1003,6 +1095,8 @@ def validate_plan_and_scope_bindings(
1003
1095
  for test_id in receipt.get("test_ids", []):
1004
1096
  if isinstance(test_id, str):
1005
1097
  selected_by_test.setdefault(test_id, []).append(receipt)
1098
+ changed_tests = changed_test_statuses(root)
1099
+ planned_test_paths: set[str] = set()
1006
1100
  known_behavior_ids = {item.get("id") for item in matrix.get("behaviors", []) if isinstance(item, dict)}
1007
1101
  for test in tests:
1008
1102
  if not isinstance(test, dict):
@@ -1018,9 +1112,13 @@ def validate_plan_and_scope_bindings(
1018
1112
  for behavior_id in behavior_ids if isinstance(behavior_ids, list) else []:
1019
1113
  require(behavior_id in known_behavior_ids, f"planned test maps unknown behavior: {test_id}/{behavior_id}", errors)
1020
1114
  project_relative = test.get("project_path")
1115
+ planned_test_paths.add(project_relative)
1021
1116
  inventory_relative = test.get("inventory_path")
1022
1117
  snapshot_relative = test.get("snapshot_path")
1118
+ inferred_status = changed_tests.get(project_relative, "existing")
1023
1119
  change_status = test.get("change_status", "modified" if isinstance(snapshot_relative, str) else "existing")
1120
+ if changed_tests:
1121
+ require(change_status == inferred_status, f"planned test change_status does not match canonical capture: {test_id}", errors)
1024
1122
  require(change_status in {"existing", "added", "modified"}, f"planned test has invalid change_status: {test_id}", errors)
1025
1123
  project_path = safe_path(project_root, project_relative, errors, f"planned test {test_id} project_path")
1026
1124
  require(project_path is not None and project_path.is_file(), f"planned test live path is missing: {test_id}", errors)
@@ -1043,6 +1141,19 @@ def validate_plan_and_scope_bindings(
1043
1141
  for receipt in matching_receipts:
1044
1142
  require(receipt.get("layer") in test_layers, f"planned test receipt uses an unplanned layer: {test_id}", errors)
1045
1143
 
1144
+ for relative, status in changed_tests.items():
1145
+ if relative in inventory_by_category.get("tests", {}) and relative not in planned_test_paths:
1146
+ errors.append(f"changed native test is not bound by test plan: {relative}")
1147
+ generated_dir = root / "generated-tests"
1148
+ referenced_snapshots = {
1149
+ test.get("snapshot_path") for test in tests if isinstance(test, dict) and isinstance(test.get("snapshot_path"), str)
1150
+ }
1151
+ if generated_dir.is_dir():
1152
+ actual_snapshots = {
1153
+ path.relative_to(root).as_posix() for path in generated_dir.rglob("*") if path.is_file()
1154
+ }
1155
+ for relative in sorted(actual_snapshots - referenced_snapshots):
1156
+ errors.append(f"generated test snapshot is not bound by test plan: {relative}")
1046
1157
  for ring in scope.get("regression_ring", []):
1047
1158
  if not isinstance(ring, dict):
1048
1159
  continue
@@ -1415,11 +1526,25 @@ def validate_evidence(
1415
1526
  entry_map = validate_manifest(root, manifest, manifest_schema, records_schema, errors, ignored_hash_paths)
1416
1527
  change_class = manifest.get("change_class")
1417
1528
  final_verdict = manifest.get("final_verdict")
1529
+ canonical_lifecycle = (root / "lifecycle.json").is_file()
1418
1530
  required = COMMON_RECORDS
1531
+ if canonical_lifecycle:
1532
+ required = required | {"change-capture.json"}
1419
1533
  if final_verdict != "TEST_BLOCKED":
1420
1534
  required = required | (BEHAVIOR_RECORDS if change_class == "behavior" else LIGHTWEIGHT_RECORDS)
1535
+ if canonical_lifecycle and change_class == "behavior":
1536
+ required = required | {"test-preparation.json"}
1421
1537
  for relative in required:
1422
1538
  require(relative in entry_map, f"required evidence record not hashed: {relative}", errors)
1539
+ if canonical_lifecycle:
1540
+ capture_path = root / "change-capture.json"
1541
+ if capture_path.is_file():
1542
+ try:
1543
+ capture = load_json(capture_path)
1544
+ validate_definition(capture, "changeCapture", records_schema, "change-capture", errors)
1545
+ validate_canonical_capture(root, project_root, capture, inventory, scope, errors)
1546
+ except EvidenceError as exc:
1547
+ errors.append(str(exc))
1423
1548
  validate_definition(classification, "changeClassification", records_schema, "change-classification", errors)
1424
1549
  validate_definition(scope, "scope", records_schema, "scope", errors)
1425
1550
  validate_definition(inventory, "targetInventory", records_schema, "target-inventory", errors)
@@ -1427,18 +1552,32 @@ def validate_evidence(
1427
1552
  require(classification.get("full_protocol_required") is (change_class == "behavior"), "change classification protocol decision is inconsistent", errors)
1428
1553
 
1429
1554
  plan: dict[str, Any] | None = None
1555
+ preparation: dict[str, Any] | None = None
1430
1556
  if change_class == "behavior":
1431
1557
  try:
1432
1558
  plan = load_json(root / "test-plan.json")
1559
+ if canonical_lifecycle:
1560
+ preparation = load_json(root / "test-preparation.json")
1433
1561
  except EvidenceError as exc:
1434
1562
  errors.append(str(exc))
1563
+ plan = preparation = None
1435
1564
  if isinstance(plan, dict):
1436
1565
  validate_definition(plan, "testPlan", records_schema, "test-plan", errors)
1437
1566
  else:
1438
1567
  errors.append("test-plan.json must be an object")
1439
1568
  plan = None
1569
+ if canonical_lifecycle:
1570
+ if isinstance(preparation, dict):
1571
+ pass
1572
+ else:
1573
+ errors.append("test-preparation.json must be an object")
1440
1574
 
1441
1575
  inventory_by_category, _ = validate_inventory(root, project_root, manifest, inventory, scope, plan, entry_map, errors)
1576
+ if canonical_lifecycle and change_class == "behavior" and isinstance(preparation, dict):
1577
+ validate_test_preparation(
1578
+ root, project_root, preparation, plan or {}, inventory_by_category,
1579
+ entry_map, records_schema, errors,
1580
+ )
1442
1581
  require(manifest.get("evidence_id_inputs", {}).get("scope_sha256") == canonical_sha256(scope), "scope identity hash mismatch", errors)
1443
1582
  validate_scope(
1444
1583
  scope, inventory, inventory_by_category, project_root, root,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.1.134",
3
+ "version": "1.1.135",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {