prizmkit 1.1.156 → 1.1.160
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled/VERSION.json +3 -3
- package/bundled/adapters/codebuddy/settings-adapter.js +76 -25
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +9 -2
- package/bundled/dev-pipeline/prizmkit_runtime/reset.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +20 -11
- package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +42 -15
- package/bundled/dev-pipeline/scripts/update-feature-status.py +7 -0
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +1 -1
- package/bundled/dev-pipeline/templates/sections/runtime-commit-handoff.md +6 -6
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +4 -3
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +2 -2
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +2 -2
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +115 -4
- package/bundled/dev-pipeline/tests/test_reset_modes.py +137 -0
- package/bundled/dev-pipeline/tests/test_runtime_commit.py +68 -13
- package/bundled/dev-pipeline/tests/test_unified_cli.py +12 -7
- package/bundled/rules/prizm/prizm-commit-workflow.md +7 -7
- package/bundled/rules/prizm/prizm-documentation.md +24 -65
- package/bundled/rules/prizm/prizm-progressive-loading.md +9 -8
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit/SKILL.md +1 -13
- package/bundled/skills/prizmkit-code-review/SKILL.md +29 -10
- package/bundled/skills/prizmkit-code-review/references/independent-code-review.md +5 -4
- package/bundled/skills/prizmkit-code-review/references/review-report-template.md +15 -0
- package/bundled/skills/prizmkit-code-review/scripts/render_review_report.py +61 -0
- package/bundled/skills/prizmkit-committer/SKILL.md +50 -38
- package/bundled/skills/prizmkit-deploy/SKILL.md +172 -198
- package/bundled/skills/prizmkit-deploy/references/capability-contract.md +137 -0
- package/bundled/skills/prizmkit-deploy/references/deploy-record-schema.json +1053 -0
- package/bundled/skills/prizmkit-deploy/references/deployment-declaration-schema.json +291 -0
- package/bundled/skills/prizmkit-deploy/references/legacy-migration.md +90 -0
- package/bundled/skills/prizmkit-deploy/references/policy-and-execution.md +170 -0
- package/bundled/skills/prizmkit-deploy/references/real-resource-test-policy.md +73 -0
- package/bundled/skills/prizmkit-deploy/references/verification-and-recovery.md +164 -0
- package/bundled/skills/prizmkit-deploy/scripts/validate-deploy-artifact.py +371 -0
- package/bundled/skills/prizmkit-implement/SKILL.md +2 -2
- package/bundled/skills/prizmkit-init/SKILL.md +38 -42
- package/bundled/skills/prizmkit-init/references/config-schema.md +11 -7
- package/bundled/skills/prizmkit-init/references/rules/layer-detection.md +3 -3
- package/bundled/skills/prizmkit-init/references/update-supplement.md +5 -5
- package/bundled/skills/prizmkit-plan/SKILL.md +5 -4
- package/bundled/skills/prizmkit-plan/assets/plan-template.md +4 -1
- package/bundled/skills/prizmkit-plan/references/verification-checklist.md +3 -2
- package/bundled/skills/prizmkit-prizm-docs/SKILL.md +17 -11
- package/bundled/skills/prizmkit-prizm-docs/assets/prizm-docs-format.md +95 -155
- package/bundled/skills/prizmkit-prizm-docs/references/op-init.md +6 -5
- package/bundled/skills/prizmkit-prizm-docs/references/op-rebuild.md +4 -2
- package/bundled/skills/prizmkit-prizm-docs/references/op-status.md +9 -10
- package/bundled/skills/prizmkit-prizm-docs/references/op-update.md +6 -5
- package/bundled/skills/prizmkit-prizm-docs/references/op-validate.md +13 -12
- package/bundled/skills/prizmkit-retrospective/SKILL.md +3 -3
- package/bundled/skills/prizmkit-test/SKILL.md +26 -13
- package/bundled/skills/prizmkit-test/references/boundary-contract-and-test-double-guidance.md +8 -4
- package/bundled/skills/prizmkit-test/references/independent-test-review.md +7 -3
- package/bundled/skills/prizmkit-test/references/test-coverage-model.md +33 -10
- package/bundled/skills/prizmkit-test/references/test-report-template.md +33 -11
- package/bundled/skills/prizmkit-workflow/SKILL.md +33 -3
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +23 -5
- package/bundled/templates/hooks/commit-intent-status.py +9 -41
- package/bundled/templates/hooks/commit-intent.json +3 -3
- package/bundled/templates/hooks/diff-prizm-docs.py +3 -1
- package/bundled/templates/hooks/prizm-pre-commit.py +32 -6
- package/bundled/templates/hooks/run-python-hook.cjs +44 -0
- package/bundled/templates/hooks/validate-prizm-docs.py +399 -173
- package/bundled/templates/project-memory-template.md +19 -19
- package/package.json +1 -1
- package/src/clean.js +6 -7
- package/src/gitignore-template.js +8 -12
- package/src/scaffold.js +125 -71
- package/bundled/skills/prizmkit/references/workflow-state-protocol.md +0 -178
- package/bundled/skills/prizmkit-deploy/assets/deploy-document-template.md +0 -89
- package/bundled/skills/prizmkit-deploy/references/ci-cd-workflows.md +0 -145
- package/bundled/skills/prizmkit-deploy/references/cloud-platform-deploy.md +0 -109
- package/bundled/skills/prizmkit-deploy/references/data-safety-examples.md +0 -120
- package/bundled/skills/prizmkit-deploy/references/database-setup.md +0 -65
- package/bundled/skills/prizmkit-deploy/references/deploy-config-schema.md +0 -150
- package/bundled/skills/prizmkit-deploy/references/deploy-history-schema.md +0 -78
- package/bundled/skills/prizmkit-deploy/references/deploy-metadata-schema.json +0 -49
- package/bundled/skills/prizmkit-deploy/references/deployment-modes.md +0 -50
- package/bundled/skills/prizmkit-deploy/references/direct-upload.md +0 -26
- package/bundled/skills/prizmkit-deploy/references/dns-setup.md +0 -42
- package/bundled/skills/prizmkit-deploy/references/docker-deploy.md +0 -31
- package/bundled/skills/prizmkit-deploy/references/firewall-setup.md +0 -37
- package/bundled/skills/prizmkit-deploy/references/live-validation-notes.md +0 -21
- package/bundled/skills/prizmkit-deploy/references/nginx-blue-green.md +0 -59
- package/bundled/skills/prizmkit-deploy/references/pending-input-schema.json +0 -164
- package/bundled/skills/prizmkit-deploy/references/ssh-adapter-flow.md +0 -221
- package/bundled/skills/prizmkit-deploy/references/ssh-bootstrap-flow.md +0 -49
- package/bundled/skills/prizmkit-deploy/references/ssh-execution-flow.md +0 -41
- package/bundled/skills/prizmkit-deploy/references/ssh-takeover.md +0 -20
- package/bundled/skills/prizmkit-deploy/references/ssl-setup.md +0 -56
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Deployment Verification and Recovery Contract
|
|
2
|
+
|
|
3
|
+
Use this contract after each planned step, after any failure or interruption, and before selecting a terminal status. Verification observes actual state; it does not infer success from a command exit, API acceptance, or user action alone.
|
|
4
|
+
|
|
5
|
+
## Layered Verification
|
|
6
|
+
|
|
7
|
+
Evaluate all five layers and record each named layer exactly once:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
PASSED, FAILED, INACCESSIBLE, NOT_APPLICABLE
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Layer | Evidence | Applicability rule |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `command_or_platform` | Exit/result plus target-native operation identity and completion state, not merely request acceptance | Applicable to every executed automated action; manual-only operations explain `NOT_APPLICABLE`. |
|
|
16
|
+
| `runtime_or_release` | Intended process/service/container/release exists, has expected identity/version, and is in the required state | Applicable when the operation creates, starts, stops, restarts, promotes, or rolls back a runtime/release. |
|
|
17
|
+
| `configured_health` | Every configured required project health check meets its explicit expectation | Applicable when a health boundary exists; absence is reported and never replaced with an invented endpoint. |
|
|
18
|
+
| `external_url` | Intended externally observed endpoint reaches the expected release/behavior through the real traffic path | Applicable when an external URL is configured and accessible from the controller boundary. |
|
|
19
|
+
| `startup_logs` | Bounded, redacted project-owned startup window contains no planned fatal condition | Applicable when startup output exists and access is authorized; specify time and/or line bound. |
|
|
20
|
+
|
|
21
|
+
For every layer store whether it is required and a concise redacted evidence statement. `INACCESSIBLE` and `NOT_APPLICABLE` are not passes. A required layer must be `PASSED` for `SUCCEEDED`; required `FAILED`, `INACCESSIBLE`, or `NOT_APPLICABLE` prevents success. Optional inaccessible or inapplicable layers remain visible in residual risk. Before persisting or trusting the result, validate the complete record with both `${SKILL_DIR}/references/deploy-record-schema.json` and `${SKILL_DIR}/scripts/validate-deploy-artifact.py --kind record` so omitted, duplicate, or contradictory layers fail closed.
|
|
22
|
+
|
|
23
|
+
A configured required health check failing after an otherwise successful command makes the operation `FAILED`. Do not downgrade it to a warning.
|
|
24
|
+
|
|
25
|
+
## Evidence Rules
|
|
26
|
+
|
|
27
|
+
- Re-observe state after the action; do not reuse pre-action evidence as proof of a postcondition.
|
|
28
|
+
- Bind release/runtime observations to the intended target profile and release identity.
|
|
29
|
+
- Distinguish controller result, target-control-plane result, and target workload state.
|
|
30
|
+
- Use actual configured ports/endpoints and target-native identities.
|
|
31
|
+
- Bound all output. Persist only concise summaries, never raw command output or full logs.
|
|
32
|
+
- Redact Secret-bearing URLs, headers, environment assignments, tokens, keys, cookies, connection strings, and user-identified patterns before display or storage.
|
|
33
|
+
- If evidence may be sensitive and reliable redaction is unavailable, mark the layer `INACCESSIBLE` and provide a manual verification action.
|
|
34
|
+
|
|
35
|
+
## Terminal Status Decision
|
|
36
|
+
|
|
37
|
+
Exactly one terminal status is returned:
|
|
38
|
+
|
|
39
|
+
| Status | Required condition |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `SUCCEEDED` | All planned steps completed, all required applicable verification layers are `PASSED`, no effect remains unknown, and the required sole-record update was verified. |
|
|
42
|
+
| `FAILED` | Confirmed execution occurred but a step, required verification layer, rollback attempt, cleanup required for correctness, or required local-record update failed. Report actual live state. |
|
|
43
|
+
| `BLOCKED` | Safe continuation cannot be established because capability/evidence/authorization is absent, input conflicts, plan validity changed, an unknown effect remains indeterminate, or destructive impact cannot be bounded. |
|
|
44
|
+
| `WAITING_USER` | Execution is safely paused for an exact confirmation, login, MFA, browser approval, manual action, or separately authorized test boundary. Minimal recovery is available when its write was confirmed. |
|
|
45
|
+
| `CANCELLED` | The operator cancelled; no further unconfirmed action ran, and confirmed safe cleanup plus residual-resource reporting completed. |
|
|
46
|
+
|
|
47
|
+
Partial completion is never `SUCCEEDED`. A release can be live while the operation is `FAILED`; say both. A control-plane “accepted,” queued, or zero-exit response is not success until required layers pass.
|
|
48
|
+
|
|
49
|
+
## Final Result
|
|
50
|
+
|
|
51
|
+
Return and, when confirmed, persist the complete terminal result defined by `${SKILL_DIR}/references/deploy-record-schema.json` and accepted by the shipped semantic validator:
|
|
52
|
+
|
|
53
|
+
- terminal status and operation;
|
|
54
|
+
- target profile and immutable plan identity;
|
|
55
|
+
- start/finish boundary and concise summary;
|
|
56
|
+
- observed live state and release identity;
|
|
57
|
+
- rollback availability: `VERIFIED`, `UNVERIFIED`, `ABSENT`, `UNKNOWN`, or `NOT_APPLICABLE`;
|
|
58
|
+
- every verification layer with required flag, status, and evidence;
|
|
59
|
+
- residual releases, processes, artifacts, locks, migrations, traffic state, and other project-owned resources;
|
|
60
|
+
- local-record update result;
|
|
61
|
+
- exact recovery guidance and relevant next operations.
|
|
62
|
+
|
|
63
|
+
Report inaccessible or inapplicable layers explicitly. Never fill missing evidence with `PASSED`.
|
|
64
|
+
|
|
65
|
+
## Failure Handling
|
|
66
|
+
|
|
67
|
+
At the first failed step or required layer:
|
|
68
|
+
|
|
69
|
+
1. stop dependent actions and plan expansion;
|
|
70
|
+
2. bound and redact the failure evidence;
|
|
71
|
+
3. observe current target/release/runtime/data/traffic state read-only;
|
|
72
|
+
4. classify every planned write as proven applied, proven not applied, or indeterminate;
|
|
73
|
+
5. perform only cleanup already authorized by the immutable plan and safe in the observed state;
|
|
74
|
+
6. determine rollback availability from current evidence;
|
|
75
|
+
7. select `FAILED`, `BLOCKED`, or `WAITING_USER` truthfully;
|
|
76
|
+
8. preserve residual resources needed for diagnosis unless confirmed cleanup says otherwise.
|
|
77
|
+
|
|
78
|
+
Never automatically replay an indeterminate write. Follow the `unknown_outcome_inspection` step. Only proven read-only or idempotent actions may use bounded retry.
|
|
79
|
+
|
|
80
|
+
### Failure Before Any Write
|
|
81
|
+
|
|
82
|
+
If a precondition or capability fails before execution, live state remains unchanged. Return `BLOCKED` when continuation lacks evidence/authorization or `FAILED` when confirmed execution itself failed. Do not create a misleading release/history event file.
|
|
83
|
+
|
|
84
|
+
### Failure After a Write
|
|
85
|
+
|
|
86
|
+
State the applied/unknown effects, current live release, health, traffic, data state, residual resources, and whether old/new units coexist. A healthy previous release does not erase a failed candidate operation.
|
|
87
|
+
|
|
88
|
+
### Failed Health
|
|
89
|
+
|
|
90
|
+
Required health failure always fails the operation. If traffic/live selection changed, inspect actual live state and apply only a pre-confirmed verified rollback; otherwise stop and offer the verified rollback or manual recovery.
|
|
91
|
+
|
|
92
|
+
### Local Record Failure
|
|
93
|
+
|
|
94
|
+
If the live target changed but the required `.prizmkit/deploy/deploy.json` update or read-back validation failed:
|
|
95
|
+
|
|
96
|
+
- return `FAILED`;
|
|
97
|
+
- report that the target may be live and local state is stale;
|
|
98
|
+
- preserve the candidate temporary/legacy evidence without overwriting uncertain files;
|
|
99
|
+
- require read-only target rediscovery before any later record repair or deployment;
|
|
100
|
+
- never replay the deployment to repair the record.
|
|
101
|
+
|
|
102
|
+
## Rollback
|
|
103
|
+
|
|
104
|
+
A rollback is `VERIFIED` only when current evidence establishes:
|
|
105
|
+
|
|
106
|
+
- exact prior release/configuration/data identity;
|
|
107
|
+
- required artifacts/state still exist and match the target;
|
|
108
|
+
- rollback action and authorization are available;
|
|
109
|
+
- compatibility with the current persistent-data state;
|
|
110
|
+
- rollback verification and restore-on-rollback-failure behavior.
|
|
111
|
+
|
|
112
|
+
`UNVERIFIED`, `UNKNOWN`, or `ABSENT` rollback is not offered as an automated safety guarantee.
|
|
113
|
+
|
|
114
|
+
A rollback may run automatically only when it was explicitly part of the confirmed immutable failure plan and its preconditions still match. Otherwise show the exact rollback plan/effects and obtain confirmation. Rollback itself uses all applicable verification layers. Failed rollback leaves the operation `FAILED` and reports both attempted states.
|
|
115
|
+
|
|
116
|
+
When rollback is absent, do not block a deploy that received exact `NO_RELIABLE_ROLLBACK` risk acceptance. On failure, provide live-state evidence, residual resources, the earliest safe manual stop point, and a concrete manual recovery procedure. Do not claim the old release can be restored.
|
|
117
|
+
|
|
118
|
+
## Interruption and Unknown Remote State
|
|
119
|
+
|
|
120
|
+
A process/session interruption does not reveal whether the target wrote state. On recovery:
|
|
121
|
+
|
|
122
|
+
1. validate the sole local record with both schema and semantic validation without dropping unknown data;
|
|
123
|
+
2. load only `plan_id`, Secret-free plan digest, operation/profile, last proven step, minimal confirmation receipts, pending checkpoint, and bounded observations from `recovery`;
|
|
124
|
+
3. rediscover project revision, target identity, authentication identity, tool capability/version, native configuration, actual ports, live/release/runtime/data/traffic state, health boundary, and rollback evidence affected by the plan;
|
|
125
|
+
4. recompile the complete candidate plan from current authoritative evidence and compare its Secret-free digest with the stored immutable digest; never reconstruct actions from history summaries;
|
|
126
|
+
5. inspect each possibly executed write before replay;
|
|
127
|
+
6. continue only from the first unproven step when earlier postconditions are proven and every required confirmation receipt matches the digest; when a receipt is absent or cannot be validated, request fresh confirmation;
|
|
128
|
+
7. compile and preview a new plan when identity, command/action, effect, risk, scope, or evidence assumption changed;
|
|
129
|
+
8. clear recovery only after a verified terminal result is safely recorded.
|
|
130
|
+
|
|
131
|
+
Do not reconstruct commands from history summaries. Do not infer “not applied” from a missing client response. Do not claim concurrent session safety; this procedure assumes one operator.
|
|
132
|
+
|
|
133
|
+
## Human Checkpoint Recovery
|
|
134
|
+
|
|
135
|
+
For login, MFA, browser approval, confirmation, or another manual action:
|
|
136
|
+
|
|
137
|
+
- record `WAITING_USER`, exact non-sensitive action, and expected postcondition;
|
|
138
|
+
- never store a password, token, challenge, browser cookie, one-time code, approval URL containing credentials, or raw authentication output;
|
|
139
|
+
- after the user reports completion, re-run the planned read-only identity/authorization/postcondition probes;
|
|
140
|
+
- remain `WAITING_USER` or become `BLOCKED` when revalidation fails;
|
|
141
|
+
- resume only when the immutable plan and target identity still match.
|
|
142
|
+
|
|
143
|
+
The user's “done” is a signal to revalidate, not verification itself.
|
|
144
|
+
|
|
145
|
+
## Bounded Retry
|
|
146
|
+
|
|
147
|
+
Retry only when the immutable plan identifies the action as proven read-only or idempotent, the error as transient, the maximum attempts as greater than the current attempt, and the stop condition as unmet. Report each attempt without a duration promise.
|
|
148
|
+
|
|
149
|
+
Never retry automatically after authentication denial, invalid input, migration uncertainty, destructive failure, required health failure, plan mismatch, or an indeterminate write.
|
|
150
|
+
|
|
151
|
+
## Deterministic Recovery Cases
|
|
152
|
+
|
|
153
|
+
| Observed case | Terminal/result behavior |
|
|
154
|
+
|---|---|
|
|
155
|
+
| Command result passed; required configured health failed | `FAILED`; report actual live state and verified rollback availability |
|
|
156
|
+
| External URL required but controller cannot access it | `FAILED` or `BLOCKED` according to whether execution occurred; layer is `INACCESSIBLE`, never `PASSED` |
|
|
157
|
+
| Startup logs do not exist for this target | Layer is `NOT_APPLICABLE` with reason |
|
|
158
|
+
| Login or browser approval is required | `WAITING_USER`; persist minimal checkpoint only after confirmed record write |
|
|
159
|
+
| Possible write timed out; inspection proves applied | Do not replay; continue verification from the proven postcondition |
|
|
160
|
+
| Possible write timed out; inspection proves not applied | Retry only within confirmed idempotent policy |
|
|
161
|
+
| Possible write timed out; inspection is indeterminate | `BLOCKED` or `WAITING_USER`; replay needs dedicated confirmation |
|
|
162
|
+
| Rollback is verified after failed required health | Offer or run only according to confirmed rollback policy, then verify rollback |
|
|
163
|
+
| Rollback is absent after partial release | `FAILED` with live state, residual resources, and manual recovery |
|
|
164
|
+
| Live release is healthy but sole-record write failed | `FAILED`; state target-live/local-record-stale and rediscover before repair |
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate non-sensitive semantic invariants for PrizmKit Deploy artifacts.
|
|
3
|
+
|
|
4
|
+
This companion validator is intentionally read-only and standard-library-only.
|
|
5
|
+
JSON Schema validation remains mandatory; this script enforces cross-field and
|
|
6
|
+
host-neutral path rules that are awkward to express or easy to regress in a
|
|
7
|
+
schema alone. Diagnostics contain stable codes and JSON paths, never values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import stat
|
|
16
|
+
import sys
|
|
17
|
+
import unicodedata
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
21
|
+
|
|
22
|
+
MAX_INPUT_BYTES = 1024 * 1024
|
|
23
|
+
MAX_ERRORS = 50
|
|
24
|
+
VERIFICATION_LAYERS = {
|
|
25
|
+
"command_or_platform",
|
|
26
|
+
"runtime_or_release",
|
|
27
|
+
"configured_health",
|
|
28
|
+
"external_url",
|
|
29
|
+
"startup_logs",
|
|
30
|
+
}
|
|
31
|
+
FORBIDDEN_FIELDS = {
|
|
32
|
+
"value",
|
|
33
|
+
"secret_value",
|
|
34
|
+
"password",
|
|
35
|
+
"token",
|
|
36
|
+
"private_key",
|
|
37
|
+
"connection_string",
|
|
38
|
+
"raw_output",
|
|
39
|
+
"raw_logs",
|
|
40
|
+
}
|
|
41
|
+
_SAFE_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
|
|
42
|
+
_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DuplicateKeyError(ValueError):
|
|
46
|
+
"""Raised for a duplicate object key without retaining its key or value."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, order=True)
|
|
50
|
+
class ValidationError:
|
|
51
|
+
code: str
|
|
52
|
+
path: str
|
|
53
|
+
|
|
54
|
+
def as_dict(self) -> dict[str, str]:
|
|
55
|
+
return {"code": self.code, "path": self.path}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ErrorCollector:
|
|
59
|
+
"""Collect a bounded deterministic set of non-sensitive diagnostics."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self._errors: set[ValidationError] = set()
|
|
63
|
+
|
|
64
|
+
def add(self, code: str, path: str) -> None:
|
|
65
|
+
if len(self._errors) >= MAX_ERRORS:
|
|
66
|
+
return
|
|
67
|
+
self._errors.add(ValidationError(code=code, path=path))
|
|
68
|
+
|
|
69
|
+
def result(self) -> list[dict[str, str]]:
|
|
70
|
+
return [error.as_dict() for error in sorted(self._errors)]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strict_object(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]:
|
|
74
|
+
result: dict[str, Any] = {}
|
|
75
|
+
for key, value in pairs:
|
|
76
|
+
if key in result:
|
|
77
|
+
raise DuplicateKeyError("duplicate JSON key")
|
|
78
|
+
result[key] = value
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_json(path: Path) -> tuple[Any | None, list[dict[str, str]]]:
|
|
83
|
+
try:
|
|
84
|
+
metadata = path.lstat()
|
|
85
|
+
except OSError:
|
|
86
|
+
return None, [{"code": "INPUT_UNREADABLE", "path": "$"}]
|
|
87
|
+
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
|
88
|
+
return None, [{"code": "INPUT_NOT_REGULAR_FILE", "path": "$"}]
|
|
89
|
+
if metadata.st_size > MAX_INPUT_BYTES:
|
|
90
|
+
return None, [{"code": "INPUT_TOO_LARGE", "path": "$"}]
|
|
91
|
+
try:
|
|
92
|
+
content = path.read_text(encoding="utf-8")
|
|
93
|
+
return json.loads(content, object_pairs_hook=_strict_object), []
|
|
94
|
+
except DuplicateKeyError:
|
|
95
|
+
return None, [{"code": "DUPLICATE_KEY", "path": "$"}]
|
|
96
|
+
except (OSError, UnicodeError, json.JSONDecodeError, RecursionError):
|
|
97
|
+
return None, [{"code": "INVALID_JSON", "path": "$"}]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_project_relative_path(value: Any, *, allow_root: bool = False) -> bool:
|
|
101
|
+
if not isinstance(value, str) or not value or len(value) > 512:
|
|
102
|
+
return False
|
|
103
|
+
if value == ".":
|
|
104
|
+
return allow_root
|
|
105
|
+
if value.startswith("/") or _DRIVE_PREFIX.match(value) or "\\" in value:
|
|
106
|
+
return False
|
|
107
|
+
if any(unicodedata.category(character) in {"Cc", "Cs"} for character in value):
|
|
108
|
+
return False
|
|
109
|
+
parts = value.split("/")
|
|
110
|
+
return all(part not in {"", ".", ".."} for part in parts)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_legacy_deploy_path(value: Any) -> bool:
|
|
114
|
+
return _is_project_relative_path(value) and str(value).startswith(".prizmkit/deploy/")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _iter_list(value: Any) -> Iterable[tuple[int, Any]]:
|
|
118
|
+
if not isinstance(value, list):
|
|
119
|
+
return ()
|
|
120
|
+
return enumerate(value)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _check_path_list(
|
|
124
|
+
value: Any,
|
|
125
|
+
path: str,
|
|
126
|
+
errors: ErrorCollector,
|
|
127
|
+
*,
|
|
128
|
+
legacy: bool = False,
|
|
129
|
+
) -> None:
|
|
130
|
+
predicate = _is_legacy_deploy_path if legacy else _is_project_relative_path
|
|
131
|
+
for index, item in _iter_list(value):
|
|
132
|
+
if not predicate(item):
|
|
133
|
+
errors.add("UNSAFE_MANAGED_PATH", f"{path}[{index}]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_forbidden_fields(value: Any, path: str, errors: ErrorCollector) -> None:
|
|
137
|
+
pending: list[tuple[Any, str]] = [(value, path)]
|
|
138
|
+
while pending:
|
|
139
|
+
current, current_path = pending.pop()
|
|
140
|
+
if isinstance(current, Mapping):
|
|
141
|
+
for key, child in current.items():
|
|
142
|
+
child_path = (
|
|
143
|
+
f"{current_path}.{key}"
|
|
144
|
+
if _SAFE_KEY.fullmatch(str(key))
|
|
145
|
+
else f"{current_path}.[unknown]"
|
|
146
|
+
)
|
|
147
|
+
if str(key).lower() in FORBIDDEN_FIELDS:
|
|
148
|
+
errors.add("FORBIDDEN_SENSITIVE_FIELD", child_path)
|
|
149
|
+
pending.append((child, child_path))
|
|
150
|
+
elif isinstance(current, list):
|
|
151
|
+
pending.extend(
|
|
152
|
+
(child, f"{current_path}[{index}]")
|
|
153
|
+
for index, child in enumerate(current)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_unique_names(
|
|
158
|
+
value: Any,
|
|
159
|
+
path: str,
|
|
160
|
+
field: str,
|
|
161
|
+
code: str,
|
|
162
|
+
errors: ErrorCollector,
|
|
163
|
+
) -> set[str]:
|
|
164
|
+
seen: set[str] = set()
|
|
165
|
+
for index, item in _iter_list(value):
|
|
166
|
+
if not isinstance(item, Mapping):
|
|
167
|
+
continue
|
|
168
|
+
identity = item.get(field)
|
|
169
|
+
if not isinstance(identity, str):
|
|
170
|
+
continue
|
|
171
|
+
if identity in seen:
|
|
172
|
+
errors.add(code, f"{path}[{index}].{field}")
|
|
173
|
+
seen.add(identity)
|
|
174
|
+
return seen
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _check_profile_reference(
|
|
178
|
+
value: Any,
|
|
179
|
+
path: str,
|
|
180
|
+
profiles: set[str],
|
|
181
|
+
errors: ErrorCollector,
|
|
182
|
+
) -> None:
|
|
183
|
+
if value is not None and isinstance(value, str) and value not in profiles:
|
|
184
|
+
errors.add("DANGLING_PROFILE_REFERENCE", path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _check_terminal_result(value: Any, path: str, errors: ErrorCollector) -> None:
|
|
188
|
+
if not isinstance(value, Mapping):
|
|
189
|
+
return
|
|
190
|
+
verification = value.get("verification")
|
|
191
|
+
if not isinstance(verification, list):
|
|
192
|
+
errors.add("VERIFICATION_LAYER_SET", f"{path}.verification")
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
layers: list[str] = []
|
|
196
|
+
for item in verification:
|
|
197
|
+
if isinstance(item, Mapping) and isinstance(item.get("layer"), str):
|
|
198
|
+
layers.append(item["layer"])
|
|
199
|
+
if len(verification) != len(VERIFICATION_LAYERS) or set(layers) != VERIFICATION_LAYERS:
|
|
200
|
+
errors.add("VERIFICATION_LAYER_SET", f"{path}.verification")
|
|
201
|
+
if len(layers) != len(set(layers)):
|
|
202
|
+
errors.add("DUPLICATE_VERIFICATION_LAYER", f"{path}.verification")
|
|
203
|
+
|
|
204
|
+
if value.get("status") == "SUCCEEDED":
|
|
205
|
+
for index, item in _iter_list(verification):
|
|
206
|
+
if (
|
|
207
|
+
isinstance(item, Mapping)
|
|
208
|
+
and item.get("required") is True
|
|
209
|
+
and item.get("status") != "PASSED"
|
|
210
|
+
):
|
|
211
|
+
errors.add(
|
|
212
|
+
"SUCCEEDED_REQUIRED_LAYER_NOT_PASSED",
|
|
213
|
+
f"{path}.verification[{index}]",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _validate_record(data: Any, errors: ErrorCollector) -> None:
|
|
218
|
+
if not isinstance(data, Mapping):
|
|
219
|
+
errors.add("ROOT_NOT_OBJECT", "$")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
project = data.get("project")
|
|
223
|
+
if isinstance(project, Mapping):
|
|
224
|
+
_check_path_list(
|
|
225
|
+
project.get("native_config_files"),
|
|
226
|
+
"$.project.native_config_files",
|
|
227
|
+
errors,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
profiles_value = data.get("profiles")
|
|
231
|
+
profiles = _check_unique_names(
|
|
232
|
+
profiles_value,
|
|
233
|
+
"$.profiles",
|
|
234
|
+
"id",
|
|
235
|
+
"DUPLICATE_PROFILE_ID",
|
|
236
|
+
errors,
|
|
237
|
+
)
|
|
238
|
+
for index, profile in _iter_list(profiles_value):
|
|
239
|
+
if isinstance(profile, Mapping):
|
|
240
|
+
_check_path_list(
|
|
241
|
+
profile.get("native_config_files"),
|
|
242
|
+
f"$.profiles[{index}].native_config_files",
|
|
243
|
+
errors,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
_check_unique_names(
|
|
247
|
+
data.get("secret_references"),
|
|
248
|
+
"$.secret_references",
|
|
249
|
+
"name",
|
|
250
|
+
"DUPLICATE_SECRET_REFERENCE",
|
|
251
|
+
errors,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
current = data.get("current")
|
|
255
|
+
if isinstance(current, Mapping):
|
|
256
|
+
_check_profile_reference(
|
|
257
|
+
current.get("profile_id"),
|
|
258
|
+
"$.current.profile_id",
|
|
259
|
+
profiles,
|
|
260
|
+
errors,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
latest = data.get("latest_result")
|
|
264
|
+
if isinstance(latest, Mapping):
|
|
265
|
+
_check_profile_reference(
|
|
266
|
+
latest.get("profile_id"),
|
|
267
|
+
"$.latest_result.profile_id",
|
|
268
|
+
profiles,
|
|
269
|
+
errors,
|
|
270
|
+
)
|
|
271
|
+
_check_terminal_result(latest, "$.latest_result", errors)
|
|
272
|
+
|
|
273
|
+
for index, history in _iter_list(data.get("history")):
|
|
274
|
+
if isinstance(history, Mapping):
|
|
275
|
+
_check_profile_reference(
|
|
276
|
+
history.get("profile_id"),
|
|
277
|
+
f"$.history[{index}].profile_id",
|
|
278
|
+
profiles,
|
|
279
|
+
errors,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
recovery = data.get("recovery")
|
|
283
|
+
if isinstance(recovery, Mapping):
|
|
284
|
+
_check_profile_reference(
|
|
285
|
+
recovery.get("profile_id"),
|
|
286
|
+
"$.recovery.profile_id",
|
|
287
|
+
profiles,
|
|
288
|
+
errors,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
legacy = data.get("legacy_migration")
|
|
292
|
+
if isinstance(legacy, Mapping):
|
|
293
|
+
sources = legacy.get("sources")
|
|
294
|
+
for index, source in _iter_list(sources):
|
|
295
|
+
if isinstance(source, Mapping) and not _is_legacy_deploy_path(source.get("path")):
|
|
296
|
+
errors.add("UNSAFE_MANAGED_PATH", f"$.legacy_migration.sources[{index}].path")
|
|
297
|
+
_check_path_list(
|
|
298
|
+
legacy.get("preserved_paths"),
|
|
299
|
+
"$.legacy_migration.preserved_paths",
|
|
300
|
+
errors,
|
|
301
|
+
legacy=True,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _validate_declaration(data: Any, errors: ErrorCollector) -> None:
|
|
306
|
+
if not isinstance(data, Mapping):
|
|
307
|
+
errors.add("ROOT_NOT_OBJECT", "$")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
_check_path_list(data.get("native_config_files"), "$.native_config_files", errors)
|
|
311
|
+
_check_unique_names(
|
|
312
|
+
data.get("profile_hints"),
|
|
313
|
+
"$.profile_hints",
|
|
314
|
+
"id",
|
|
315
|
+
"DUPLICATE_PROFILE_ID",
|
|
316
|
+
errors,
|
|
317
|
+
)
|
|
318
|
+
_check_unique_names(
|
|
319
|
+
data.get("secret_references"),
|
|
320
|
+
"$.secret_references",
|
|
321
|
+
"name",
|
|
322
|
+
"DUPLICATE_SECRET_REFERENCE",
|
|
323
|
+
errors,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
for index, command in _iter_list(data.get("commands")):
|
|
327
|
+
if (
|
|
328
|
+
isinstance(command, Mapping)
|
|
329
|
+
and not _is_project_relative_path(command.get("working_directory"), allow_root=True)
|
|
330
|
+
):
|
|
331
|
+
errors.add("UNSAFE_MANAGED_PATH", f"$.commands[{index}].working_directory")
|
|
332
|
+
|
|
333
|
+
for index, hint in _iter_list(data.get("capability_hints")):
|
|
334
|
+
if isinstance(hint, Mapping):
|
|
335
|
+
_check_path_list(
|
|
336
|
+
hint.get("evidence_files"),
|
|
337
|
+
f"$.capability_hints[{index}].evidence_files",
|
|
338
|
+
errors,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def validate(kind: str, data: Any) -> list[dict[str, str]]:
|
|
343
|
+
errors = ErrorCollector()
|
|
344
|
+
_check_forbidden_fields(data, "$", errors)
|
|
345
|
+
if kind == "record":
|
|
346
|
+
_validate_record(data, errors)
|
|
347
|
+
else:
|
|
348
|
+
_validate_declaration(data, errors)
|
|
349
|
+
return errors.result()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parser() -> argparse.ArgumentParser:
|
|
353
|
+
parser = argparse.ArgumentParser(
|
|
354
|
+
description="Validate semantic invariants for one PrizmKit Deploy artifact."
|
|
355
|
+
)
|
|
356
|
+
parser.add_argument("--kind", choices=("record", "declaration"), required=True)
|
|
357
|
+
parser.add_argument("path", type=Path)
|
|
358
|
+
return parser
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
362
|
+
args = _parser().parse_args(argv)
|
|
363
|
+
data, load_errors = _load_json(args.path)
|
|
364
|
+
errors = load_errors or validate(args.kind, data)
|
|
365
|
+
output = {"valid": not errors, "errors": errors}
|
|
366
|
+
print(json.dumps(output, ensure_ascii=False, separators=(",", ":")))
|
|
367
|
+
return 0 if not errors else 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
raise SystemExit(main())
|
|
@@ -36,8 +36,8 @@ Before editing:
|
|
|
36
36
|
|
|
37
37
|
1. Read `plan.md`, `spec.md`, and only relevant companion artifacts.
|
|
38
38
|
2. Read `.prizmkit/prizm-docs/root.prizm` when present.
|
|
39
|
-
3. Follow its pointers to only the relevant module and detail documentation.
|
|
40
|
-
4. If relevant detail documentation is missing,
|
|
39
|
+
3. Follow its pointers to only the relevant direct-child module index and nested detail documentation. Before modifying source, read the complete relevant detail plus the complete parent/child documents needed to resolve its pointers; grep-only fragments are insufficient modification context.
|
|
40
|
+
4. If relevant detail documentation is missing, inspect only bounded target source files and narrowly implicated callers/contracts as fallback, record that detailed context was unavailable, and proceed without creating a placeholder. Context loading never creates or modifies documentation.
|
|
41
41
|
5. Read referenced layer rules when present. If a rule conflicts with the plan, stop and ask the user unless the plan clearly supersedes the rule.
|
|
42
42
|
|
|
43
43
|
## Optional Inline Delegation
|