prizmkit 1.1.159 → 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/runtime_commit.py +42 -15
- 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_runtime_commit.py +68 -13
- package/bundled/dev-pipeline/tests/test_unified_cli.py +10 -6
- 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-code-review/SKILL.md +1 -1
- package/bundled/skills/prizmkit-committer/SKILL.md +48 -41
- 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 +17 -17
- package/bundled/skills/prizmkit-init/references/update-supplement.md +5 -5
- 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 +5 -7
- package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +5 -6
- 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-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
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Validate
|
|
2
|
+
"""Validate canonical Prizm v4 documentation snapshots."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import re
|
|
7
7
|
import subprocess
|
|
8
8
|
import sys
|
|
9
|
+
import unicodedata
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from functools import cmp_to_key
|
|
11
12
|
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Mapping
|
|
12
14
|
|
|
13
15
|
DOCS_ROOT = Path(".prizmkit/prizm-docs")
|
|
14
|
-
|
|
16
|
+
DOCS_ROOT_POSIX = PurePosixPath(DOCS_ROOT.as_posix())
|
|
17
|
+
ROOT_PATH = f"{DOCS_ROOT.as_posix()}/root.prizm"
|
|
15
18
|
VALID_MODES = {"--all", "--staged"}
|
|
16
19
|
|
|
17
20
|
AUXILIARY_METADATA_RE = re.compile(
|
|
@@ -75,55 +78,44 @@ BRANCH_SIMPLE_FIELD_RE = re.compile(
|
|
|
75
78
|
r"[ \t]*(#.*)?$",
|
|
76
79
|
re.IGNORECASE | re.MULTILINE,
|
|
77
80
|
)
|
|
81
|
+
HEADER_RE = re.compile(r"^([A-Z][A-Z0-9_]*):(.*)$")
|
|
82
|
+
CONCERN_RE = re.compile(r"^(.+?)\s*\|\s*SLUG:\s*([^\s|]+)\s*$")
|
|
83
|
+
DETAILS_ENTRY_RE = re.compile(r"^\s*-\s+([^:]+):\s+.+?\s+->\s+`?([^`\s]+)`?\s*$")
|
|
84
|
+
TRAP_RE = re.compile(r"^-\s+(?:\[REVIEW\])?\[(CRITICAL|HIGH|LOW)\]\s+.+\|\s+FIX:\s+.+")
|
|
85
|
+
|
|
86
|
+
ROOT_REQUIRED = {"PRIZM_VERSION", "PROJECT", "LANG", "RULES"}
|
|
87
|
+
L1_REQUIRED = {"MODULE", "FILES", "RESPONSIBILITY", "DEPENDENCIES"}
|
|
88
|
+
L1_PROHIBITED = {"CONCERN", "INTERFACES", "DATA_FLOW", "TRAPS", "DECISIONS"}
|
|
89
|
+
L2_REQUIRED = {
|
|
90
|
+
"MODULE", "FILES", "RESPONSIBILITY", "INTERFACES", "KEY_FILES", "DEPENDENCIES", "TRAPS",
|
|
91
|
+
}
|
|
92
|
+
SEMANTIC_REQUIRED = L2_REQUIRED | {"CONCERN", "DATA_FLOW", "RULES", "DECISIONS"}
|
|
78
93
|
|
|
79
94
|
|
|
80
95
|
def emit_error(file_path: str, message: str) -> None:
|
|
81
96
|
print(f"ERROR: {file_path} {message}", file=sys.stderr)
|
|
82
97
|
|
|
83
98
|
|
|
84
|
-
def run_git(args: list[str], *,
|
|
99
|
+
def run_git(args: list[str], *, binary: bool = False) -> subprocess.CompletedProcess:
|
|
85
100
|
return subprocess.run(
|
|
86
101
|
["git", *args],
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
stdout=subprocess.PIPE,
|
|
103
|
+
stderr=subprocess.PIPE,
|
|
104
|
+
text=not binary,
|
|
105
|
+
encoding=None if binary else "utf-8",
|
|
106
|
+
errors=None if binary else "replace",
|
|
92
107
|
check=False,
|
|
93
108
|
)
|
|
94
109
|
|
|
95
110
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def read_check_content(file_path: str, mode: str) -> tuple[bytes, str] | None:
|
|
107
|
-
if mode == "--staged":
|
|
108
|
-
exists = run_git(["cat-file", "-e", f":{file_path}"], capture=False)
|
|
109
|
-
if exists.returncode != 0:
|
|
110
|
-
return None
|
|
111
|
-
blob = subprocess.run(
|
|
112
|
-
["git", "show", f":{file_path}"],
|
|
113
|
-
stdout=subprocess.PIPE,
|
|
114
|
-
stderr=subprocess.DEVNULL,
|
|
115
|
-
check=False,
|
|
116
|
-
)
|
|
117
|
-
if blob.returncode != 0:
|
|
118
|
-
return None
|
|
119
|
-
raw = blob.stdout
|
|
120
|
-
else:
|
|
121
|
-
path = Path(file_path)
|
|
122
|
-
if not path.is_file():
|
|
123
|
-
return None
|
|
124
|
-
raw = path.read_bytes()
|
|
125
|
-
|
|
126
|
-
return raw, raw.decode("utf-8", errors="replace")
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Document:
|
|
113
|
+
path: str
|
|
114
|
+
raw: bytes
|
|
115
|
+
text: str
|
|
116
|
+
level: str
|
|
117
|
+
sections: Mapping[str, tuple[str, ...]]
|
|
118
|
+
fields: Mapping[str, str]
|
|
127
119
|
|
|
128
120
|
|
|
129
121
|
@dataclass(frozen=True)
|
|
@@ -138,48 +130,40 @@ class CapacityDiagnostic:
|
|
|
138
130
|
actions: str
|
|
139
131
|
|
|
140
132
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Pointer:
|
|
135
|
+
source: str
|
|
136
|
+
section: str
|
|
137
|
+
line: str
|
|
138
|
+
target: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def document_level(file_path: str) -> str:
|
|
142
|
+
path = PurePosixPath(file_path)
|
|
143
|
+
if path == PurePosixPath(ROOT_PATH):
|
|
144
|
+
return "L0"
|
|
145
|
+
if path.parent == DOCS_ROOT_POSIX:
|
|
146
|
+
return "L1"
|
|
147
|
+
return "L2"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def size_policy(level: str) -> tuple[int, str]:
|
|
151
|
+
if level == "L0":
|
|
152
|
+
return 4096, (
|
|
153
|
+
"trim: lower-priority global wording; deduplicate: repeated RULES or module summaries; "
|
|
154
|
+
"move-to-child: module detail into direct L1; semantic-split: use capacity-safe MODULE_GROUPS"
|
|
159
155
|
)
|
|
160
|
-
if
|
|
161
|
-
return (
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
"trim: low-value module summaries; "
|
|
165
|
-
"deduplicate: repeated interfaces, rules, or traps; "
|
|
166
|
-
"move-to-child: implementation detail into an L2 document; "
|
|
167
|
-
"semantic-split: distinct submodules into focused L2 documents",
|
|
156
|
+
if level == "L1":
|
|
157
|
+
return 4096, (
|
|
158
|
+
"trim: low-value module summaries; deduplicate: repeated structure or RULES; "
|
|
159
|
+
"move-to-child: behavioral detail into nested L2; semantic-split: stable concerns into sibling L2 documents"
|
|
168
160
|
)
|
|
169
|
-
return (
|
|
170
|
-
|
|
171
|
-
"L2"
|
|
172
|
-
"trim: stale or derivable detail; "
|
|
173
|
-
"deduplicate: repeated implementation notes; "
|
|
174
|
-
"move-to-child: one coherent detail set into a focused child document; "
|
|
175
|
-
"semantic-split: unrelated responsibilities into focused L2 documents",
|
|
161
|
+
return 5120, (
|
|
162
|
+
"trim: stale or derivable detail; deduplicate: repeated meanings; "
|
|
163
|
+
"move-to-child: not applicable because L2 is terminal; semantic-split: unrelated stable concerns into sibling L2 documents"
|
|
176
164
|
)
|
|
177
165
|
|
|
178
166
|
|
|
179
|
-
def ceil_percent(limit: int, percent: int) -> int:
|
|
180
|
-
return (limit * percent + 99) // 100
|
|
181
|
-
|
|
182
|
-
|
|
183
167
|
def capacity_band(size: int, limit: int) -> str:
|
|
184
168
|
if size > limit:
|
|
185
169
|
return "error"
|
|
@@ -190,15 +174,19 @@ def capacity_band(size: int, limit: int) -> str:
|
|
|
190
174
|
return "normal"
|
|
191
175
|
|
|
192
176
|
|
|
193
|
-
def
|
|
194
|
-
limit
|
|
195
|
-
|
|
177
|
+
def ceil_percent(limit: int, percent: int) -> int:
|
|
178
|
+
return (limit * percent + 99) // 100
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def capacity_diagnostic(document: Document) -> CapacityDiagnostic | None:
|
|
182
|
+
limit, actions = size_policy(document.level)
|
|
183
|
+
band = capacity_band(len(document.raw), limit)
|
|
196
184
|
if band == "normal":
|
|
197
185
|
return None
|
|
198
186
|
return CapacityDiagnostic(
|
|
199
|
-
file_path=
|
|
200
|
-
level=level,
|
|
201
|
-
size=
|
|
187
|
+
file_path=document.path,
|
|
188
|
+
level=document.level,
|
|
189
|
+
size=len(document.raw),
|
|
202
190
|
limit=limit,
|
|
203
191
|
band=band,
|
|
204
192
|
target_low=ceil_percent(limit, 80),
|
|
@@ -210,15 +198,9 @@ def capacity_diagnostic(file_path: str, size: int) -> CapacityDiagnostic | None:
|
|
|
210
198
|
def compare_capacity(left: CapacityDiagnostic, right: CapacityDiagnostic) -> int:
|
|
211
199
|
left_ratio = left.size * right.limit
|
|
212
200
|
right_ratio = right.size * left.limit
|
|
213
|
-
if left_ratio
|
|
214
|
-
return -1
|
|
215
|
-
if
|
|
216
|
-
return 1
|
|
217
|
-
if left.file_path < right.file_path:
|
|
218
|
-
return -1
|
|
219
|
-
if left.file_path > right.file_path:
|
|
220
|
-
return 1
|
|
221
|
-
return 0
|
|
201
|
+
if left_ratio != right_ratio:
|
|
202
|
+
return -1 if left_ratio > right_ratio else 1
|
|
203
|
+
return -1 if left.file_path < right.file_path else (1 if left.file_path > right.file_path else 0)
|
|
222
204
|
|
|
223
205
|
|
|
224
206
|
def utilization_percent(size: int, limit: int) -> str:
|
|
@@ -227,71 +209,114 @@ def utilization_percent(size: int, limit: int) -> str:
|
|
|
227
209
|
|
|
228
210
|
|
|
229
211
|
def emit_capacity(diagnostic: CapacityDiagnostic) -> None:
|
|
230
|
-
severity = {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"error": "ERROR",
|
|
234
|
-
}[diagnostic.band]
|
|
212
|
+
severity = {"warning": "WARNING", "strong-warning": "STRONG WARNING", "error": "ERROR"}[
|
|
213
|
+
diagnostic.band
|
|
214
|
+
]
|
|
235
215
|
print(
|
|
236
|
-
f"{severity}: {diagnostic.file_path} capacity "
|
|
237
|
-
f"
|
|
216
|
+
f"{severity}: {diagnostic.file_path} capacity level={diagnostic.level} "
|
|
217
|
+
f"bytes={diagnostic.size}B limit={diagnostic.limit}B "
|
|
238
218
|
f"utilization={diagnostic.size}/{diagnostic.limit} "
|
|
239
|
-
f"({utilization_percent(diagnostic.size, diagnostic.limit)}) "
|
|
240
|
-
f"band={diagnostic.band} "
|
|
219
|
+
f"({utilization_percent(diagnostic.size, diagnostic.limit)}) band={diagnostic.band} "
|
|
241
220
|
f"target={diagnostic.target_low}-{diagnostic.target_high}B (80% to <90%) "
|
|
242
221
|
f"actions={diagnostic.actions}",
|
|
243
222
|
file=sys.stderr,
|
|
244
223
|
)
|
|
245
224
|
|
|
246
225
|
|
|
247
|
-
def
|
|
248
|
-
|
|
249
|
-
|
|
226
|
+
def _is_doc_path(value: str) -> bool:
|
|
227
|
+
normalized = value.strip().replace("\\", "/")
|
|
228
|
+
return normalized.startswith(f"{DOCS_ROOT.as_posix()}/") and normalized.endswith(".prizm")
|
|
250
229
|
|
|
251
|
-
def validate_file(file_path: str, raw: bytes, text: str) -> tuple[int, CapacityDiagnostic | None]:
|
|
252
|
-
errors = 0
|
|
253
230
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
231
|
+
def collect_snapshot(mode: str) -> tuple[dict[str, bytes], bool, str]:
|
|
232
|
+
if mode == "--all":
|
|
233
|
+
active = DOCS_ROOT.exists()
|
|
234
|
+
snapshot = {
|
|
235
|
+
path.as_posix(): path.read_bytes()
|
|
236
|
+
for path in sorted(DOCS_ROOT.rglob("*.prizm"))
|
|
237
|
+
if path.is_file()
|
|
238
|
+
} if active else {}
|
|
239
|
+
return snapshot, active, ""
|
|
240
|
+
|
|
241
|
+
changed = run_git(["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMRD"])
|
|
242
|
+
if changed.returncode != 0:
|
|
243
|
+
return {}, True, "git_index_unavailable"
|
|
244
|
+
changed_docs = [path for path in changed.stdout.split("\0") if _is_doc_path(path)]
|
|
245
|
+
if not changed_docs:
|
|
246
|
+
return {}, False, ""
|
|
247
|
+
|
|
248
|
+
indexed = run_git(["ls-files", "--cached", "-z", "--", DOCS_ROOT.as_posix()])
|
|
249
|
+
if indexed.returncode != 0:
|
|
250
|
+
return {}, True, "git_index_unavailable"
|
|
251
|
+
snapshot: dict[str, bytes] = {}
|
|
252
|
+
for file_path in indexed.stdout.split("\0"):
|
|
253
|
+
if not _is_doc_path(file_path):
|
|
254
|
+
continue
|
|
255
|
+
blob = run_git(["show", f":{file_path}"], binary=True)
|
|
256
|
+
if blob.returncode != 0:
|
|
257
|
+
return {}, True, f"git_index_blob_unavailable:{file_path}"
|
|
258
|
+
snapshot[file_path] = blob.stdout
|
|
259
|
+
return snapshot, True, ""
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def parse_document(file_path: str, raw: bytes) -> tuple[Document, list[str]]:
|
|
263
|
+
errors: list[str] = []
|
|
264
|
+
try:
|
|
265
|
+
text = raw.decode("utf-8")
|
|
266
|
+
except UnicodeDecodeError:
|
|
267
|
+
text = raw.decode("utf-8", errors="replace")
|
|
268
|
+
errors.append("is not valid UTF-8")
|
|
269
|
+
|
|
270
|
+
fields: dict[str, str] = {}
|
|
271
|
+
sections: dict[str, list[str]] = {}
|
|
272
|
+
current_section = ""
|
|
273
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
274
|
+
stripped = line.strip()
|
|
275
|
+
if not stripped:
|
|
276
|
+
continue
|
|
277
|
+
header = HEADER_RE.match(line)
|
|
278
|
+
if header:
|
|
279
|
+
current_section = header.group(1)
|
|
280
|
+
fields.setdefault(current_section, header.group(2).strip())
|
|
281
|
+
sections.setdefault(current_section, [])
|
|
282
|
+
continue
|
|
283
|
+
if stripped.startswith("- "):
|
|
284
|
+
if not current_section:
|
|
285
|
+
errors.append(f"line {line_number} is a list item outside a section")
|
|
286
|
+
else:
|
|
287
|
+
sections.setdefault(current_section, []).append(stripped)
|
|
288
|
+
continue
|
|
289
|
+
if line.startswith((" ", "\t")) and re.match(r"^[ \t]+[A-Za-z0-9_-]+:\s*$", line):
|
|
290
|
+
if current_section != "MODULE_GROUPS":
|
|
291
|
+
errors.append(f"line {line_number} has a nested key outside MODULE_GROUPS")
|
|
292
|
+
continue
|
|
293
|
+
errors.append(f"line {line_number} is not KEY: value or a section list item")
|
|
257
294
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
errors += 1
|
|
295
|
+
frozen_sections = {key: tuple(value) for key, value in sections.items()}
|
|
296
|
+
return Document(file_path, raw, text, document_level(file_path), frozen_sections, fields), errors
|
|
261
297
|
|
|
262
|
-
base_name = PurePosixPath(file_path).name.lower()
|
|
263
|
-
if base_name in {"changelog.prizm", "changelog-archive.prizm"} or (
|
|
264
|
-
"changelog" in base_name and base_name.endswith(".prizm")
|
|
265
|
-
):
|
|
266
|
-
emit_error(file_path, "is auxiliary history. Use git history instead of changelog .prizm files.")
|
|
267
|
-
errors += 1
|
|
268
298
|
|
|
299
|
+
def _metadata_errors(document: Document) -> list[str]:
|
|
300
|
+
text = document.text
|
|
301
|
+
errors: list[str] = []
|
|
302
|
+
if re.search(r"^#{1,6}\s", text, re.MULTILINE):
|
|
303
|
+
errors.append("contains markdown headers")
|
|
304
|
+
if re.search(r"^```", text, re.MULTILINE):
|
|
305
|
+
errors.append("contains code blocks")
|
|
306
|
+
if re.search(r"^\s*---+\s*$", text, re.MULTILINE):
|
|
307
|
+
errors.append("contains horizontal rules")
|
|
308
|
+
base_name = PurePosixPath(document.path).name.lower()
|
|
309
|
+
if "changelog" in base_name and base_name.endswith(".prizm"):
|
|
310
|
+
errors.append("is auxiliary history")
|
|
269
311
|
if AUXILIARY_METADATA_RE.search(text):
|
|
270
|
-
|
|
271
|
-
file_path,
|
|
272
|
-
"contains auxiliary history metadata. Use durable KEY: value sections only; git tracks history.",
|
|
273
|
-
)
|
|
274
|
-
errors += 1
|
|
275
|
-
|
|
312
|
+
errors.append("contains auxiliary history metadata")
|
|
276
313
|
if WORKFLOW_ID_RE.search(text) or WORKFLOW_FIELD_RE.search(text) or WORKFLOW_COMPOUND_FIELD_RE.search(text):
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
normalized_text = text.replace("\\", "/")
|
|
284
|
-
if ARTIFACT_PATH_RE.search(normalized_text):
|
|
285
|
-
emit_error(
|
|
286
|
-
file_path,
|
|
287
|
-
"contains PrizmKit artifact paths. Translate specs/dev-pipeline references into durable product/domain language.",
|
|
288
|
-
)
|
|
289
|
-
errors += 1
|
|
290
|
-
|
|
291
|
-
if ABSOLUTE_PATH_RE.search(normalized_text):
|
|
292
|
-
emit_error(file_path, "contains absolute worktree paths. Use repository-relative source paths only.")
|
|
293
|
-
errors += 1
|
|
294
|
-
|
|
314
|
+
errors.append("contains workflow metadata")
|
|
315
|
+
normalized = text.replace("\\", "/")
|
|
316
|
+
if ARTIFACT_PATH_RE.search(normalized):
|
|
317
|
+
errors.append("contains PrizmKit artifact paths")
|
|
318
|
+
if ABSOLUTE_PATH_RE.search(normalized):
|
|
319
|
+
errors.append("contains absolute worktree paths")
|
|
295
320
|
if (
|
|
296
321
|
BRANCH_EXACT_FIELD_RE.search(text)
|
|
297
322
|
or BRANCH_UPPER_FIELD_RE.search(text)
|
|
@@ -299,54 +324,255 @@ def validate_file(file_path: str, raw: bytes, text: str) -> tuple[int, CapacityD
|
|
|
299
324
|
or BRANCH_TITLE_FIELD_RE.search(text)
|
|
300
325
|
or BRANCH_SIMPLE_FIELD_RE.search(text)
|
|
301
326
|
):
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
327
|
+
errors.append("contains branch names")
|
|
328
|
+
traps = document.sections.get("TRAPS", ())
|
|
329
|
+
for trap in traps:
|
|
330
|
+
if not TRAP_RE.fullmatch(trap):
|
|
331
|
+
errors.append("contains a TRAPS entry without valid severity and FIX")
|
|
332
|
+
break
|
|
333
|
+
return errors
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _shape_errors(document: Document) -> list[str]:
|
|
337
|
+
names = set(document.fields)
|
|
338
|
+
errors: list[str] = []
|
|
339
|
+
if document.level == "L0":
|
|
340
|
+
missing = sorted(ROOT_REQUIRED - names)
|
|
341
|
+
if missing:
|
|
342
|
+
errors.append(f"missing required root fields: {', '.join(missing)}")
|
|
343
|
+
if document.fields.get("PRIZM_VERSION") != "4":
|
|
344
|
+
errors.append("requires exact PRIZM_VERSION: 4")
|
|
345
|
+
indexes = {name for name in ("MODULE_INDEX", "MODULE_GROUPS") if name in names}
|
|
346
|
+
if len(indexes) != 1:
|
|
347
|
+
errors.append("requires exactly one of MODULE_INDEX or MODULE_GROUPS")
|
|
348
|
+
elif document.level == "L1":
|
|
349
|
+
missing = sorted(L1_REQUIRED - names)
|
|
350
|
+
if missing:
|
|
351
|
+
errors.append(f"missing required L1 fields: {', '.join(missing)}")
|
|
352
|
+
prohibited = sorted(L1_PROHIBITED & names)
|
|
353
|
+
if prohibited:
|
|
354
|
+
errors.append(f"contains prohibited L1 behavioral fields: {', '.join(prohibited)}")
|
|
355
|
+
if len(document.sections.get("RULES", ())) > 3:
|
|
356
|
+
errors.append("contains more than three L1 RULES entries")
|
|
357
|
+
else:
|
|
358
|
+
semantic = "CONCERN" in names
|
|
359
|
+
required = SEMANTIC_REQUIRED if semantic else L2_REQUIRED
|
|
360
|
+
missing = sorted(required - names)
|
|
361
|
+
if missing:
|
|
362
|
+
kind = "semantic L2" if semantic else "mirrored L2"
|
|
363
|
+
errors.append(f"missing required {kind} fields: {', '.join(missing)}")
|
|
364
|
+
return errors
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _normalize_slug(value: str) -> str:
|
|
368
|
+
normalized = unicodedata.normalize("NFKC", value).lower()
|
|
369
|
+
pieces: list[str] = []
|
|
370
|
+
pending_hyphen = False
|
|
371
|
+
for char in normalized:
|
|
372
|
+
valid = char.isalpha() or unicodedata.category(char) == "Nd"
|
|
373
|
+
if valid:
|
|
374
|
+
if pending_hyphen and pieces:
|
|
375
|
+
pieces.append("-")
|
|
376
|
+
pieces.append(char)
|
|
377
|
+
pending_hyphen = False
|
|
378
|
+
else:
|
|
379
|
+
pending_hyphen = True
|
|
380
|
+
return "".join(pieces).strip("-")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _field_items(document: Document, field: str) -> tuple[str, ...]:
|
|
384
|
+
inline = document.fields.get(field, "").strip()
|
|
385
|
+
values: list[str] = []
|
|
386
|
+
if inline and inline.lower() != "none":
|
|
387
|
+
values.extend(part.strip() for part in inline.split(",") if part.strip())
|
|
388
|
+
for item in document.sections.get(field, ()):
|
|
389
|
+
value = item[2:].strip()
|
|
390
|
+
if value and value.lower() != "none":
|
|
391
|
+
values.append(value.split(":", 1)[0].strip())
|
|
392
|
+
return tuple(values)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _safe_owned_path(value: str) -> bool:
|
|
396
|
+
text = value.replace("\\", "/").strip()
|
|
397
|
+
if not text or text.startswith("/") or re.match(r"^[A-Za-z]:/", text):
|
|
398
|
+
return False
|
|
399
|
+
path = PurePosixPath(text)
|
|
400
|
+
return not path.is_absolute() and all(part not in {"", ".", ".."} for part in path.parts)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _resolve_pointer_target(raw_target: str) -> str | None:
|
|
404
|
+
target = raw_target.strip().strip("`").replace("\\", "/")
|
|
405
|
+
if target.startswith("./"):
|
|
406
|
+
target = target[2:]
|
|
407
|
+
if target.startswith(f"{DOCS_ROOT.as_posix()}/"):
|
|
408
|
+
candidate = PurePosixPath(target)
|
|
409
|
+
else:
|
|
410
|
+
candidate = DOCS_ROOT_POSIX / PurePosixPath(target)
|
|
411
|
+
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
|
412
|
+
return None
|
|
413
|
+
if not candidate.as_posix().endswith(".prizm"):
|
|
414
|
+
return None
|
|
415
|
+
try:
|
|
416
|
+
candidate.relative_to(DOCS_ROOT_POSIX)
|
|
417
|
+
except ValueError:
|
|
418
|
+
return None
|
|
419
|
+
return candidate.as_posix()
|
|
319
420
|
|
|
320
421
|
|
|
321
|
-
def
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
422
|
+
def collect_pointers(document: Document) -> tuple[list[Pointer], list[str]]:
|
|
423
|
+
pointers: list[Pointer] = []
|
|
424
|
+
errors: list[str] = []
|
|
425
|
+
pointer_sections = (
|
|
426
|
+
{"MODULE_INDEX", "MODULE_GROUPS"}
|
|
427
|
+
if document.level == "L0"
|
|
428
|
+
else ({"SUBDIRS", "DETAILS"} if document.level == "L1" else set())
|
|
429
|
+
)
|
|
430
|
+
for section, lines in document.sections.items():
|
|
431
|
+
if section not in pointer_sections:
|
|
432
|
+
continue
|
|
433
|
+
for line in lines:
|
|
434
|
+
if "->" not in line:
|
|
435
|
+
continue
|
|
436
|
+
target_text = line.split("->", 1)[1].strip().split()[0].strip("`") if line.split("->", 1)[1].strip() else ""
|
|
437
|
+
target = _resolve_pointer_target(target_text)
|
|
438
|
+
if target is None:
|
|
439
|
+
errors.append(f"has unsafe or invalid pointer in {section}: {line}")
|
|
440
|
+
continue
|
|
441
|
+
pointers.append(Pointer(document.path, section, line, target))
|
|
442
|
+
return pointers, errors
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def validate_snapshot(snapshot: Mapping[str, bytes], active: bool, snapshot_error: str) -> int:
|
|
446
|
+
if snapshot_error:
|
|
447
|
+
emit_error(ROOT_PATH, snapshot_error)
|
|
325
448
|
return 1
|
|
326
|
-
|
|
327
|
-
if not ROOT_PRIZM.is_file():
|
|
449
|
+
if not active:
|
|
328
450
|
return 0
|
|
451
|
+
if ROOT_PATH not in snapshot:
|
|
452
|
+
emit_error(ROOT_PATH, "missing exact root document from validation snapshot")
|
|
453
|
+
return 1
|
|
329
454
|
|
|
330
455
|
errors = 0
|
|
456
|
+
documents: dict[str, Document] = {}
|
|
331
457
|
capacity_entries: list[CapacityDiagnostic] = []
|
|
332
|
-
for file_path in
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
458
|
+
for file_path in sorted(snapshot):
|
|
459
|
+
document, parse_errors = parse_document(file_path, snapshot[file_path])
|
|
460
|
+
documents[file_path] = document
|
|
461
|
+
for message in [*parse_errors, *_metadata_errors(document), *_shape_errors(document)]:
|
|
462
|
+
emit_error(file_path, message)
|
|
463
|
+
errors += 1
|
|
464
|
+
diagnostic = capacity_diagnostic(document)
|
|
339
465
|
if diagnostic is not None:
|
|
340
466
|
capacity_entries.append(diagnostic)
|
|
467
|
+
if diagnostic.band == "error":
|
|
468
|
+
errors += 1
|
|
469
|
+
|
|
470
|
+
pointers: list[Pointer] = []
|
|
471
|
+
for document in documents.values():
|
|
472
|
+
found, pointer_errors = collect_pointers(document)
|
|
473
|
+
pointers.extend(found)
|
|
474
|
+
for message in pointer_errors:
|
|
475
|
+
emit_error(document.path, message)
|
|
476
|
+
errors += 1
|
|
477
|
+
|
|
478
|
+
inbound_l2: dict[str, list[Pointer]] = {}
|
|
479
|
+
for pointer in pointers:
|
|
480
|
+
source = documents[pointer.source]
|
|
481
|
+
target = documents.get(pointer.target)
|
|
482
|
+
if target is None:
|
|
483
|
+
emit_error(pointer.source, f"pointer does not resolve in snapshot: {pointer.target}")
|
|
484
|
+
errors += 1
|
|
485
|
+
continue
|
|
486
|
+
if source.level == "L0" and target.level != "L1":
|
|
487
|
+
emit_error(pointer.source, f"root pointer must resolve to direct-child L1: {pointer.target}")
|
|
488
|
+
errors += 1
|
|
489
|
+
if source.level == "L1":
|
|
490
|
+
if target.level != "L2":
|
|
491
|
+
emit_error(pointer.source, f"L1 pointer must resolve to nested L2: {pointer.target}")
|
|
492
|
+
errors += 1
|
|
493
|
+
else:
|
|
494
|
+
inbound_l2.setdefault(pointer.target, []).append(pointer)
|
|
495
|
+
semantic_target = "CONCERN" in target.fields
|
|
496
|
+
if pointer.section == "SUBDIRS" and semantic_target:
|
|
497
|
+
emit_error(pointer.source, f"SUBDIRS must target mirrored L2 without CONCERN: {pointer.target}")
|
|
498
|
+
errors += 1
|
|
499
|
+
if pointer.section == "DETAILS":
|
|
500
|
+
match = DETAILS_ENTRY_RE.match(pointer.line)
|
|
501
|
+
if not match:
|
|
502
|
+
emit_error(pointer.source, f"invalid DETAILS pointer grammar: {pointer.line}")
|
|
503
|
+
errors += 1
|
|
504
|
+
elif not semantic_target:
|
|
505
|
+
emit_error(pointer.source, f"DETAILS must target semantic L2: {pointer.target}")
|
|
506
|
+
errors += 1
|
|
507
|
+
elif match.group(1).strip() != PurePosixPath(pointer.target).stem:
|
|
508
|
+
emit_error(pointer.source, f"DETAILS slug disagrees with target path: {pointer.target}")
|
|
509
|
+
errors += 1
|
|
510
|
+
|
|
511
|
+
semantic_ownership: dict[str, dict[str, str]] = {}
|
|
512
|
+
for document in documents.values():
|
|
513
|
+
if document.level != "L2":
|
|
514
|
+
continue
|
|
515
|
+
inbound = inbound_l2.get(document.path, [])
|
|
516
|
+
if not inbound:
|
|
517
|
+
emit_error(document.path, "has no resolving direct-child L1 pointer")
|
|
518
|
+
errors += 1
|
|
519
|
+
if "CONCERN" not in document.fields:
|
|
520
|
+
continue
|
|
521
|
+
if len(inbound) != 1 or inbound[0].section != "DETAILS":
|
|
522
|
+
emit_error(document.path, "semantic L2 requires exactly one resolving L1 DETAILS pointer")
|
|
523
|
+
errors += 1
|
|
524
|
+
concern_match = CONCERN_RE.match(document.fields.get("CONCERN", ""))
|
|
525
|
+
if not concern_match:
|
|
526
|
+
emit_error(document.path, "requires CONCERN: <identity> | SLUG: <slug>")
|
|
527
|
+
errors += 1
|
|
528
|
+
continue
|
|
529
|
+
identity, slug = concern_match.group(1).strip(), concern_match.group(2).strip()
|
|
530
|
+
expected_slug = _normalize_slug(identity)
|
|
531
|
+
if not expected_slug or slug != expected_slug or slug != PurePosixPath(document.path).stem:
|
|
532
|
+
emit_error(document.path, "semantic CONCERN identity, normalized slug, and filename disagree")
|
|
533
|
+
errors += 1
|
|
534
|
+
if inbound:
|
|
535
|
+
parent = documents[inbound[0].source]
|
|
536
|
+
if document.fields.get("MODULE", "").strip() != parent.fields.get("MODULE", "").strip():
|
|
537
|
+
emit_error(document.path, "semantic MODULE disagrees with resolving L1 MODULE")
|
|
538
|
+
errors += 1
|
|
539
|
+
owned = _field_items(document, "FILES")
|
|
540
|
+
if not owned:
|
|
541
|
+
emit_error(document.path, "semantic FILES ownership must be non-empty")
|
|
542
|
+
errors += 1
|
|
543
|
+
owner_map = semantic_ownership.setdefault(document.fields.get("MODULE", "").strip(), {})
|
|
544
|
+
for owned_path in owned:
|
|
545
|
+
if not _safe_owned_path(owned_path):
|
|
546
|
+
emit_error(document.path, f"semantic FILES contains unsafe ownership path: {owned_path}")
|
|
547
|
+
errors += 1
|
|
548
|
+
continue
|
|
549
|
+
previous = owner_map.get(owned_path)
|
|
550
|
+
if previous and previous != document.path:
|
|
551
|
+
emit_error(document.path, f"semantic FILES overlaps {previous}: {owned_path}")
|
|
552
|
+
errors += 1
|
|
553
|
+
owner_map[owned_path] = document.path
|
|
341
554
|
|
|
342
555
|
for diagnostic in sorted(capacity_entries, key=cmp_to_key(compare_capacity)):
|
|
343
556
|
emit_capacity(diagnostic)
|
|
344
557
|
|
|
345
|
-
if errors
|
|
346
|
-
print(f"PrizmKit: {errors}
|
|
558
|
+
if errors:
|
|
559
|
+
print(f"PrizmKit: {errors} validation error(s) in the selected Prizm snapshot.", file=sys.stderr)
|
|
347
560
|
return 1
|
|
348
561
|
return 0
|
|
349
562
|
|
|
350
563
|
|
|
564
|
+
def main(argv: list[str]) -> int:
|
|
565
|
+
mode = argv[1] if len(argv) > 1 else "--staged"
|
|
566
|
+
if len(argv) > 2 or mode not in VALID_MODES:
|
|
567
|
+
print("Usage: validate-prizm-docs.py [--all | --staged]", file=sys.stderr)
|
|
568
|
+
return 1
|
|
569
|
+
try:
|
|
570
|
+
snapshot, active, error = collect_snapshot(mode)
|
|
571
|
+
except OSError as exc:
|
|
572
|
+
emit_error(ROOT_PATH, f"snapshot read failed: {exc}")
|
|
573
|
+
return 1
|
|
574
|
+
return validate_snapshot(snapshot, active, error)
|
|
575
|
+
|
|
576
|
+
|
|
351
577
|
if __name__ == "__main__":
|
|
352
578
|
raise SystemExit(main(sys.argv))
|