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.
- package/bundled/VERSION.json +3 -3
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit-test/SKILL.md +8 -9
- package/bundled/skills/prizmkit-test/assets/authoritative-records.schema.json +84 -2
- package/bundled/skills/prizmkit-test/assets/evidence-package-template.json +2 -0
- package/bundled/skills/prizmkit-test/references/evidence-protocol.md +2 -2
- package/bundled/skills/prizmkit-test/references/evidence-request-protocol.md +1 -1
- package/bundled/skills/prizmkit-test/references/test-generation-steps.md +2 -2
- package/bundled/skills/prizmkit-test/scripts/build_test_evidence.py +416 -20
- package/bundled/skills/prizmkit-test/scripts/validate_test_evidence.py +143 -4
- package/package.json +1 -1
package/bundled/VERSION.json
CHANGED
|
@@ -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
|
|
31
|
-
- `
|
|
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
|
-
|
|
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`.
|
|
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", "
|
|
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. `
|
|
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.
|
|
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
|
|
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,
|
|
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", "
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 =
|
|
367
|
-
known = {item.get("id") for item in tests
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
830
|
-
|
|
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", "
|
|
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",
|
|
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
|
|
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
|
-
|
|
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,
|