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.
Files changed (77) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/adapters/codebuddy/settings-adapter.js +76 -25
  3. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +9 -2
  4. package/bundled/dev-pipeline/prizmkit_runtime/runtime_commit.py +42 -15
  5. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  6. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +1 -1
  7. package/bundled/dev-pipeline/templates/sections/runtime-commit-handoff.md +6 -6
  8. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +4 -3
  9. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +2 -2
  10. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +2 -2
  11. package/bundled/dev-pipeline/tests/test_runtime_commit.py +68 -13
  12. package/bundled/dev-pipeline/tests/test_unified_cli.py +10 -6
  13. package/bundled/rules/prizm/prizm-commit-workflow.md +7 -7
  14. package/bundled/rules/prizm/prizm-documentation.md +24 -65
  15. package/bundled/rules/prizm/prizm-progressive-loading.md +9 -8
  16. package/bundled/skills/_metadata.json +1 -1
  17. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  18. package/bundled/skills/prizmkit-committer/SKILL.md +48 -41
  19. package/bundled/skills/prizmkit-deploy/SKILL.md +172 -198
  20. package/bundled/skills/prizmkit-deploy/references/capability-contract.md +137 -0
  21. package/bundled/skills/prizmkit-deploy/references/deploy-record-schema.json +1053 -0
  22. package/bundled/skills/prizmkit-deploy/references/deployment-declaration-schema.json +291 -0
  23. package/bundled/skills/prizmkit-deploy/references/legacy-migration.md +90 -0
  24. package/bundled/skills/prizmkit-deploy/references/policy-and-execution.md +170 -0
  25. package/bundled/skills/prizmkit-deploy/references/real-resource-test-policy.md +73 -0
  26. package/bundled/skills/prizmkit-deploy/references/verification-and-recovery.md +164 -0
  27. package/bundled/skills/prizmkit-deploy/scripts/validate-deploy-artifact.py +371 -0
  28. package/bundled/skills/prizmkit-implement/SKILL.md +2 -2
  29. package/bundled/skills/prizmkit-init/SKILL.md +17 -17
  30. package/bundled/skills/prizmkit-init/references/update-supplement.md +5 -5
  31. package/bundled/skills/prizmkit-prizm-docs/SKILL.md +17 -11
  32. package/bundled/skills/prizmkit-prizm-docs/assets/prizm-docs-format.md +95 -155
  33. package/bundled/skills/prizmkit-prizm-docs/references/op-init.md +6 -5
  34. package/bundled/skills/prizmkit-prizm-docs/references/op-rebuild.md +4 -2
  35. package/bundled/skills/prizmkit-prizm-docs/references/op-status.md +9 -10
  36. package/bundled/skills/prizmkit-prizm-docs/references/op-update.md +6 -5
  37. package/bundled/skills/prizmkit-prizm-docs/references/op-validate.md +13 -12
  38. package/bundled/skills/prizmkit-retrospective/SKILL.md +3 -3
  39. package/bundled/skills/prizmkit-test/SKILL.md +26 -13
  40. package/bundled/skills/prizmkit-test/references/boundary-contract-and-test-double-guidance.md +8 -4
  41. package/bundled/skills/prizmkit-test/references/independent-test-review.md +7 -3
  42. package/bundled/skills/prizmkit-test/references/test-coverage-model.md +33 -10
  43. package/bundled/skills/prizmkit-test/references/test-report-template.md +33 -11
  44. package/bundled/skills/prizmkit-workflow/SKILL.md +5 -7
  45. package/bundled/skills/prizmkit-workflow/references/workflow-state-protocol.md +5 -6
  46. package/bundled/templates/hooks/commit-intent-status.py +9 -41
  47. package/bundled/templates/hooks/commit-intent.json +3 -3
  48. package/bundled/templates/hooks/diff-prizm-docs.py +3 -1
  49. package/bundled/templates/hooks/prizm-pre-commit.py +32 -6
  50. package/bundled/templates/hooks/run-python-hook.cjs +44 -0
  51. package/bundled/templates/hooks/validate-prizm-docs.py +399 -173
  52. package/bundled/templates/project-memory-template.md +19 -19
  53. package/package.json +1 -1
  54. package/src/clean.js +6 -7
  55. package/src/gitignore-template.js +8 -12
  56. package/src/scaffold.js +125 -71
  57. package/bundled/skills/prizmkit-deploy/assets/deploy-document-template.md +0 -89
  58. package/bundled/skills/prizmkit-deploy/references/ci-cd-workflows.md +0 -145
  59. package/bundled/skills/prizmkit-deploy/references/cloud-platform-deploy.md +0 -109
  60. package/bundled/skills/prizmkit-deploy/references/data-safety-examples.md +0 -120
  61. package/bundled/skills/prizmkit-deploy/references/database-setup.md +0 -65
  62. package/bundled/skills/prizmkit-deploy/references/deploy-config-schema.md +0 -150
  63. package/bundled/skills/prizmkit-deploy/references/deploy-history-schema.md +0 -78
  64. package/bundled/skills/prizmkit-deploy/references/deploy-metadata-schema.json +0 -49
  65. package/bundled/skills/prizmkit-deploy/references/deployment-modes.md +0 -50
  66. package/bundled/skills/prizmkit-deploy/references/direct-upload.md +0 -26
  67. package/bundled/skills/prizmkit-deploy/references/dns-setup.md +0 -42
  68. package/bundled/skills/prizmkit-deploy/references/docker-deploy.md +0 -31
  69. package/bundled/skills/prizmkit-deploy/references/firewall-setup.md +0 -37
  70. package/bundled/skills/prizmkit-deploy/references/live-validation-notes.md +0 -21
  71. package/bundled/skills/prizmkit-deploy/references/nginx-blue-green.md +0 -59
  72. package/bundled/skills/prizmkit-deploy/references/pending-input-schema.json +0 -164
  73. package/bundled/skills/prizmkit-deploy/references/ssh-adapter-flow.md +0 -221
  74. package/bundled/skills/prizmkit-deploy/references/ssh-bootstrap-flow.md +0 -49
  75. package/bundled/skills/prizmkit-deploy/references/ssh-execution-flow.md +0 -41
  76. package/bundled/skills/prizmkit-deploy/references/ssh-takeover.md +0 -20
  77. package/bundled/skills/prizmkit-deploy/references/ssl-setup.md +0 -56
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- """Validate .prizmkit/prizm-docs files for PrizmKit projects."""
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
- ROOT_PRIZM = DOCS_ROOT / "root.prizm"
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], *, capture: bool = True) -> subprocess.CompletedProcess[str]:
99
+ def run_git(args: list[str], *, binary: bool = False) -> subprocess.CompletedProcess:
85
100
  return subprocess.run(
86
101
  ["git", *args],
87
- text=True,
88
- encoding="utf-8",
89
- errors="replace",
90
- stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
91
- stderr=subprocess.DEVNULL,
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
- def collect_files(mode: str) -> list[str]:
97
- if mode == "--all":
98
- return sorted(path.as_posix() for path in DOCS_ROOT.rglob("*.prizm") if path.is_file())
99
-
100
- result = run_git(["diff", "--cached", "--name-only"])
101
- if result.returncode != 0:
102
- return []
103
- return [line.strip() for line in result.stdout.splitlines() if line.strip().endswith(".prizm")]
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
- def is_root_doc(file_path: str) -> bool:
142
- return PurePosixPath(file_path) == PurePosixPath(ROOT_PRIZM.as_posix())
143
-
144
-
145
- def is_l1_doc(file_path: str) -> bool:
146
- parent = PurePosixPath(file_path).parent.as_posix()
147
- return parent == DOCS_ROOT.as_posix()
148
-
149
-
150
- def size_limit(file_path: str) -> tuple[int, str, str]:
151
- if is_root_doc(file_path):
152
- return (
153
- 4096,
154
- "L0",
155
- "trim: lower-priority global wording; "
156
- "deduplicate: repeated RULES or module summaries; "
157
- "move-to-child: module detail into an L1 document; "
158
- "semantic-split: broad module groups into focused L1 documents",
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 is_l1_doc(file_path):
161
- return (
162
- 4096,
163
- "L1",
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
- 5120,
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 capacity_diagnostic(file_path: str, size: int) -> CapacityDiagnostic | None:
194
- limit, level, actions = size_limit(file_path)
195
- band = capacity_band(size, limit)
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=file_path,
200
- level=level,
201
- size=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 > right_ratio:
214
- return -1
215
- if left_ratio < right_ratio:
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
- "warning": "WARNING",
232
- "strong-warning": "STRONG WARNING",
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"level={diagnostic.level} bytes={diagnostic.size}B limit={diagnostic.limit}B "
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 contains_root_field(text: str, field: str) -> bool:
248
- return re.search(rf"^{re.escape(field)}:", text, re.MULTILINE) is not None
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
- if re.search(r"^#{1,6} ", text, re.MULTILINE):
255
- emit_error(file_path, "contains markdown headers (##). Use KEY: value format.")
256
- errors += 1
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
- if re.search(r"^```", text, re.MULTILINE):
259
- emit_error(file_path, "contains code blocks. Use file_path:line_number reference.")
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
- emit_error(
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
- emit_error(
278
- file_path,
279
- "contains workflow metadata. Translate IDs, branch names, worktree paths, and pipeline artifacts into durable product/domain language.",
280
- )
281
- errors += 1
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
- emit_error(file_path, "contains branch names. Describe durable product/domain context instead.")
303
- errors += 1
304
-
305
- diagnostic = capacity_diagnostic(file_path, len(raw))
306
- if diagnostic is not None and diagnostic.band == "error":
307
- errors += 1
308
-
309
- if is_root_doc(file_path):
310
- for field in ["PRIZM_VERSION", "PROJECT", "LANG", "RULES"]:
311
- if not contains_root_field(text, field):
312
- emit_error(file_path, f"missing required field {field}:")
313
- errors += 1
314
- if not contains_root_field(text, "MODULE_INDEX") and not contains_root_field(text, "MODULE_GROUPS"):
315
- emit_error(file_path, "missing required field MODULE_INDEX: or MODULE_GROUPS:")
316
- errors += 1
317
-
318
- return errors, diagnostic
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 main(argv: list[str]) -> int:
322
- mode = argv[1] if len(argv) > 1 else "--staged"
323
- if len(argv) > 2 or mode not in VALID_MODES:
324
- print("Usage: validate-prizm-docs.py [--all | --staged]", file=sys.stderr)
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 collect_files(mode):
333
- content = read_check_content(file_path, mode)
334
- if content is None:
335
- continue
336
- raw, text = content
337
- file_errors, diagnostic = validate_file(file_path, raw, text)
338
- errors += file_errors
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 > 0:
346
- print(f"PrizmKit: {errors} format error(s) in .prizm files. Fix before committing.", file=sys.stderr)
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))