wdi-method 0.4.5 → 0.5.0

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 (100) hide show
  1. package/README.md +58 -16
  2. package/bin/wdi-method.js +152 -60
  3. package/kit/.constitution/method/README.md +66 -169
  4. package/kit/.constitution/{constitution.md → method/constitution.md} +10 -56
  5. package/kit/.constitution/{document → method/document}/architecture-guide.md +1 -1
  6. package/kit/.constitution/{document → method/document}/corpus-guide.md +2 -2
  7. package/kit/.constitution/{document → method/document}/delivery-flow-guide.md +338 -338
  8. package/kit/.constitution/{document → method/document}/sdd-guide.md +1 -1
  9. package/kit/.constitution/{document → method/document}/templates/cross-cutting.md +5 -5
  10. package/kit/.constitution/{document → method/document}/templates/design-system.md +67 -67
  11. package/kit/.constitution/{document → method/document}/templates/inventory.md +69 -69
  12. package/kit/.constitution/{document → method/document}/templates/model.md +2 -2
  13. package/kit/.constitution/{document → method/document}/templates/questions.md +10 -9
  14. package/kit/.constitution/{document → method/document}/templates/sdd.md +1 -1
  15. package/kit/.constitution/{document → method/document}/templates/srs.md +2 -2
  16. package/kit/.constitution/{document → method/document}/templates/structure-codebase.md +129 -129
  17. package/kit/.constitution/{document → method/document}/templates/structure-document.md +90 -90
  18. package/kit/.constitution/{language-guide.md → method/language-guide.md} +2 -2
  19. package/kit/.constitution/{repo-guide.md → method/repo-guide.md} +16 -3
  20. package/kit/.constitution/{scripts → method/scripts}/inventory.py +102 -100
  21. package/kit/.constitution/{scripts → method/scripts}/timeline.py +665 -665
  22. package/kit/.constitution/{scripts → method/scripts}/validate.py +1599 -1579
  23. package/kit/.constitution/{structure-guide.md → method/structure-guide.md} +3 -3
  24. package/kit/.constitution/method/why/README.md +169 -0
  25. package/kit/.constitution/method/{artifact-map.md → why/artifact-map.md} +5 -5
  26. package/kit/.constitution/method/{rationale.md → why/rationale.md} +5 -5
  27. package/kit/.constitution/project/constitution.md +63 -0
  28. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +9 -9
  29. package/kit/assets/bmad-custom/bmad-architecture.toml +33 -31
  30. package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
  31. package/kit/assets/bmad-custom/bmad-build.toml +52 -52
  32. package/kit/assets/bmad-custom/bmad-code-review.toml +13 -12
  33. package/kit/assets/bmad-custom/bmad-correct-course.toml +20 -19
  34. package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
  35. package/kit/assets/bmad-custom/bmad-prd.toml +42 -42
  36. package/kit/assets/bmad-custom/bmad-product-brief.toml +18 -18
  37. package/kit/assets/bmad-custom/bmad-retrospective.toml +4 -4
  38. package/kit/assets/bmad-custom/bmad-spec.toml +16 -15
  39. package/kit/assets/bmad-custom/bmad-ux.toml +19 -19
  40. package/kit/assets/bmad-custom/config.toml +3 -3
  41. package/kit/skills/wdi-blueprint/SKILL.md +3 -3
  42. package/kit/skills/wdi-build/SKILL.md +244 -244
  43. package/kit/skills/wdi-component/SKILL.md +1 -1
  44. package/kit/skills/wdi-help/SKILL.md +98 -98
  45. package/kit/skills/wdi-init/SKILL.md +1 -1
  46. package/kit/skills/wdi-problem/SKILL.md +90 -90
  47. package/kit/skills/wdi-product/SKILL.md +127 -127
  48. package/kit/skills/wdi-reconcile/SKILL.md +2 -2
  49. package/kit/skills/wdi-report/SKILL.md +205 -205
  50. package/kit/skills/wdi-systematic-debugging/SKILL.md +193 -193
  51. package/kit/skills/wdi-ux/SKILL.md +145 -145
  52. package/kit-overlay/AGENTS.md +20 -20
  53. package/kit-overlay/README.md +24 -17
  54. package/kit-overlay/constitution.md +10 -56
  55. package/kit-overlay/portability.md +6 -6
  56. package/kit-overlay/repo-guide.md +112 -97
  57. package/package.json +37 -37
  58. package/scaffold/.control/product-glossary.md +21 -21
  59. package/scaffold/.control/project-non-technical-log.md +23 -23
  60. package/scaffold/.control/questions/answered.md +11 -11
  61. package/scaffold/.control/questions/assumptions.md +15 -15
  62. package/scaffold/.control/questions/blocking.md +21 -21
  63. package/scaffold/.control/questions/external.md +11 -11
  64. package/scaffold/.control/registry/components.yaml +21 -21
  65. package/scaffold/.control/registry/defects.yaml +3 -3
  66. package/scaffold/.control/registry/index.yaml +46 -46
  67. package/scaffold/.control/registry/requirements.yaml +15 -15
  68. package/scaffold/.control/registry/risks.yaml +5 -5
  69. package/scaffold/.control/registry/usecases.yaml +6 -6
  70. package/kit/.constitution/README.md +0 -66
  71. /package/kit/.constitution/{document → method/document}/bmad-guide.md +0 -0
  72. /package/kit/.constitution/{document → method/document}/bmad-skill-register.md +0 -0
  73. /package/kit/.constitution/{document → method/document}/brief-guide.md +0 -0
  74. /package/kit/.constitution/{document → method/document}/decision-guide.md +0 -0
  75. /package/kit/.constitution/{document → method/document}/prd-guide.md +0 -0
  76. /package/kit/.constitution/{document → method/document}/srs-guide.md +0 -0
  77. /package/kit/.constitution/{document → method/document}/templates/addendum.md +0 -0
  78. /package/kit/.constitution/{document → method/document}/templates/architecture-spine.md +0 -0
  79. /package/kit/.constitution/{document → method/document}/templates/brief.md +0 -0
  80. /package/kit/.constitution/{document → method/document}/templates/c4.md +0 -0
  81. /package/kit/.constitution/{document → method/document}/templates/contract.md +0 -0
  82. /package/kit/.constitution/{document → method/document}/templates/decision.md +0 -0
  83. /package/kit/.constitution/{document → method/document}/templates/flow.md +0 -0
  84. /package/kit/.constitution/{document → method/document}/templates/integration.md +0 -0
  85. /package/kit/.constitution/{document → method/document}/templates/lc.md +0 -0
  86. /package/kit/.constitution/{document → method/document}/templates/lifecycle.md +0 -0
  87. /package/kit/.constitution/{document → method/document}/templates/meeting.md +0 -0
  88. /package/kit/.constitution/{document → method/document}/templates/oq.md +0 -0
  89. /package/kit/.constitution/{document → method/document}/templates/prd.md +0 -0
  90. /package/kit/.constitution/{document → method/document}/templates/rtr.md +0 -0
  91. /package/kit/.constitution/{document → method/document}/templates/rules.md +0 -0
  92. /package/kit/.constitution/{document → method/document}/templates/scn.md +0 -0
  93. /package/kit/.constitution/{document → method/document}/templates/uc.md +0 -0
  94. /package/kit/.constitution/{document → method/document}/templates/ux.md +0 -0
  95. /package/kit/.constitution/{document → method/document}/ux-guide.md +0 -0
  96. /package/kit/.constitution/{method-glossary.md → method/method-glossary.md} +0 -0
  97. /package/kit/.constitution/method/{portability.md → why/portability.md} +0 -0
  98. /package/kit/.constitution/{codebase/brownfield-guide.md → project/codebase-brownfield-guide.md} +0 -0
  99. /package/kit/.constitution/{codebase/conventions-guide.md → project/codebase-conventions-guide.md} +0 -0
  100. /package/kit/.constitution/{codebase/stack-guide.md → project/codebase-stack-guide.md} +0 -0
@@ -1,1579 +1,1599 @@
1
- #!/usr/bin/env -S uv run --script
2
- # /// script
3
- # requires-python = ">=3.11"
4
- # dependencies = ["pyyaml>=6"]
5
- # ///
6
- """validate — V1..V27 plus generator .control/generated/.
7
-
8
- Dua mode:
9
- validate --check keluar non-zero bila ada yang merah; tidak menulis apa pun
10
- validate --generate tulis ulang .control/generated/ (dan tetap menjalankan --check)
11
-
12
- Determinisme adalah kontraknya: dua run atas data yang sama MUST memberi hasil yang sama.
13
- Karena itu tidak ada iterasi tak berurut, dan satu-satunya masukan yang bergantung waktu
14
- (--asof, dipakai V14) dinyatakan eksplisit alih-alih diambil diam-diam dari jam dinding.
15
-
16
- Yang TIDAK dikerjakan di sini: dimensi waktu dari git. `generated/timeline` dan
17
- `generated/report` milik wdi-report. Lihat 08-project-management.md.
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import datetime as dt
24
- import re
25
- import subprocess
26
- import sys
27
- from dataclasses import dataclass, field
28
- from pathlib import Path
29
-
30
- import yaml
31
-
32
- REGISTRY = "control/registry" # dirapikan di resolve(); '.control' dipakai sebenarnya
33
- GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
34
-
35
- # Halaman yang dibaca MANUSIA, bukan mesin: ditulis sebagai tabel markdown sungguhan, bukan yaml
36
- # dalam fence. Ketiganya disebut §22 dan masing-masing punya satu pembaca yang jelas.
37
- GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
38
-
39
- MODES = ("catalog", "outline", "guarded", "deep")
40
-
41
- # Keywords that make a component "sensitive" for V23. Matched against `risk_note`, which is PROSE in
42
- # whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
43
- # than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
44
- # it does not judge. Deliberately short.
45
- SENSITIVE_MARKERS = (
46
- # English
47
- "money", "payment", "personal data", "pii",
48
- "irreversible", "cannot be undone", "contractual", "contract", "integration",
49
- # Bahasa Indonesia
50
- "uang", "pembayaran", "data pribadi",
51
- "tak-terbalikkan", "tak terbalikkan", "tidak dapat dibatalkan",
52
- "kontraktual", "kontrak", "integrasi",
53
- )
54
-
55
-
56
- # ---------------------------------------------------------------- infrastruktur
57
-
58
-
59
- @dataclass(frozen=True)
60
- class Finding:
61
- vid: str
62
- subject: str
63
- message: str
64
-
65
- @property
66
- def sort_key(self) -> tuple[int, str, str]:
67
- digits = "".join(ch for ch in self.vid if ch.isdigit())
68
- return (int(digits or 0), self.subject, self.message)
69
-
70
-
71
- @dataclass
72
- class Result:
73
- findings: list[Finding] = field(default_factory=list)
74
- skipped: dict[str, str] = field(default_factory=dict)
75
-
76
- def fail(self, vid: str, subject: str, message: str) -> None:
77
- self.findings.append(Finding(vid, subject, message))
78
-
79
- def skip(self, vid: str, why: str) -> None:
80
- self.skipped[vid] = why
81
-
82
- @property
83
- def red(self) -> list[str]:
84
- return sorted({f.vid for f in self.findings})
85
-
86
-
87
- def load_yaml(path: Path) -> dict:
88
- if not path.exists():
89
- return {}
90
- data = yaml.safe_load(path.read_text(encoding="utf-8"))
91
- return data if isinstance(data, dict) else {}
92
-
93
-
94
- def rows(data: dict, key: str) -> list[dict]:
95
- """Registry list, selalu terurut menurut id supaya keluaran deterministik."""
96
- value = data.get(key) or []
97
- if not isinstance(value, list):
98
- return []
99
- items = [v for v in value if isinstance(v, dict)]
100
- return sorted(items, key=lambda r: str(r.get("id", "")))
101
-
102
-
103
- FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
104
-
105
-
106
- class Dumper(yaml.SafeDumper):
107
- """Tanpa anchor/alias: keluaran MUST bisa dibaca dan di-diff baris demi baris."""
108
-
109
- def ignore_aliases(self, data) -> bool: # noqa: ARG002
110
- return True
111
-
112
-
113
- def dump(payload: dict) -> str:
114
- return yaml.dump(payload, Dumper=Dumper, allow_unicode=True, sort_keys=False,
115
- default_flow_style=False, width=100)
116
-
117
-
118
- def frontmatter(path: Path) -> dict | None:
119
- """None bila berkasnya tidak ada; {} bila ada tetapi tanpa frontmatter."""
120
- if not path.exists():
121
- return None
122
- match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
123
- if not match:
124
- return {}
125
- data = yaml.safe_load(match.group(1))
126
- return data if isinstance(data, dict) else {}
127
-
128
-
129
- def git(root: Path, *args: str) -> str | None:
130
- try:
131
- out = subprocess.run(
132
- ["git", "-C", str(root), *args],
133
- capture_output=True, text=True, timeout=30, check=False,
134
- )
135
- except (OSError, subprocess.SubprocessError):
136
- return None
137
- return out.stdout.strip() if out.returncode == 0 else None
138
-
139
-
140
- # ------------------------------------------------------------------- pemuatan
141
-
142
-
143
- @dataclass
144
- class Corpus:
145
- root: Path
146
- requirements: dict
147
- usecases: dict
148
- decisions: dict
149
- risks: dict
150
- components: dict
151
- waves: dict
152
- defects: dict
153
- index: dict
154
-
155
- @classmethod
156
- def load(cls, root: Path) -> "Corpus":
157
- reg = root / ".control" / "registry"
158
- return cls(
159
- root=root,
160
- requirements=load_yaml(reg / "requirements.yaml"),
161
- usecases=load_yaml(reg / "usecases.yaml"),
162
- decisions=load_yaml(reg / "decisions.yaml"),
163
- risks=load_yaml(reg / "risks.yaml"),
164
- components=load_yaml(reg / "components.yaml"),
165
- waves=load_yaml(reg / "waves.yaml"),
166
- defects=load_yaml(reg / "defects.yaml"),
167
- index=load_yaml(reg / "index.yaml"),
168
- )
169
-
170
- # --- pintasan yang dipakai berulang
171
- @property
172
- def goals(self) -> list[dict]:
173
- return rows(self.requirements, "goals")
174
-
175
- @property
176
- def caps(self) -> list[dict]:
177
- return rows(self.requirements, "capabilities")
178
-
179
- @property
180
- def frs(self) -> list[dict]:
181
- return rows(self.requirements, "functional")
182
-
183
- @property
184
- def nfrs(self) -> list[dict]:
185
- return rows(self.requirements, "nonfunctional")
186
-
187
- @property
188
- def ucs(self) -> list[dict]:
189
- return rows(self.usecases, "usecases")
190
-
191
- @property
192
- def decs(self) -> list[dict]:
193
- return rows(self.decisions, "decisions")
194
-
195
- def mode_of(self, pc: dict) -> str:
196
- """`mode` per komponen menang atas global; tanpa keduanya, default `catalog`."""
197
- own = str(pc.get("mode") or "").strip()
198
- if own:
199
- return own
200
- return str(self.index.get("mode") or "").strip() or "catalog"
201
-
202
- @property
203
- def lcs(self) -> list[dict]:
204
- return rows(self.components, "logical_components")
205
-
206
- @property
207
- def pcs(self) -> list[dict]:
208
- return rows(self.components, "product_components")
209
-
210
- @property
211
- def wave_list(self) -> list[dict]:
212
- return rows(self.waves, "waves")
213
-
214
- @property
215
- def defect_list(self) -> list[dict]:
216
- return rows(self.defects, "defects")
217
-
218
- def stories(self) -> list[tuple[dict, dict, dict]]:
219
- """(wave, epic, story) — urut menurut id di tiap tingkat."""
220
- out = []
221
- for wave in self.wave_list:
222
- for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
223
- if not isinstance(epic, dict):
224
- continue
225
- for story in sorted(epic.get("stories") or [], key=lambda s: str(s.get("id", ""))):
226
- if isinstance(story, dict):
227
- out.append((wave, epic, story))
228
- return out
229
-
230
-
231
- def listy(row: dict, key: str) -> list[str]:
232
- value = row.get(key) or []
233
- if isinstance(value, str):
234
- return [value]
235
- return [str(v) for v in value if v is not None]
236
-
237
-
238
- # ------------------------------------------------------------------ validator
239
-
240
-
241
- def v1(c: Corpus, r: Result) -> None:
242
- """Tiap BG punya >=1 FR lewat CAP-nya, ATAU menyatakan alasannya di `no_fr`.
243
-
244
- Sebuah sasaran MAY dipenuhi oleh **invarian**, bukan oleh fitur. `BG-6` — fondasi data dan
245
- deployment dapat dilanjutkan tanpa dibongkar diukur oleh dua sifat arsitektural yang `measure`-nya
246
- sendiri sebut, dan tidak ada `FR` yang dapat memikulnya tanpa dikarang. Menuntut satu `FR` di sana
247
- menghasilkan janji palsu, dan janji palsu lebih mahal daripada temuan.
248
-
249
- Escape-nya MUST membawa alasan, bukan boolean — bentuk yang sama dengan `no_uc` pada `FR` (V2).
250
- """
251
- cap_by_goal: dict[str, list[str]] = {}
252
- for cap in c.caps:
253
- cap_by_goal.setdefault(str(cap.get("goal", "")), []).append(str(cap.get("id")))
254
- fr_caps = {str(fr.get("capability", "")) for fr in c.frs}
255
- for goal in c.goals:
256
- gid = str(goal.get("id"))
257
- reachable = [cid for cid in cap_by_goal.get(gid, []) if cid in fr_caps]
258
- if reachable:
259
- continue
260
- if str(goal.get("no_fr") or "").strip():
261
- continue
262
- r.fail("V1", gid, "tidak punya FR lewat CAP-nya dan tidak menyatakan alasan di `no_fr`")
263
-
264
-
265
- def v2(c: Corpus, r: Result) -> None:
266
- covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
267
- for fr in c.frs:
268
- fid = str(fr.get("id"))
269
- if fid in covered:
270
- continue
271
- if str(fr.get("no_uc") or "").strip():
272
- continue
273
- r.fail("V2", fid, "tidak punya UC dan tidak menyatakan alasan di `no_uc`")
274
-
275
-
276
- def v3(c: Corpus, r: Result) -> None:
277
- """Sebuah UC pada komponen yang SUDAH disentuh sebuah wave MUST dijadwalkan story.
278
-
279
- Bentuk lama menuntutnya atas SETIAP UC, kapan pun. Sebelum wave pertama itu berarti seluruh
280
- katalog dilaporkan merah — 56 temuan dari 62, dan ke-56 itu keadaan yang benar, bukan drift:
281
- story lahir di wave, dan belum ada wave. Sebuah validator yang menenggelamkan enam temuan nyata
282
- di bawah lima puluh enam yang diharapkan berhenti dibaca, dan validator yang tidak dibaca tidak
283
- menjaga apa pun.
284
-
285
- Yang dijaga sekarang adalah kelalaian yang sebenarnya: sebuah wave menyentuh komponen, dan sebuah
286
- UC komponen itu tertinggal tanpa story. Cakupan penuh atas seluruh katalog adalah pertanyaan G5,
287
- dan `wdi-build` yang memilikinyasama seperti V12 yang digeser ke penutupan wave.
288
- """
289
- scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
290
- touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
291
- if not c.wave_list:
292
- r.skip("V3", "belum ada wave, jadi belum ada story — tiap UC tak terjadwal adalah keadaan "
293
- "yang benar. Cakupan penuh katalog diperiksa di G5")
294
- return
295
- for uc in c.ucs:
296
- uid = str(uc.get("id"))
297
- if uid in scheduled or str(uc.get("component")) not in touched:
298
- continue
299
- r.fail("V3", uid, f"komponen `{uc.get('component')}` sudah disentuh sebuah wave, "
300
- f"tetapi UC ini tidak dijadwalkan story mana pun")
301
-
302
-
303
- def v4(c: Corpus, r: Result) -> None:
304
- for _, _, story in c.stories():
305
- if not [t for t in listy(story, "tests") if t.strip()]:
306
- r.fail("V4", str(story.get("id")), "tidak punya satu pun test bernama")
307
-
308
-
309
- def v5(c: Corpus, r: Result) -> None:
310
- """Tiap NFR punya penegak, ATAU menyatakan alasannya di `no_enforcer`.
311
-
312
- Dua NFR di repo ini tidak dapat punya penegak, dan keduanya sah: satu sudah **dicabut**, dan satu
313
- lagi menyatakan sendiri bahwa ia **ukuran perancangan, bukan pagar**. Menuntut test untuk keduanya
314
- menghasilkan test yang tidak mungkin gagal, dan test yang tidak mungkin gagal adalah teater.
315
- """
316
- for nfr in c.nfrs:
317
- if [e for e in listy(nfr, "enforced_by") if e.strip()]:
318
- continue
319
- if str(nfr.get("no_enforcer") or "").strip():
320
- continue
321
- r.fail("V5", str(nfr.get("id")),
322
- "tidak punya penegak di `enforced_by` dan tidak menyatakan alasan di `no_enforcer`")
323
-
324
-
325
- def v6(c: Corpus, r: Result) -> None:
326
- defined: set[str] = set()
327
- for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
328
- rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
329
- defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
330
- for wave in c.wave_list:
331
- defined.add(str(wave.get("id")))
332
- for _, epic, story in c.stories():
333
- defined.add(str(epic.get("id")))
334
- defined.add(str(story.get("id")))
335
-
336
- refs: list[tuple[str, str]] = []
337
- for cap in c.caps:
338
- refs.append((str(cap.get("id")), str(cap.get("goal", ""))))
339
- refs += [(str(cap.get("id")), d) for d in listy(cap, "depends_on")]
340
- for fr in c.frs:
341
- refs.append((str(fr.get("id")), str(fr.get("capability", ""))))
342
- for nfr in c.nfrs:
343
- refs.append((str(nfr.get("id")), str(nfr.get("goal", ""))))
344
- for uc in c.ucs:
345
- refs += [(str(uc.get("id")), f) for f in listy(uc, "satisfies")]
346
- for dec in c.decs:
347
- refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
348
- for defect in c.defect_list:
349
- refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
350
- for _, _, story in c.stories():
351
- refs += [(str(story.get("id")), u) for u in listy(story, "satisfies")]
352
- refs += [(str(story.get("id")), d) for d in listy(story, "depends_on")]
353
-
354
- for owner, target in sorted(set(refs)):
355
- if target and target not in defined:
356
- r.fail("V6", owner, f"menunjuk `{target}` yang tidak ada di registry mana pun")
357
-
358
-
359
- def _cycles(graph: dict[str, list[str]]) -> list[str]:
360
- state: dict[str, int] = {}
361
- bad: list[str] = []
362
-
363
- def walk(node: str) -> None:
364
- state[node] = 1
365
- for nxt in sorted(graph.get(node, [])):
366
- if state.get(nxt) == 1:
367
- bad.append(node)
368
- elif state.get(nxt) is None and nxt in graph:
369
- walk(nxt)
370
- state[node] = 2
371
-
372
- for node in sorted(graph):
373
- if state.get(node) is None:
374
- walk(node)
375
- return sorted(set(bad))
376
-
377
-
378
- def v7(c: Corpus, r: Result) -> None:
379
- caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
380
- for node in _cycles(caps):
381
- r.fail("V7", node, "ikut dalam siklus `depends_on` antar-CAP")
382
- stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
383
- for node in _cycles(stories):
384
- r.fail("V7", node, "ikut dalam siklus `depends_on` antar-story")
385
-
386
-
387
- def v8(c: Corpus, r: Result) -> None:
388
- """Tiap keputusan `applied` menyebut `touches` yang tidak kosong.
389
-
390
- Menggantikan bentuk lama "tiap keputusan accepted melayani >=1 FR/NFR". Keputusan seperti
391
- "filter harus begini" tidak melayani FR mana pun, dan itu SAHjustru keputusan seperti itu
392
- yang paling perlu diingat, dan aturan lama menyingkirkannya.
393
- """
394
- for dec in c.decs:
395
- if str(dec.get("status")) != "applied":
396
- continue
397
- if not [x for x in listy(dec, "touches") if str(x).strip()]:
398
- r.fail("V8", str(dec.get("id")),
399
- "berstatus applied tetapi `touches` kosongpenerapan tanpa jejak berkas")
400
-
401
-
402
- def v9(c: Corpus, r: Result) -> None:
403
- passed = {str(g) for g in (c.index.get("gates_passed") or [])}
404
- for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
405
- fm = frontmatter(path) or {}
406
- if str(fm.get("status")) != "locked":
407
- continue
408
- gate = str(fm.get("locked_at_gate") or "")
409
- if gate not in passed:
410
- rel = path.relative_to(c.root).as_posix()
411
- r.fail("V9", rel, f"berstatus locked tetapi gate `{gate or '?'}` tidak tercatat lulus")
412
-
413
-
414
- def v11(c: Corpus, r: Result) -> None:
415
- per_wave: dict[str, list[dict]] = {}
416
- for wave, _, story in c.stories():
417
- per_wave.setdefault(str(wave.get("id")), []).append(story)
418
-
419
- for wid in sorted(per_wave):
420
- items = per_wave[wid]
421
- edges = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
422
-
423
- def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
424
- seen = seen or set()
425
- if a in seen:
426
- return False
427
- seen.add(a)
428
- if b in edges.get(a, set()):
429
- return True
430
- return any(reaches(n, b, seen) for n in sorted(edges.get(a, set())))
431
-
432
- for i, left in enumerate(items):
433
- for right in items[i + 1:]:
434
- lid, rid = str(left.get("id")), str(right.get("id"))
435
- shared = sorted(set(listy(left, "touches")) & set(listy(right, "touches")))
436
- if not shared:
437
- continue
438
- if reaches(lid, rid) or reaches(rid, lid):
439
- continue
440
- r.fail("V11", f"{lid} + {rid}",
441
- f"berbagi touches {shared} tanpa relasi depends_on — MUST NOT paralel")
442
-
443
-
444
- def v12(c: Corpus, r: Result) -> None:
445
- """Pendaftaran LC diperiksa saat wave DITUTUP, bukan sebelum story `ready-for-dev`.
446
-
447
- Bentuk lama menuntut jawabannya pada saat informasinya paling tipis. Di penutupan wave,
448
- tiap `touches` sudah punya wilayah dan tiap boundary sudah punya nama.
449
- """
450
- areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
451
- lcs_per_pc: dict[str, int] = {}
452
- for lc in c.lcs:
453
- lcs_per_pc[str(lc.get("component"))] = lcs_per_pc.get(str(lc.get("component")), 0) + 1
454
- pc_by_id = {str(x.get("id")): x for x in c.pcs}
455
-
456
- seen: set[tuple[str, str]] = set()
457
- for wave, _, story in c.stories():
458
- if str(wave.get("status")) != "closed":
459
- continue
460
- for area in listy(story, "touches"):
461
- if area not in areas:
462
- r.fail("V12", str(story.get("id")),
463
- f"wave-nya sudah ditutup, tetapi `{area}` tidak terdaftar sebagai `area` "
464
- f"di components.yaml")
465
- pid = str(story.get("component") or "")
466
- row = pc_by_id.get(pid)
467
- if row is None or (str(wave.get("id")), pid) in seen:
468
- continue
469
- seen.add((str(wave.get("id")), pid))
470
- if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
471
- r.fail("V12", f"{wave.get('id')} / {pid}",
472
- f"wave tertutup dan komponen ber-mode `{c.mode_of(row)}` belum punya satu pun "
473
- f"`LC` terdaftar")
474
-
475
-
476
- LENS_BY_RISK = {
477
- "low": {"edge-case-hunter"},
478
- "medium": {"edge-case-hunter"},
479
- "high": set(),
480
- }
481
- FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
482
-
483
-
484
- def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
485
- if not isinstance(block, dict) or not block.get("sha") or not block.get("date"):
486
- r.fail("V13", rel, "tidak membawa jejak `reviewed` berisi date dan sha")
487
- return
488
- lenses = {str(x) for x in (block.get("lenses") or [])}
489
- if not lenses:
490
- r.fail("V13", rel, "jejak `reviewed` tidak menyebut satu lensa pun")
491
- missing = sorted(need - lenses)
492
- if missing:
493
- r.fail("V13", rel,
494
- f"lensa {missing} MUST ikutitu yang dituntut `risk_accepted` komponennya")
495
-
496
-
497
- def _only_reviewed_block(diff: str) -> bool:
498
- """True bila diff sebuah commit atas satu berkas HANYA menyentuh blok `reviewed:`.
499
-
500
- Inilah perbaikan OQ-146. V13 lama membandingkan `sha` dengan commit terakhir yang mengubah
501
- berkasnyatetapi commit yang MENULISKAN blok `reviewed:` selalu mengubah berkasnya, dan
502
- menulis hash diri sendiri ke dalam sebuah commit git mustahil secara kriptografis. Akibatnya
503
- setiap artefak yang baru distempel langsung terbaca "review basi", selamanya.
504
- """
505
- touched = [ln for ln in diff.splitlines()
506
- if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
507
- if not touched:
508
- return True
509
- for ln in touched:
510
- body = ln[1:].strip()
511
- if not body or body.startswith("#"):
512
- continue
513
- if not body.startswith(FRONTMATTER_KEYS):
514
- return False
515
- return True
516
-
517
-
518
- def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
519
- """Commit pertama sesudah `sha` yang mengubah berkas ini karena alasan selain stempel review."""
520
- log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
521
- if not log:
522
- return None
523
- for head in log.splitlines():
524
- head = head.strip()
525
- if not head:
526
- continue
527
- diff = git(c.root, "show", "--format=", "--unified=0", head, "--", rel)
528
- if diff is None:
529
- return head
530
- if _only_reviewed_block(diff):
531
- continue
532
- return head
533
- return None
534
-
535
-
536
- def v13(c: Corpus, r: Result) -> None:
537
- """Jejak review mengikuti INTENSITAS review, bukan kedalaman dokumen.
538
-
539
- Dipersempit ke komponen ber-`risk_accepted` `low` atau `medium`. Pada `high` pemilik sudah
540
- menyatakan menerima risikonya, dan menuntut jejak di situ adalah pembukuan tanpa pembeli.
541
- """
542
- watched = [pc for pc in c.pcs
543
- if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
544
- if not watched:
545
- r.skip("V13", "tidak ada komponen ber-risk_accepted low atau medium — tidak ada yang dijaga")
546
- targets: list[tuple[Path, set[str]]] = []
547
- if watched:
548
- targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
549
- for pc in watched:
550
- pid = str(pc.get("id"))
551
- need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
552
- # SRS ada dan bermakna pada SETIAP mode: ia membawa Actor Register dan UC Catalogue, dan
553
- # keduanya lahir di G3 yang tidak disentuh knob kedalaman.
554
- targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
555
- # SDD hanya dijaga ketika ia PUNYA ISI untuk dijaga. Dua keadaan mengecualikannya, dan
556
- # keduanya keadaan SELESAI bukan keadaan tertinggal:
557
- # mode: catalog skeleton adalah bentuk akhirnya; G4 dilewati di situ
558
- # g4_passed belum ada G4 belum dijalankan, jadi tidak ada satu bagian pun yang tertulis
559
- # Menuntut jejak review atas berkas yang isinya 13 baris komentar template adalah teater —
560
- # persis upacara yang rancang ulang ini cabut, dan review yang tidak mungkin gagal tidak
561
- # membuktikan apa pun. Begitu G4 lewat, tuntutannya kembali dan ia bermakna.
562
- passed = str(pc.get("g4_passed") or "").strip().lower()
563
- if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
564
- targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
565
-
566
- for path, need in targets:
567
- fm = frontmatter(path)
568
- if fm is None:
569
- continue # belum lahirbukan urusan V13
570
- rel = path.relative_to(c.root).as_posix()
571
- _reviewed_ok(r, rel, fm.get("reviewed"), need)
572
- block = fm.get("reviewed")
573
- if isinstance(block, dict) and block.get("sha"):
574
- stale = _stale_since(c, rel, str(block["sha"]))
575
- if stale:
576
- r.fail("V13", rel,
577
- f"berubah di {stale[:7]} sesudah direview di {str(block['sha'])[:7]} — "
578
- f"review basi")
579
-
580
- for wave in c.wave_list:
581
- if not wave.get("epics"):
582
- continue
583
- _reviewed_ok(r, f"waves.yaml:{wave.get('id')}", wave.get("spec_reviewed"),
584
- {"edge-case-hunter"})
585
-
586
-
587
- def cap_stories(c: Corpus) -> dict[str, list[dict]]:
588
- """CAP -> story, ditelusuri CAP -> FR -> UC -> story. Tanpa git, tanpa timeline."""
589
- frs_of: dict[str, list[str]] = {}
590
- for fr in c.frs:
591
- frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
592
- ucs_of: dict[str, list[str]] = {}
593
- for uc in c.ucs:
594
- for fid in listy(uc, "satisfies"):
595
- ucs_of.setdefault(fid, []).append(str(uc.get("id")))
596
- out: dict[str, list[dict]] = {}
597
- for cap in c.caps:
598
- cid = str(cap.get("id"))
599
- wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
600
- out[cid] = [s for _, _, s in c.stories()
601
- if wanted & set(listy(s, "satisfies"))]
602
- return out
603
-
604
-
605
- def v14(c: Corpus, r: Result, asof: dt.date) -> None:
606
- """Keterlambatan dihitung dari registry sendiri — timeline hanya memperkuat, bukan syarat."""
607
- by_cap = cap_stories(c)
608
- timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
609
- listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
610
- if str(row.get("state")) == "overdue"} if timeline else None
611
- if listed is None:
612
- r.skip("V14", "generated/timeline.yaml belum adaketerlambatan tetap dihitung "
613
- "dari registry, tetapi kehadirannya di generated/report tidak diperiksa")
614
-
615
- for cap in c.caps:
616
- cid = str(cap.get("id"))
617
- end = str(cap.get("planned_end") or "")
618
- if not end:
619
- continue
620
- try:
621
- due = dt.date.fromisoformat(end)
622
- except ValueError:
623
- r.fail("V14", cid, f"`planned_end` `{end}` bukan tanggal ISO")
624
- continue
625
- items = by_cap.get(cid, [])
626
- closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
627
- if closed or due >= asof:
628
- continue
629
- late = (asof - due).days
630
- if listed is not None and cid not in listed:
631
- r.fail("V14", cid, f"lewat {late} hari tanpa realisasi, dan tidak disebut "
632
- f"`overdue` di generated/timeline")
633
- else:
634
- r.fail("V14", cid, f"lewat {late} hari tanpa realisasi tertutup")
635
-
636
-
637
- def v15(c: Corpus, r: Result) -> None:
638
- for cap in c.caps:
639
- if not str(cap.get("goal") or "").strip():
640
- r.fail("V15", str(cap.get("id")), "tidak menunjuk `goal`")
641
- for fr in c.frs:
642
- if not str(fr.get("capability") or "").strip():
643
- r.fail("V15", str(fr.get("id")), "tidak menunjuk `capability`")
644
-
645
-
646
- def v16(c: Corpus, r: Result) -> None:
647
- for path in sorted((c.root / ".control/memlog").glob("*.md")):
648
- fm = frontmatter(path) or {}
649
- rel = path.relative_to(c.root).as_posix()
650
- artifact = str(fm.get("artifact") or "")
651
- if not artifact:
652
- r.fail("V16", rel, "tidak punya `artifact:` di frontmatter")
653
- elif not (c.root / artifact).exists():
654
- r.fail("V16", rel, f"`artifact:` menunjuk `{artifact}` yang tidak ada")
655
- for layer in (".what", ".how"):
656
- for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
657
- r.fail("V16", stray.relative_to(c.root).as_posix(),
658
- "memlog MUST NOT tinggal di dalam korpus")
659
-
660
-
661
- def v17(c: Corpus, r: Result) -> None:
662
- for wave in c.wave_list:
663
- wid = str(wave.get("id"))
664
- if not str(wave.get("release") or "").strip():
665
- r.fail("V17", wid, "tidak menyebut `release`")
666
- slugs = listy(wave, "prd")
667
- if not slugs:
668
- r.fail("V17", wid, "tidak menyebut `prd`")
669
- for slug in slugs:
670
- if not (c.root / ".what/_prd" / slug).is_dir():
671
- r.fail("V17", wid, f"`prd: {slug}` tidak punya folder .what/_prd/{slug}/")
672
-
673
-
674
- def v18(c: Corpus, r: Result) -> None:
675
- for _, _, story in c.stories():
676
- sid = str(story.get("id"))
677
- folder = str(story.get("spec_folder") or "").strip()
678
- if not folder:
679
- r.fail("V18", sid, "tidak menyebut `spec_folder`")
680
- continue
681
- matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
682
- if not matches:
683
- r.fail("V18", sid, f"tidak punya story file di {folder}stories/")
684
- continue
685
- fm = frontmatter(matches[0]) or {}
686
- if not str(fm.get("status") or "").strip():
687
- r.fail("V18", sid, "story file tidak punya `status` di frontmatter")
688
-
689
-
690
- def v19(c: Corpus, r: Result) -> None:
691
- """Arsip retrospektif diikatkan ke UKURAN WAVE, bukan ke `mode`.
692
-
693
- Wajib pada wave `L`; advisory pada `S` dan `M`. Kedalaman dokumen dan volume kerja adalah dua
694
- hal berbeda, dan menuntut retrospektif atas wave tiga story adalah upacara.
695
- """
696
- names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
697
- advisory: list[str] = []
698
- for wave in c.wave_list:
699
- if str(wave.get("status")) != "closed":
700
- continue
701
- wid = str(wave.get("id"))
702
- if any(wid in name for name in names):
703
- continue
704
- if str(wave.get("size")).upper() == "L":
705
- r.fail("V19", wid, "wave `L` tertutup tanpa `RTR-` di .control/reports/")
706
- else:
707
- advisory.append(wid)
708
- if advisory:
709
- r.skip("V19", "advisory — wave S/M tertutup tanpa RTR-: " + ", ".join(sorted(advisory)))
710
- else:
711
- r.skip("V19", "hanya baris RTR- yang diperiksa mekanis; sisa distilasi dijaga wdi-build")
712
-
713
-
714
- PLATFORM = "_platform"
715
- CROSS_CUTTING = ".how/_platform/cross-cutting.md"
716
- PLATFORM_DATA_HEADING = "Milik platform"
717
-
718
-
719
- def v21(c: Corpus, r: Result) -> None:
720
- """Satu entitas domain punya TEPAT SATU pemilik yang berwenang menulisnya.
721
-
722
- Pemiliknya sebuah Product Component, ATAU `_platform` untuk entitas yang tidak ada satu pun
723
- janji komponen di belakangnya. Bentrokan semantik antar-PRD sudah pernah terjadi sungguhan: satu
724
- komponen mengambil rentang penomoran business rule dari deret global milik bersama. Dua `FR`
725
- yang mengklaim wewenang tulis atas entitas yang sama, tanpa salah satunya menunjuk yang lain,
726
- adalah defect saat ditulis.
727
-
728
- `_platform` BUKAN Product Component dan karena itu tidak punya `mode`, `risk_accepted`, SRS,
729
- maupun G4. Ia rumah kepemilikan, bukan irisan domain — dan supaya ia tidak jadi tempat
730
- pembuangan, tiap entitas yang ia klaim MUST dijelaskan di `cross-cutting.md`: kalau platform
731
- memiliki data, platform yang mendokumentasikannya.
732
- """
733
- owner: dict[str, str] = {}
734
- for pc in c.pcs:
735
- pid = str(pc.get("id"))
736
- for entity in listy(pc, "owns"):
737
- if entity in owner and owner[entity] != pid:
738
- r.fail("V21", entity,
739
- f"diklaim `owns` oleh `{owner[entity]}` dan `{pid}` — satu entitas MUST "
740
- f"punya tepat satu pemilik")
741
- else:
742
- owner.setdefault(entity, pid)
743
-
744
- platform = listy(c.components, "platform_owns")
745
- for entity in platform:
746
- if entity in owner:
747
- r.fail("V21", entity,
748
- f"diklaim `platform_owns` dan juga `owns` milik `{owner[entity]}` — "
749
- f"`{PLATFORM}` bukan jalan kedua bagi entitas yang sudah punya pemilik")
750
- else:
751
- owner[entity] = PLATFORM
752
-
753
- _platform_documented(c, r, platform + _platform_inventory_rows(c))
754
-
755
- cap_home = {str(x.get("id")): str(x.get("component") or "") for x in c.caps}
756
- for fr in c.frs:
757
- fid = str(fr.get("id"))
758
- home = str(fr.get("component") or cap_home.get(str(fr.get("capability", "")), ""))
759
- for entity in listy(fr, "writes"):
760
- own = owner.get(entity)
761
- if not own or not home or own == home:
762
- continue
763
- if own == PLATFORM:
764
- # Platform tidak punya `FR`, jadi tidak ada yang bisa ditunjuk `defers_to`. Yang
765
- # menggantikan "satu penulis" di sini adalah SATU BENTUK YANG TERDOKUMENTASI, dan
766
- # itu diperiksa _platform_documented di atas.
767
- continue
768
- if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
769
- r.fail("V21", fid,
770
- f"menjanjikan penulisan `{entity}` yang dimiliki `{own}`, tanpa `defers_to` "
771
- f"menunjuk `FR` milik pemiliknya")
772
-
773
-
774
- def _platform_inventory_rows(c: Corpus) -> list[str]:
775
- """Baris inventaris yang dimiliki `_platform`, dibaca dari `platform_rows:` tiap inventaris.
776
-
777
- `_platform` adalah nilai sah di SETIAP posisi kepemilikan, jadi penjaganya berlaku di setiap
778
- posisi juga: apa pun yang ia miliki MUST terdokumentasi di `cross-cutting.md`.
779
- """
780
- out: list[str] = []
781
- for kind in ("db", "api", "screen"):
782
- path = c.root / f".how/_platform/inventory-{kind}.md"
783
- fm = frontmatter(path)
784
- if not fm:
785
- continue
786
- out += [str(x) for x in (fm.get("platform_rows") or [])]
787
- return out
788
-
789
-
790
- def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
791
- """Tiap entitas ber-`platform_owns` MUST disebut di `cross-cutting.md`.
792
-
793
- Dilewati selama berkasnya belum memuat bagian itu: `cross-cutting.md` adalah keluaran G3, dan
794
- artefak yang gate berikutnya lahirkan MUST NOT dilaporkan hilang.
795
- """
796
- if not entities:
797
- return
798
- path = c.root / CROSS_CUTTING
799
- text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
800
- if PLATFORM_DATA_HEADING.lower() not in text.lower():
801
- r.skip("V21", f"`{CROSS_CUTTING}` belum punya bagian `{PLATFORM_DATA_HEADING}` — "
802
- f"{len(entities)} entitas ber-platform_owns belum terdokumentasi: "
803
- + ", ".join(sorted(entities)))
804
- return
805
- for entity in sorted(entities):
806
- if entity not in text:
807
- r.fail("V21", entity,
808
- f"diklaim `platform_owns` tetapi tidak disebut di `{CROSS_CUTTING}` — "
809
- f"platform yang memiliki data MUST mendokumentasikannya")
810
-
811
-
812
- def v22(c: Corpus, r: Result) -> None:
813
- """Sebuah wave MUST NOT menyentuh komponen yang G4-nya belum lewat dan mode-nya bukan catalog.
814
-
815
- `catalog` melewati G4 dengan sengaja, jadi ia bukan pengecualiania bagian aturannya.
816
- """
817
- pc_by_id = {str(x.get("id")): x for x in c.pcs}
818
- seen: set[tuple[str, str]] = set()
819
- for wave, _, story in c.stories():
820
- pid = str(story.get("component") or "")
821
- row = pc_by_id.get(pid)
822
- if row is None:
823
- continue
824
- key = (str(wave.get("id")), pid)
825
- if key in seen:
826
- continue
827
- seen.add(key)
828
- mode = c.mode_of(row)
829
- if mode == "catalog":
830
- continue
831
- if mode not in MODES:
832
- r.fail("V22", pid, f"`mode: {mode}` bukan salah satu dari {list(MODES)}")
833
- continue
834
- passed = row.get("g4_passed")
835
- if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
836
- r.fail("V22", f"{wave.get('id')} / {pid}",
837
- f"wave menyentuh komponen ber-mode `{mode}` yang `g4_passed`-nya belum diisi")
838
-
839
-
840
- def v23(c: Corpus, r: Result) -> None:
841
- """`risk_accepted: high` pada komponen sensitif menuntut sebuah `DEC-` di `risk_accepted_by`.
842
-
843
- Pada komponen yang tidak menyentuh apa pun dari daftar itu, `high` GRATIS. Kontrolnya
844
- pengungkapan, bukan veto — pemilik tetap boleh memilih cepat, tapi tidak tanpa tahu apa yang ia
845
- taruhkan.
846
- """
847
- known = {str(x.get("id")) for x in c.decs}
848
- for pc in c.pcs:
849
- pid = str(pc.get("id"))
850
- if str(pc.get("risk_accepted") or "").strip() != "high":
851
- continue
852
- note = str(pc.get("risk_note") or "").lower()
853
- hits = sorted({m for m in SENSITIVE_MARKERS if m in note})
854
- if not hits:
855
- continue
856
- ref = str(pc.get("risk_accepted_by") or "").strip()
857
- if not ref:
858
- r.fail("V23", pid,
859
- f"`risk_accepted: high` sementara `risk_note` menyebut {hits}, tanpa "
860
- f"`risk_accepted_by` menunjuk sebuah `DEC-` bertipe risk-acceptance")
861
- elif ref not in known:
862
- r.fail("V23", pid, f"`risk_accepted_by: {ref}` tidak ada di decisions.yaml")
863
-
864
-
865
- def v20(c: Corpus, r: Result) -> None:
866
- needs_link = {"requirement", "architecture"}
867
- for defect in c.defect_list:
868
- did = str(defect.get("id"))
869
- cause = str(defect.get("root_cause") or "")
870
- if cause not in needs_link:
871
- continue
872
- if not listy(defect, "violates"):
873
- r.fail("V20", did, f"ber-root_cause `{cause}` tetapi `violates` kosong")
874
- if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
875
- r.fail("V20", did,
876
- f"ditutup sebagai fixed dengan root_cause `{cause}` tanpa `DEC-` yang menyertainya")
877
-
878
-
879
- # Berkas yang MENGGAMBARKAN masa lalu, bukan MENYATAKAN apa yang berlaku. Kutipan menggantung di sini
880
- # bukan temuan — corpus-guide.md memiliki aturannya, dan menulisnya ulang akan memalsukan riwayat.
881
- PAST_RECORD = (
882
- ".control/memlog/",
883
- ".control/decisions/",
884
- ".control/questions/answered.md",
885
- ".control/reports/",
886
- )
887
- # Korpus yang §25 bekukan apa adanya. Kutipannya ke prototipe yang sudah dipensiunkan disahkan DEC-016.
888
- FROZEN = (".what/",)
889
- # Path yang sebuah run LAHIRKAN, bukan yang sebuah dokumen kutip. Aturan yang menyatakan "memlog pass
890
- # ini mendarat di X" menyebut TUJUAN; menuntut X sudah ada berarti menuntut run-nya sudah jalan.
891
- DESTINATION = (
892
- ".control/memlog/",
893
- ".control/meetings/",
894
- ".control/reports/",
895
- "_bmad-output/",
896
- )
897
-
898
- CITE_RE = re.compile(
899
- r"`((?:\.constitution|\.control|\.what|\.how|_bmad-output|\.work|src|web|public|deploy)"
900
- r"/[A-Za-z0-9_./-]+\.(?:md|yaml|yml|py|go|tsx|ts|js|mjs|sql|html|css|json))`")
901
-
902
-
903
- def v24(c: Corpus, r: Result) -> None:
904
- """Kutipan path di dalam dokumen yang MENYATAKAN apa yang berlaku MUST resolve.
905
-
906
- Ini paruh mekanis dari Evidence check `wdi-reconcile`, dan ia satu-satunya cara mengetahui bahwa
907
- sebuah migrasi tetap tuntas. Kelas kegagalannya khas: sebuah berkas dihapus atau dipindahkan,
908
- sementara baris routing yang menunjuk ke arahnya tinggal — tidak ada satu pun validator lain yang
909
- melihatnya, sebab tidak ada id yang bergerak.
910
-
911
- Yang DILEWATI dengan sengaja: berkas yang menggambarkan masa lalu, dan korpus yang dibekukan.
912
- Sebuah Trace `DEC-` yang menyebut bahan yang sudah dipensiunkan menggambarkan apa yang dibaca pada
913
- tanggal itu; melaporkannya akan menuntut riwayat ditulis ulang agar cocok dengan masa kini.
914
- """
915
- scanned = 0
916
- for path in sorted(c.root.rglob("*.md")) + sorted(c.root.rglob("*.yaml")):
917
- rel = path.relative_to(c.root).as_posix()
918
- if rel.startswith((".git/", "node_modules/", "_bmad-output/", ".claude/skills/bmad-")):
919
- continue
920
- if rel.startswith(PAST_RECORD) or rel.startswith(FROZEN):
921
- continue
922
- scanned += 1
923
- text = path.read_text(encoding="utf-8", errors="replace")
924
- for cited in sorted(set(CITE_RE.findall(text))):
925
- if "<" in cited or "{" in cited:
926
- continue # placeholder, bukan path
927
- if cited.startswith(DESTINATION):
928
- continue
929
- if not (c.root / cited).exists():
930
- r.fail("V24", rel, f"mengutip `{cited}` yang tidak ada")
931
- if not scanned:
932
- r.skip("V24", "tidak ada berkas yang dipindai")
933
-
934
-
935
- CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
936
-
937
-
938
- def map_container_headings(root: Path) -> list[str] | None:
939
- """Heading `### x` di bawah `## Containers` pada peta kode. None bila petanya tidak ada."""
940
- path = root / ".control" / "structure-codebase.md"
941
- if not path.exists():
942
- return None
943
- text = path.read_text(encoding="utf-8", errors="replace")
944
- start = text.find("\n## Containers")
945
- if start < 0:
946
- return []
947
- rest = text[start + 1:]
948
- nxt = re.search(r"^##\s+(?!#)", rest[len("## Containers"):], re.M)
949
- if nxt:
950
- rest = rest[:len("## Containers") + nxt.start()]
951
- return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
952
-
953
-
954
- def v25(c: Corpus, r: Result) -> None:
955
- """`built` sebuah container dan keempat konsekuensinya, plus matriks PC x container.
956
-
957
- Sebuah container ADA di dalam boundary entah kita yang menulis isinya atau bukan, dan itulah yang
958
- dulu membuat aturannya tak bisa dipenuhi: `structure-guide.md` menuntut tiap heading peta kode cocok
959
- dengan registry, sementara basis data dan web server MUST terdaftar dan MUST NOT punya heading.
960
- `built` memisahkan keduanya, dan pemeriksaan ini yang membuat pemisahan itu berlaku alih-alih
961
- diulang argumennya tiap proyek. `DEC-017` merekam definisinya.
962
-
963
- Yang runtime-nya bukan kita yang deploy adalah external system: ia hidup di C4 L1 dan MUST NOT
964
- terdaftar di sini sama sekali — ketiadaannya di registry itulah pemeriksaannya.
965
- """
966
- containers = rows(c.components, "containers")
967
- if not containers:
968
- r.skip("V25", "`containers:` belum terdaftar")
969
- return
970
-
971
- built: dict[str, bool] = {}
972
- for ctr in containers:
973
- cid = str(ctr.get("id") or "").strip()
974
- if not cid:
975
- r.fail("V25", "containers", "ada container tanpa `id`")
976
- continue
977
- flag = ctr.get("built")
978
- if not isinstance(flag, bool):
979
- r.fail("V25", cid, "`built` MUST bool true bila isinya kita tulis, false bila implementasinya orang lain")
980
- continue
981
- built[cid] = flag
982
-
983
- # (1) heading peta kode = TEPAT container `built: true`
984
- headings = map_container_headings(c.root)
985
- if headings is None:
986
- r.fail("V25", ".control/structure-codebase.md", "peta kode tidak ada, jadi heading container tidak dapat diadu")
987
- else:
988
- for h in headings:
989
- if h not in built:
990
- r.fail("V25", f"peta kode §{h}", "heading bukan container terdaftar — daftarkan, atau ia bukan container")
991
- elif not built[h]:
992
- r.fail("V25", f"peta kode §{h}", "`built: false` MUST NOT punya heading — tidak ada kode kita di dalamnya")
993
- for cid, flag in sorted(built.items()):
994
- if flag and cid not in headings:
995
- r.fail("V25", cid, "`built: true` MUST punya heading di peta kode")
996
-
997
- # (2) `built: false` MUST NOT dipakai sebuah LC, dan (3) MUST NOT muncul di `containers:` sebuah PC
998
- for lc in c.lcs:
999
- ctr = str(lc.get("container") or "").strip()
1000
- if ctr and built.get(ctr) is False:
1001
- r.fail("V25", str(lc.get("id") or "LC-?"), f"menyebut container `{ctr}` yang `built: false`")
1002
- elif ctr and ctr not in built:
1003
- r.fail("V25", str(lc.get("id") or "LC-?"), f"menyebut container `{ctr}` yang tidak terdaftar")
1004
-
1005
- # (4) matriks PC x container SSOT-nya field ini, dan ia MUST lengkap di G3
1006
- for pc in c.pcs:
1007
- pid = str(pc.get("id") or "?")
1008
- listed = listy(pc, "containers")
1009
- if not listed:
1010
- r.fail("V25", pid, "`containers:` kosong tiap PC MUST hidup di setidaknya satu container (utang G3)")
1011
- continue
1012
- for ctr in listed:
1013
- if ctr not in built:
1014
- r.fail("V25", pid, f"`containers:` menyebut `{ctr}` yang tidak terdaftar")
1015
- elif not built[ctr]:
1016
- r.fail("V25", pid, f"`containers:` menyebut `{ctr}` yang `built: false` — data hidup di sana menurut definisi, jadi barisnya tidak memberi tahu apa pun")
1017
-
1018
- # (5) L3hanya untuk `built: true`, dan hanya yang memuat lebih dari satu PC
1019
- pcs_per: dict[str, list[str]] = {}
1020
- for pc in c.pcs:
1021
- for ctr in listy(pc, "containers"):
1022
- pcs_per.setdefault(ctr, []).append(str(pc.get("id") or "?"))
1023
- for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
1024
- cid = path.name[len("c4-l3-"):-len(".md")]
1025
- if cid not in built:
1026
- r.fail("V25", path.relative_to(c.root).as_posix(),
1027
- f"L3 untuk `{cid}` yang bukan container terdaftar")
1028
- elif not built[cid]:
1029
- r.fail("V25", path.relative_to(c.root).as_posix(),
1030
- f"`{cid}` `built: false` MUST NOT punya L3 — tak satu kotak di dalamnya kita yang gambar")
1031
- for cid, pids in sorted(pcs_per.items()):
1032
- if built.get(cid) and len(pids) > 1:
1033
- l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
1034
- if not l3.exists():
1035
- r.fail("V25", cid, f"memuat {len(pids)} PC, jadi `c4-l3-{cid}.md` MUST ada")
1036
-
1037
-
1038
- UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
1039
-
1040
- # Nilai kolom `critical` dicocokkan mesin, jadi ia machine-facing dan bentuk kanoniknya English `yes`.
1041
- # `ya` tetap diterima: sebuah korpus yang menulisnya sebelum aturan ini berlaku MUST NOT dipaksa migrasi
1042
- # hanya supaya sebuah regex lebih rapi. Batas kata mencegah `ya` mencocoki kata lain.
1043
- CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
1044
-
1045
-
1046
- def v26(c: Corpus, r: Result) -> None:
1047
- """Katalog UC di tiap SRS MUST sepakat dengan `usecases.yaml` — id-nya DAN `critical`-nya.
1048
-
1049
- Ini celah yang paling mahal dari semua yang ditutup lintasan ini, sebab ia satu-satunya yang
1050
- **sudah terjadi dan tidak satu pun validator melihatnya.** Step 16 menurunkan ulang `critical`
1051
- di registry dengan definisi yang dipersempit — uang, data pribadi, tindakan tak-terbalikkan — dan
1052
- ketujuh tabel katalog di SRS tidak ikut. Dua puluh enam baris berselisih, dan selisihnya baru
1053
- ketahuan ketika seorang manusia membaca kalimat "sembilan di antaranya critical" di SRS-admin
1054
- sementara registry menyimpan tiga.
1055
-
1056
- Registry-nya SSOT. Tabel di SRS adalah rumah permanen katalog untuk seorang pembaca, dan dua rumah
1057
- untuk satu fakta hanya aman kalau ada yang mengadu keduanya. Ini yang mengadu.
1058
-
1059
- Yang TIDAK diperiksa di sini: judul dan aktor. Keduanya prosa, dan prosa yang berbeda kata bukan
1060
- prosa yang berbeda artimengadunya akan melaporkan gaya sebagai cacat.
1061
- """
1062
- reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
1063
- reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
1064
- checked = 0
1065
- for pc in c.pcs:
1066
- pid = str(pc.get("id"))
1067
- path = c.root / f".what/{pid}/SRS-{pid}.md"
1068
- if not path.exists():
1069
- continue
1070
- checked += 1
1071
- text = path.read_text(encoding="utf-8", errors="replace")
1072
- seen: set[str] = set()
1073
- for match in UC_ROW_RE.finditer(text):
1074
- uid = match.group(1)
1075
- cells = [x.strip() for x in match.group(2).split("|")]
1076
- if len(cells) < 4:
1077
- continue
1078
- seen.add(uid)
1079
- if uid not in reg:
1080
- r.fail("V26", f"{pid}/{uid}", "ada di katalog SRS tetapi tidak di `usecases.yaml`")
1081
- continue
1082
- if reg_pc[uid] != pid:
1083
- r.fail("V26", f"{pid}/{uid}",
1084
- f"registry menaruhnya di `{reg_pc[uid]}`, bukan di komponen ini")
1085
- marked = CRITICAL_YES.search(cells[3]) is not None
1086
- if marked != reg[uid]:
1087
- r.fail("V26", f"{pid}/{uid}",
1088
- f"`critical` in the SRS {'yes' if marked else 'no'}, "
1089
- f"in the registry {'yes' if reg[uid] else 'no'}")
1090
- for uid, owner in sorted(reg_pc.items()):
1091
- if owner == pid and uid not in seen:
1092
- r.fail("V26", f"{pid}/{uid}", "ada di `usecases.yaml` tetapi tidak di katalog SRS")
1093
- if not checked:
1094
- r.skip("V26", "tidak ada SRS yang dapat dibaca")
1095
-
1096
-
1097
- def v27(c: Corpus, r: Result) -> None:
1098
- """Tiap berkas di kamar custom MUST menyatakan dirinya, dan pembantahan MUST punya keputusan.
1099
-
1100
- Kamar `.constitution/project/` ada supaya aturan khusus produk punya rumah yang `update` tidak
1101
- timpa dan `promote` tidak terbitkan. Ongkos yang datang bersamanya: ia juga tempat paling mudah
1102
- untuk melanggar aturan generic tanpa jejak. Frontmatter-nya yang menahan itu.
1103
-
1104
- Sebuah berkas di sini MAY mempersempit atau menambah tanpa menyebut apa pun. Untuk MEMBANTAH
1105
- aturan generic ia MUST menyebutnya di `overrides:` dan membawa `decision:` — sebab metode yang
1106
- boleh dibantah tanpa keputusan berhenti dapat dipercaya di repo berikutnya.
1107
-
1108
- `README.md` kamar dilewati: ia dikarang di paket, bukan di produk.
1109
- """
1110
- room = c.root / ".constitution" / "project"
1111
- if not room.is_dir():
1112
- r.skip("V27", "kamar `.constitution/project/` belum ada — ia disemai saat install")
1113
- return
1114
- files = [p for p in sorted(room.rglob("*.md")) if p.name != "README.md"]
1115
- if not files:
1116
- r.skip("V27", "kamar `.constitution/project/` kosong, dan itu keadaan yang sah — "
1117
- "aturan generic MUST NOT dipindahkan ke sini supaya kamarnya terpakai")
1118
- return
1119
- dec_ids = {str(d.get("id")) for d in c.decs}
1120
- for path in files:
1121
- rel = path.relative_to(c.root).as_posix()
1122
- fm = frontmatter(path)
1123
- if fm is None:
1124
- r.fail("V27", rel, "tidak punya frontmatter")
1125
- continue
1126
- if str(fm.get("scope") or "").strip() != "project":
1127
- r.fail("V27", rel, "`scope:` MUST berisi tepat `project`")
1128
- if not str(fm.get("purpose") or "").strip():
1129
- r.fail("V27", rel, "`purpose:` kosong — satu baris: aturan ini menjaga apa")
1130
- over = str(fm.get("overrides") or "").strip()
1131
- dec = str(fm.get("decision") or "").strip()
1132
- if over:
1133
- if not (c.root / over).exists():
1134
- r.fail("V27", rel, f"`overrides:` menunjuk `{over}` yang tidak ada — "
1135
- f"aturan yang dibantah mungkin sudah hilang")
1136
- if not dec:
1137
- r.fail("V27", rel, "membantah aturan generic tanpa `decision:` "
1138
- "pembantahan MUST punya `DEC-` yang memutuskannya")
1139
- elif dec not in dec_ids:
1140
- r.fail("V27", rel, f"`decision: {dec}` tidak terdaftar di decisions.yaml")
1141
- elif dec:
1142
- r.fail("V27", rel, "`decision:` terisi tanpa `overrides:` — "
1143
- "sebutkan aturan mana yang dibantah, atau cabut `decision:`")
1144
-
1145
-
1146
- def run_checks(c: Corpus, asof: dt.date) -> Result:
1147
- r = Result()
1148
- for fn in (v1, v2, v3, v4, v5, v6, v7, v8, v9, v11, v12, v13, v15, v16, v17, v18, v19, v20,
1149
- v21, v22, v23, v24, v25, v26, v27):
1150
- fn(c, r)
1151
- v14(c, r, asof)
1152
- return r
1153
-
1154
-
1155
- # ------------------------------------------------------------------ generator
1156
-
1157
-
1158
- def _story_status(c: Corpus, story: dict) -> str:
1159
- folder = str(story.get("spec_folder") or "").strip()
1160
- if not folder:
1161
- return "unknown"
1162
- matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
1163
- if not matches:
1164
- return "unknown"
1165
- return str((frontmatter(matches[0]) or {}).get("status") or "unknown")
1166
-
1167
-
1168
- def gen_components(c: Corpus) -> dict:
1169
- return {
1170
- "product_components": [
1171
- {"id": pc.get("id"), "name": pc.get("name"),
1172
- "containers": listy(pc, "containers"),
1173
- "logical_components": sorted(
1174
- str(lc.get("id")) for lc in c.lcs
1175
- if str(lc.get("component")) == str(pc.get("id")))}
1176
- for pc in c.pcs
1177
- ],
1178
- "logical_components": [
1179
- {"id": lc.get("id"), "type": lc.get("type"), "component": lc.get("component"),
1180
- "area": lc.get("area"), "owner": lc.get("owner")}
1181
- for lc in c.lcs
1182
- ],
1183
- }
1184
-
1185
-
1186
- def gen_risks(c: Corpus) -> dict:
1187
- return {"risks": [
1188
- {"id": x.get("id"), "impact": x.get("impact"), "likelihood": x.get("likelihood"),
1189
- "owner": x.get("owner"), "status": x.get("status"),
1190
- "pivot_trigger": x.get("pivot_trigger")}
1191
- for x in rows(c.risks, "risks") if str(x.get("status")) != "closed"
1192
- ]}
1193
-
1194
-
1195
- def gen_dag(c: Corpus) -> dict:
1196
- out = []
1197
- per_wave: dict[str, list[dict]] = {}
1198
- for wave, _, story in c.stories():
1199
- per_wave.setdefault(str(wave.get("id")), []).append(story)
1200
- for wid in sorted(per_wave):
1201
- items = per_wave[wid]
1202
- done: set[str] = set()
1203
- pending = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
1204
- waves_out = []
1205
- while pending:
1206
- ready = sorted(k for k, deps in pending.items() if not (deps - done))
1207
- if not ready: # siklus — V7 sudah melaporkannya
1208
- waves_out.append({"blocked": sorted(pending)})
1209
- break
1210
- waves_out.append({"parallel": ready})
1211
- done |= set(ready)
1212
- for k in ready:
1213
- pending.pop(k)
1214
- out.append({"wave": wid, "order": waves_out})
1215
- return {"dag": out}
1216
-
1217
-
1218
- def gen_rtm(c: Corpus) -> dict:
1219
- cap_goal = {str(x.get("id")): str(x.get("goal", "")) for x in c.caps}
1220
- ucs_for_fr: dict[str, list[str]] = {}
1221
- for uc in c.ucs:
1222
- for fr in listy(uc, "satisfies"):
1223
- ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
1224
- stories_for_uc: dict[str, list[tuple[dict, dict]]] = {}
1225
- for wave, _, story in c.stories():
1226
- for uc in listy(story, "satisfies"):
1227
- stories_for_uc.setdefault(uc, []).append((wave, story))
1228
- decs_for: dict[str, list[str]] = {}
1229
- for dec in c.decs:
1230
- for target in listy(dec, "serves"):
1231
- decs_for.setdefault(target, []).append(str(dec.get("id")))
1232
-
1233
- lines = []
1234
- for fr in c.frs:
1235
- fid = str(fr.get("id"))
1236
- cap = str(fr.get("capability", ""))
1237
- base = {"BG": cap_goal.get(cap, ""), "CAP": cap, "FR": fid,
1238
- "DEC": sorted(decs_for.get(fid, []))}
1239
- ucs = sorted(ucs_for_fr.get(fid, []))
1240
- if not ucs:
1241
- exempt = bool(str(fr.get("no_uc") or "").strip())
1242
- lines.append({**base, "UC": "", "story": "", "wave": "", "release": "",
1243
- "test": [], "status": "", "green": False,
1244
- "exempt": exempt,
1245
- "broken_at": "no_uc" if exempt else "UC"})
1246
- continue
1247
- for uid in ucs:
1248
- pairs = sorted(stories_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
1249
- if not pairs:
1250
- lines.append({**base, "UC": uid, "story": "", "wave": "", "release": "",
1251
- "test": [], "status": "", "green": False, "exempt": False,
1252
- "broken_at": "story"})
1253
- continue
1254
- for wave, story in pairs:
1255
- status = _story_status(c, story)
1256
- tests = listy(story, "tests")
1257
- broken = ""
1258
- if not tests:
1259
- broken = "test"
1260
- elif status != "done":
1261
- broken = "status"
1262
- lines.append({**base, "UC": uid, "story": str(story.get("id")),
1263
- "wave": str(wave.get("id")), "release": str(wave.get("release", "")),
1264
- "test": tests, "status": status, "exempt": False,
1265
- "green": broken == "", "broken_at": broken})
1266
- return {"rtm": lines}
1267
-
1268
-
1269
- def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1270
- lines = rtm.get("rtm") or []
1271
- counted = [line for line in lines if not line.get("exempt")]
1272
- exempt = len(lines) - len(counted)
1273
- green = sum(1 for line in counted if line.get("green"))
1274
- per_wave = []
1275
- for wave in c.wave_list:
1276
- wid = str(wave.get("id"))
1277
- items = [s for w, _, s in c.stories() if str(w.get("id")) == wid]
1278
- done = sum(1 for s in items if _story_status(c, s) == "done")
1279
- per_wave.append({"wave": wid, "status": wave.get("status"),
1280
- "stories_done": done, "stories_total": len(items),
1281
- "progres_kerja": _pct(done, len(items))})
1282
- applicable = 26 # V1..V27 tanpa V10 yang gugur
1283
- return {
1284
- "progres_janji": _pct(green, len(counted)),
1285
- "baris_rtm": {"hijau": green, "dihitung": len(counted),
1286
- "dikecualikan_no_uc": exempt},
1287
- "progres_kerja": per_wave,
1288
- "kesiapan_gate": _pct(applicable - len(result.red), applicable),
1289
- "validator_merah": result.red,
1290
- "validator_dilewati": dict(sorted(result.skipped.items())),
1291
- "pertanyaan_terbuka": _question_budget(c),
1292
- }
1293
-
1294
-
1295
- def _question_budget(c: Corpus) -> dict:
1296
- """Hitungan keempat daftar pertanyaan, diadu dengan jatah di index.yaml.
1297
-
1298
- Jatahnya BUKAN pagar keras. Ia yang dilaporkan ketika sebuah batch melewatinya, karena batch
1299
- yang lebih besar adalah sinyal tentang lintasannya, bukan tentang korpus.
1300
- """
1301
- budget = c.index.get("question_budget") or {}
1302
- out: dict[str, object] = {}
1303
- for name in ("blocking", "assumptions", "external", "answered"):
1304
- path = c.root / ".control/questions" / f"{name}.md"
1305
- rows_n = 0
1306
- if path.exists():
1307
- rows_n = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
1308
- if line.startswith("| OQ-"))
1309
- out[name] = rows_n
1310
- cap_block = budget.get("blocking_per_component")
1311
- if cap_block and c.pcs:
1312
- allowed = int(cap_block) * len(c.pcs)
1313
- out["blocking_jatah"] = allowed
1314
- out["blocking_lewat_jatah"] = out["blocking"] > allowed
1315
- cap_assume = budget.get("assumptions_per_gate")
1316
- if cap_assume:
1317
- out["assumptions_jatah_per_gate"] = int(cap_assume)
1318
- return out
1319
-
1320
-
1321
- def _pct(part: int, total: int) -> str:
1322
- return "n/a" if total == 0 else f"{round(100 * part / total)}%"
1323
-
1324
-
1325
- def as_markdown(name: str, payload: dict) -> str:
1326
- body = dump(payload)
1327
- return (f"# {name}\n\n"
1328
- f"> Tergenerate oleh `.constitution/scripts/validate --generate`. "
1329
- f"MUST NOT diedit tangan.\n\n"
1330
- f"```yaml\n{body}```\n")
1331
-
1332
-
1333
- # ------------------------------------------------------- halaman untuk manusia
1334
-
1335
- PAGE_HEADER = ("> Tergenerate oleh `.constitution/scripts/validate --generate`. "
1336
- "MUST NOT diedit tangan.\n")
1337
-
1338
-
1339
- def _section(path: Path, heading: str) -> str:
1340
- """Ambil satu bagian `## <heading>` dari sebuah berkas markdown, apa adanya."""
1341
- if not path.exists():
1342
- return ""
1343
- lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
1344
- out: list[str] = []
1345
- inside = False
1346
- for line in lines:
1347
- if line.startswith("## "):
1348
- if inside:
1349
- break
1350
- inside = line[3:].strip().lower().startswith(heading.lower())
1351
- continue
1352
- if inside:
1353
- out.append(line)
1354
- return "\n".join(out).strip("\n")
1355
-
1356
-
1357
- def _body(path: Path) -> str:
1358
- """Isi berkas tanpa frontmatter dan tanpa komentar template."""
1359
- if not path.exists():
1360
- return ""
1361
- text = path.read_text(encoding="utf-8", errors="replace")
1362
- match = FM.match(text)
1363
- if match:
1364
- text = text[match.end():]
1365
- while "<!--" in text and "-->" in text:
1366
- head, _, rest = text.partition("<!--")
1367
- _, _, tail = rest.partition("-->")
1368
- text = head + tail
1369
- return text.strip("\n")
1370
-
1371
-
1372
- def page_decisions(c: Corpus) -> str:
1373
- """Tabel rata seluruh `DEC-`. Ini yang menggantikan mencari keputusan lewat memlog."""
1374
- rows_out = ["| id | Judul | Status | Tipe | Menyentuh | Berkas |",
1375
- "| --- | --- | --- | --- | --- | --- |"]
1376
- for dec in c.decs:
1377
- touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
1378
- rows_out.append(
1379
- f"| `{dec.get('id')}` | {_cell(dec.get('title'))} | `{dec.get('status', '')}` "
1380
- f"| {dec.get('type') or '—'} | {touches} | `{dec.get('file', '')}` |")
1381
- counts: dict[str, int] = {}
1382
- for dec in c.decs:
1383
- key = str(dec.get("status"))
1384
- counts[key] = counts.get(key, 0) + 1
1385
- tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "belum ada keputusan"
1386
- return ("# decisions\n\n" + PAGE_HEADER +
1387
- "\nMencari keputusan tidak lagi lewat memlog — memlog kembali jadi log lintasan saja.\n"
1388
- f"\n**{len(c.decs)} keputusan** {tally}.\n\n" + "\n".join(rows_out) + "\n")
1389
-
1390
-
1391
- def page_blueprint(c: Corpus) -> str:
1392
- """Roll-up satu halaman yang di-review di G3. Tujuh berkas jadi satu bacaan.
1393
-
1394
- Katalog UC, daftar aktor, dan model domain tetap tinggal di kernel komponennya masing-masing
1395
- sebagai rumah permanennya. Ini tampilannya. Satu fakta, satu rumah, satu tampilan.
1396
- """
1397
- parts = ["# blueprint\n", PAGE_HEADER,
1398
- "\nIni yang dibaca pemilik di **G3 Blueprint**, bukan tujuh berkas. Isinya tidak "
1399
- "dipengaruhi `mode` maupun `risk_accepted`.\n"]
1400
-
1401
- crit = sum(1 for uc in c.ucs if uc.get("critical"))
1402
- parts.append(f"\n## Katalog use case\n\n**{len(c.ucs)} use case**, {crit} bertanda "
1403
- f"`critical`.\n")
1404
- parts.append("| id | Use case | Komponen | Memenuhi | critical |")
1405
- parts.append("| --- | --- | --- | --- | --- |")
1406
- for uc in c.ucs:
1407
- sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
1408
- flag = "ya" if uc.get("critical") else "tidak"
1409
- parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
1410
- f"`{uc.get('component', '')}` | {sat} | {flag} |")
1411
-
1412
- parts.append("\n## Daftar aktor\n")
1413
- for pc in c.pcs:
1414
- pid = str(pc.get("id"))
1415
- block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
1416
- parts.append(f"\n### {pid} — {pc.get('name', '')}\n")
1417
- parts.append(_demote(block) if block
1418
- else "_belum ada § Actor Register di SRS komponen ini._")
1419
-
1420
- parts.append("\n## Model domain\n")
1421
- for pc in c.pcs:
1422
- pid = str(pc.get("id"))
1423
- block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
1424
- parts.append(f"\n### {pid}\n")
1425
- parts.append(_demote(block) if block else "_belum ada `03-domain/domain-model.md`._")
1426
-
1427
- parts.append("\n## Tiga inventaris\n")
1428
- for kind, name in (("db", "tabel"), ("api", "endpoint"), ("screen", "layar")):
1429
- block = _body(c.root / f".how/_platform/inventory-{kind}.md")
1430
- parts.append(f"\n### Daftar {name} `inventory-{kind}.md`\n")
1431
- parts.append(_demote(block) if block else f"_belum ada `inventory-{kind}.md`._")
1432
-
1433
- return "\n".join(parts) + "\n"
1434
-
1435
-
1436
- def _cell(value: object, limit: int = 110) -> str:
1437
- """Satu baris tabel, dipendekkan. Sumber panjangnya tetap di registry — ini tampilan."""
1438
- text = " ".join(str(value or "").split()).replace("|", "\\|")
1439
- return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
1440
-
1441
-
1442
- def _demote(block: str, by: int = 2) -> str:
1443
- """Turunkan tingkat heading isi yang di-inline, supaya ia tidak menabrak kerangka roll-up."""
1444
- out = []
1445
- for line in block.splitlines():
1446
- stripped = line.lstrip()
1447
- if stripped.startswith("#"):
1448
- hashes = len(stripped) - len(stripped.lstrip("#"))
1449
- out.append("#" * min(6, hashes + by) + stripped[hashes:])
1450
- else:
1451
- out.append(line)
1452
- return "\n".join(out)
1453
-
1454
-
1455
- def page_estimate(c: Corpus) -> str:
1456
- """Tabel task KANDIDAT. Satu baris per `FR`, karena itu bentuk ideal sebuah wave."""
1457
- mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
1458
- risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or ""),
1459
- str(pc.get("risk_note") or "—")) for pc in c.pcs}
1460
- cap_by_id = {str(x.get("id")): x for x in c.caps}
1461
- fr_per_cap: dict[str, int] = {}
1462
- for fr in c.frs:
1463
- key = str(fr.get("capability", ""))
1464
- fr_per_cap[key] = fr_per_cap.get(key, 0) + 1
1465
-
1466
- have_mandays = any(x.get("estimate_mandays") for x in c.caps)
1467
- parts = ["# estimate\n", PAGE_HEADER,
1468
- "\n**INI ESTIMASI, MENGHADAP KE DEPAN.** Tiap baris di bawah adalah task "
1469
- "**kandidat**; wave di `waves.yaml` yang nyata. Satu baris MAY jadi satu wave, dan tiga "
1470
- "baris bertetangga MAY digabung jadi satu — penggabungan itu keputusan manusia saat wave "
1471
- "dibuka.\n"]
1472
- if not have_mandays:
1473
- parts.append("\n**Tanpa `estimate_mandays` pada satu pun `CAP`**, kolom Beban kosong dan "
1474
- "keluaran ini setara ukuran kelas T-shirt. Ia MUST dilaporkan sebagai itu.\n")
1475
-
1476
- parts.append("\n| Task | FR | Epic | mode | Paparan | Beban | Prioritas | Bergantung | Rilis |")
1477
- parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
1478
- for fr in c.frs:
1479
- cap_id = str(fr.get("capability", ""))
1480
- cap = cap_by_id.get(cap_id, {})
1481
- pid = str(fr.get("component") or cap.get("component") or "")
1482
- risk, note = risk_of.get(pid, ("—", "—"))
1483
- exposure = "belum disetel" if risk == "" else f"`{risk}` — {_cell(note, 60)}"
1484
- mandays = cap.get("estimate_mandays")
1485
- share = "—"
1486
- if mandays:
1487
- try:
1488
- share = f"{float(mandays) / max(1, fr_per_cap.get(cap_id, 1)):.1f}"
1489
- except (TypeError, ValueError):
1490
- share = "—"
1491
- deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
1492
- parts.append(
1493
- f"| {_cell(fr.get('text') or fr.get('title'))} | `{fr.get('id')}` | `{pid or '—'}` "
1494
- f"| `{mode_of.get(pid, 'catalog')}` | {exposure} | {share} "
1495
- f"| {cap.get('priority', '—')} | {deps} | {cap.get('target_release', '—')} |")
1496
- return "\n".join(parts) + "\n"
1497
-
1498
-
1499
- def generate(c: Corpus, result: Result) -> list[Path]:
1500
- out_dir = c.root / ".control" / "generated"
1501
- out_dir.mkdir(parents=True, exist_ok=True)
1502
- rtm = gen_rtm(c)
1503
- payloads = {
1504
- "components": gen_components(c),
1505
- "risks": gen_risks(c),
1506
- "dag": gen_dag(c),
1507
- "rtm": rtm,
1508
- "status": gen_status(c, rtm, result),
1509
- }
1510
- written = []
1511
- for name in GENERATED_ORDER:
1512
- payload = payloads[name]
1513
- yaml_path = out_dir / f"{name}.yaml"
1514
- yaml_path.write_text(dump(payload), encoding="utf-8")
1515
- md_path = out_dir / f"{name}.md"
1516
- md_path.write_text(as_markdown(name, payload), encoding="utf-8")
1517
- written += [yaml_path, md_path]
1518
-
1519
- # Tiga halaman untuk MANUSIA: tabel markdown sungguhan, tanpa kembar .yaml. Yang dibaca orang
1520
- # tidak dibungkus fence yaml, dan tidak ada pembaca mesin yang menuntut versi keduanya.
1521
- for name, render in (("decisions", page_decisions),
1522
- ("blueprint", page_blueprint),
1523
- ("estimate", page_estimate)):
1524
- page = out_dir / f"{name}.md"
1525
- page.write_text(render(c), encoding="utf-8")
1526
- written.append(page)
1527
- return written
1528
-
1529
-
1530
- # ------------------------------------------------------------------------ CLI
1531
-
1532
-
1533
- def main(argv: list[str] | None = None) -> int:
1534
- parser = argparse.ArgumentParser(
1535
- prog="validate", description="V1..V27 dan generator .control/generated/")
1536
- parser.add_argument("--check", action="store_true",
1537
- help="periksa saja; keluar non-zero bila ada yang merah")
1538
- parser.add_argument("--generate", action="store_true",
1539
- help="tulis ulang .control/generated/ (tetap memeriksa lebih dulu)")
1540
- parser.add_argument("--root", default=".", help="akar repo (default: direktori sekarang)")
1541
- parser.add_argument("--asof", default=None,
1542
- help="tanggal acuan V14, format YYYY-MM-DD (default: hari ini). "
1543
- "Dinyatakan eksplisit supaya run bisa diulang persis")
1544
- args = parser.parse_args(argv)
1545
-
1546
- if not args.check and not args.generate:
1547
- args.check = True
1548
-
1549
- root = Path(args.root).resolve()
1550
- if not (root / ".control" / "registry").is_dir():
1551
- print(f"validate: {root} tidak punya .control/registry/ — salah akar repo?", file=sys.stderr)
1552
- return 2
1553
-
1554
- asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
1555
- corpus = Corpus.load(root)
1556
- result = run_checks(corpus, asof)
1557
-
1558
- if args.generate:
1559
- for path in generate(corpus, result):
1560
- print(f" tulis {path.relative_to(root).as_posix()}")
1561
-
1562
- if result.findings:
1563
- print(f"\nMERAH {len(result.findings)} temuan di {len(result.red)} validator\n")
1564
- for finding in sorted(result.findings, key=lambda f: f.sort_key):
1565
- print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
1566
- else:
1567
- print("\nHIJAU tidak ada temuan")
1568
-
1569
- if result.skipped:
1570
- print("\nDilewati:")
1571
- for vid, why in sorted(result.skipped.items()):
1572
- print(f" {vid:<4} {why}")
1573
-
1574
- print(f"\nacuan waktu V14: {asof.isoformat()}")
1575
- return 1 if result.findings else 0
1576
-
1577
-
1578
- if __name__ == "__main__":
1579
- raise SystemExit(main())
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml>=6"]
5
+ # ///
6
+ """validate — V1..V27 plus the .control/generated/ generator.
7
+
8
+ Two modes:
9
+ validate --check exit non-zero if anything is red; writes nothing
10
+ validate --generate rewrite .control/generated/ (and still runs --check)
11
+
12
+ Determinism is the contract: two runs over the same data MUST produce the same result.
13
+ That is why there is no unordered iteration, and the one time-dependent input
14
+ (--asof, used by V14) is stated explicitly instead of being taken silently from the wall clock.
15
+
16
+ What is NOT done here: the time dimension from git. `generated/timeline` and
17
+ `generated/report` belong to wdi-report. See 08-project-management.md.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import datetime as dt
24
+ import re
25
+ import subprocess
26
+ import sys
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+ import yaml
31
+
32
+ REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
33
+ GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
34
+
35
+ # Pages read by HUMANS, not machines: written as real markdown tables, not yaml
36
+ # in a fence. All three are named in §22 and each has one clear reader.
37
+ GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
38
+
39
+ MODES = ("catalog", "outline", "guarded", "deep")
40
+
41
+ # Keywords that make a component "sensitive" for V23. Matched against `risk_note`, which is PROSE in
42
+ # whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
43
+ # than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
44
+ # it does not judge. Deliberately short.
45
+ SENSITIVE_MARKERS = (
46
+ # English
47
+ "money", "payment", "personal data", "pii",
48
+ "irreversible", "cannot be undone", "contractual", "contract", "integration",
49
+ # Bahasa Indonesia
50
+ "uang", "pembayaran", "data pribadi",
51
+ "tak-terbalikkan", "tak terbalikkan", "tidak dapat dibatalkan",
52
+ "kontraktual", "kontrak", "integrasi",
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------- infrastructure
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Finding:
61
+ vid: str
62
+ subject: str
63
+ message: str
64
+
65
+ @property
66
+ def sort_key(self) -> tuple[int, str, str]:
67
+ digits = "".join(ch for ch in self.vid if ch.isdigit())
68
+ return (int(digits or 0), self.subject, self.message)
69
+
70
+
71
+ @dataclass
72
+ class Result:
73
+ findings: list[Finding] = field(default_factory=list)
74
+ skipped: dict[str, str] = field(default_factory=dict)
75
+
76
+ def fail(self, vid: str, subject: str, message: str) -> None:
77
+ self.findings.append(Finding(vid, subject, message))
78
+
79
+ def skip(self, vid: str, why: str) -> None:
80
+ self.skipped[vid] = why
81
+
82
+ @property
83
+ def red(self) -> list[str]:
84
+ return sorted({f.vid for f in self.findings})
85
+
86
+
87
+ def load_yaml(path: Path) -> dict:
88
+ if not path.exists():
89
+ return {}
90
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
91
+ return data if isinstance(data, dict) else {}
92
+
93
+
94
+ def rows(data: dict, key: str) -> list[dict]:
95
+ """Registry list, always sorted by id so the output is deterministic."""
96
+ value = data.get(key) or []
97
+ if not isinstance(value, list):
98
+ return []
99
+ items = [v for v in value if isinstance(v, dict)]
100
+ return sorted(items, key=lambda r: str(r.get("id", "")))
101
+
102
+
103
+ FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
104
+
105
+
106
+ class Dumper(yaml.SafeDumper):
107
+ """No anchors/aliases: output MUST be readable and diffable line by line."""
108
+
109
+ def ignore_aliases(self, data) -> bool: # noqa: ARG002
110
+ return True
111
+
112
+
113
+ def dump(payload: dict) -> str:
114
+ return yaml.dump(payload, Dumper=Dumper, allow_unicode=True, sort_keys=False,
115
+ default_flow_style=False, width=100)
116
+
117
+
118
+ def frontmatter(path: Path) -> dict | None:
119
+ """None if the file does not exist; {} if it exists but has no frontmatter."""
120
+ if not path.exists():
121
+ return None
122
+ match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
123
+ if not match:
124
+ return {}
125
+ data = yaml.safe_load(match.group(1))
126
+ return data if isinstance(data, dict) else {}
127
+
128
+
129
+ def git(root: Path, *args: str) -> str | None:
130
+ try:
131
+ out = subprocess.run(
132
+ ["git", "-C", str(root), *args],
133
+ capture_output=True, text=True, timeout=30, check=False,
134
+ )
135
+ except (OSError, subprocess.SubprocessError):
136
+ return None
137
+ return out.stdout.strip() if out.returncode == 0 else None
138
+
139
+
140
+ # ------------------------------------------------------------------- loading
141
+
142
+
143
+ @dataclass
144
+ class Corpus:
145
+ root: Path
146
+ requirements: dict
147
+ usecases: dict
148
+ decisions: dict
149
+ risks: dict
150
+ components: dict
151
+ waves: dict
152
+ defects: dict
153
+ index: dict
154
+
155
+ @classmethod
156
+ def load(cls, root: Path) -> "Corpus":
157
+ reg = root / ".control" / "registry"
158
+ return cls(
159
+ root=root,
160
+ requirements=load_yaml(reg / "requirements.yaml"),
161
+ usecases=load_yaml(reg / "usecases.yaml"),
162
+ decisions=load_yaml(reg / "decisions.yaml"),
163
+ risks=load_yaml(reg / "risks.yaml"),
164
+ components=load_yaml(reg / "components.yaml"),
165
+ waves=load_yaml(reg / "waves.yaml"),
166
+ defects=load_yaml(reg / "defects.yaml"),
167
+ index=load_yaml(reg / "index.yaml"),
168
+ )
169
+
170
+ # --- shortcuts used repeatedly
171
+ @property
172
+ def goals(self) -> list[dict]:
173
+ return rows(self.requirements, "goals")
174
+
175
+ @property
176
+ def caps(self) -> list[dict]:
177
+ return rows(self.requirements, "capabilities")
178
+
179
+ @property
180
+ def frs(self) -> list[dict]:
181
+ return rows(self.requirements, "functional")
182
+
183
+ @property
184
+ def nfrs(self) -> list[dict]:
185
+ return rows(self.requirements, "nonfunctional")
186
+
187
+ @property
188
+ def ucs(self) -> list[dict]:
189
+ return rows(self.usecases, "usecases")
190
+
191
+ @property
192
+ def decs(self) -> list[dict]:
193
+ return rows(self.decisions, "decisions")
194
+
195
+ def mode_of(self, pc: dict) -> str:
196
+ """Per-component `mode` wins over the global one; with neither, default `catalog`."""
197
+ own = str(pc.get("mode") or "").strip()
198
+ if own:
199
+ return own
200
+ return str(self.index.get("mode") or "").strip() or "catalog"
201
+
202
+ @property
203
+ def lcs(self) -> list[dict]:
204
+ return rows(self.components, "logical_components")
205
+
206
+ @property
207
+ def pcs(self) -> list[dict]:
208
+ return rows(self.components, "product_components")
209
+
210
+ @property
211
+ def wave_list(self) -> list[dict]:
212
+ return rows(self.waves, "waves")
213
+
214
+ @property
215
+ def defect_list(self) -> list[dict]:
216
+ return rows(self.defects, "defects")
217
+
218
+ def stories(self) -> list[tuple[dict, dict, dict]]:
219
+ """(wave, epic, story) — sorted by id at each level."""
220
+ out = []
221
+ for wave in self.wave_list:
222
+ for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
223
+ if not isinstance(epic, dict):
224
+ continue
225
+ for story in sorted(epic.get("stories") or [], key=lambda s: str(s.get("id", ""))):
226
+ if isinstance(story, dict):
227
+ out.append((wave, epic, story))
228
+ return out
229
+
230
+
231
+ def listy(row: dict, key: str) -> list[str]:
232
+ value = row.get(key) or []
233
+ if isinstance(value, str):
234
+ return [value]
235
+ return [str(v) for v in value if v is not None]
236
+
237
+
238
+ # ------------------------------------------------------------------ validators
239
+
240
+
241
+ def v1(c: Corpus, r: Result) -> None:
242
+ """Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
243
+
244
+ A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
245
+ deployment foundation can be extended without being torn down is measured by two architectural
246
+ properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
247
+ one `FR` there produces a false promise, and a false promise is more expensive than a finding.
248
+
249
+ The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
250
+ """
251
+ cap_by_goal: dict[str, list[str]] = {}
252
+ for cap in c.caps:
253
+ cap_by_goal.setdefault(str(cap.get("goal", "")), []).append(str(cap.get("id")))
254
+ fr_caps = {str(fr.get("capability", "")) for fr in c.frs}
255
+ for goal in c.goals:
256
+ gid = str(goal.get("id"))
257
+ reachable = [cid for cid in cap_by_goal.get(gid, []) if cid in fr_caps]
258
+ if reachable:
259
+ continue
260
+ if str(goal.get("no_fr") or "").strip():
261
+ continue
262
+ r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
263
+
264
+
265
+ def v2(c: Corpus, r: Result) -> None:
266
+ covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
267
+ for fr in c.frs:
268
+ fid = str(fr.get("id"))
269
+ if fid in covered:
270
+ continue
271
+ if str(fr.get("no_uc") or "").strip():
272
+ continue
273
+ r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
274
+
275
+
276
+ def v3(c: Corpus, r: Result) -> None:
277
+ """A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
278
+
279
+ The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
280
+ entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
281
+ not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
282
+ findings under fifty-six expected ones stops being read, and a validator that is not read
283
+ guards nothing.
284
+
285
+ What is guarded now is the actual omission: a wave touches a component, and a UC of that
286
+ component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
287
+ and `wdi-build` owns itthe same way V12 was shifted to wave closing.
288
+ """
289
+ scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
290
+ touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
291
+ if not c.wave_list:
292
+ r.skip("V3", "no wave yet, so no story yet every unscheduled UC is the correct "
293
+ "state. Full catalogue coverage is checked at G5")
294
+ return
295
+ for uc in c.ucs:
296
+ uid = str(uc.get("id"))
297
+ if uid in scheduled or str(uc.get("component")) not in touched:
298
+ continue
299
+ r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
300
+ f"but this UC is not scheduled to any story")
301
+
302
+
303
+ def v4(c: Corpus, r: Result) -> None:
304
+ for _, _, story in c.stories():
305
+ if not [t for t in listy(story, "tests") if t.strip()]:
306
+ r.fail("V4", str(story.get("id")), "has not one named test")
307
+
308
+
309
+ def v5(c: Corpus, r: Result) -> None:
310
+ """Every NFR has an enforcer, OR states its reason in `no_enforcer`.
311
+
312
+ Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
313
+ **retired**, and the other states of itself that it is a **design measure, not a gate**. Demanding
314
+ a test for both produces a test that cannot fail, and a test that cannot fail is theater.
315
+ """
316
+ for nfr in c.nfrs:
317
+ if [e for e in listy(nfr, "enforced_by") if e.strip()]:
318
+ continue
319
+ if str(nfr.get("no_enforcer") or "").strip():
320
+ continue
321
+ r.fail("V5", str(nfr.get("id")),
322
+ "has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
323
+
324
+
325
+ def v6(c: Corpus, r: Result) -> None:
326
+ defined: set[str] = set()
327
+ for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
328
+ rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
329
+ defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
330
+ for wave in c.wave_list:
331
+ defined.add(str(wave.get("id")))
332
+ for _, epic, story in c.stories():
333
+ defined.add(str(epic.get("id")))
334
+ defined.add(str(story.get("id")))
335
+
336
+ refs: list[tuple[str, str]] = []
337
+ for cap in c.caps:
338
+ refs.append((str(cap.get("id")), str(cap.get("goal", ""))))
339
+ refs += [(str(cap.get("id")), d) for d in listy(cap, "depends_on")]
340
+ for fr in c.frs:
341
+ refs.append((str(fr.get("id")), str(fr.get("capability", ""))))
342
+ for nfr in c.nfrs:
343
+ refs.append((str(nfr.get("id")), str(nfr.get("goal", ""))))
344
+ for uc in c.ucs:
345
+ refs += [(str(uc.get("id")), f) for f in listy(uc, "satisfies")]
346
+ for dec in c.decs:
347
+ refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
348
+ for defect in c.defect_list:
349
+ refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
350
+ for _, _, story in c.stories():
351
+ refs += [(str(story.get("id")), u) for u in listy(story, "satisfies")]
352
+ refs += [(str(story.get("id")), d) for d in listy(story, "depends_on")]
353
+
354
+ for owner, target in sorted(set(refs)):
355
+ if target and target not in defined:
356
+ r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
357
+
358
+
359
+ def _cycles(graph: dict[str, list[str]]) -> list[str]:
360
+ state: dict[str, int] = {}
361
+ bad: list[str] = []
362
+
363
+ def walk(node: str) -> None:
364
+ state[node] = 1
365
+ for nxt in sorted(graph.get(node, [])):
366
+ if state.get(nxt) == 1:
367
+ bad.append(node)
368
+ elif state.get(nxt) is None and nxt in graph:
369
+ walk(nxt)
370
+ state[node] = 2
371
+
372
+ for node in sorted(graph):
373
+ if state.get(node) is None:
374
+ walk(node)
375
+ return sorted(set(bad))
376
+
377
+
378
+ def v7(c: Corpus, r: Result) -> None:
379
+ caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
380
+ for node in _cycles(caps):
381
+ r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
382
+ stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
383
+ for node in _cycles(stories):
384
+ r.fail("V7", node, "is part of a `depends_on` cycle among stories")
385
+
386
+
387
+ def v8(c: Corpus, r: Result) -> None:
388
+ """Every `applied` decision names a non-empty `touches`.
389
+
390
+ Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
391
+ "the filter MUST work like this" serves no FR at all, and that is VALID it is exactly
392
+ decisions like that which most need remembering, and the old rule discarded them.
393
+ """
394
+ for dec in c.decs:
395
+ if str(dec.get("status")) != "applied":
396
+ continue
397
+ if not [x for x in listy(dec, "touches") if str(x).strip()]:
398
+ r.fail("V8", str(dec.get("id")),
399
+ "is applied but `touches` is empty an application with no file trace")
400
+
401
+
402
+ def v9(c: Corpus, r: Result) -> None:
403
+ passed = {str(g) for g in (c.index.get("gates_passed") or [])}
404
+ for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
405
+ fm = frontmatter(path) or {}
406
+ if str(fm.get("status")) != "locked":
407
+ continue
408
+ gate = str(fm.get("locked_at_gate") or "")
409
+ if gate not in passed:
410
+ rel = path.relative_to(c.root).as_posix()
411
+ r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
412
+
413
+
414
+ def v11(c: Corpus, r: Result) -> None:
415
+ per_wave: dict[str, list[dict]] = {}
416
+ for wave, _, story in c.stories():
417
+ per_wave.setdefault(str(wave.get("id")), []).append(story)
418
+
419
+ for wid in sorted(per_wave):
420
+ items = per_wave[wid]
421
+ edges = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
422
+
423
+ def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
424
+ seen = seen or set()
425
+ if a in seen:
426
+ return False
427
+ seen.add(a)
428
+ if b in edges.get(a, set()):
429
+ return True
430
+ return any(reaches(n, b, seen) for n in sorted(edges.get(a, set())))
431
+
432
+ for i, left in enumerate(items):
433
+ for right in items[i + 1:]:
434
+ lid, rid = str(left.get("id")), str(right.get("id"))
435
+ shared = sorted(set(listy(left, "touches")) & set(listy(right, "touches")))
436
+ if not shared:
437
+ continue
438
+ if reaches(lid, rid) or reaches(rid, lid):
439
+ continue
440
+ r.fail("V11", f"{lid} + {rid}",
441
+ f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
442
+
443
+
444
+ def v12(c: Corpus, r: Result) -> None:
445
+ """LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
446
+
447
+ The old shape demanded the answer when the information was thinnest. At wave closing,
448
+ every `touches` already has an area and every boundary already has a name.
449
+ """
450
+ areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
451
+ lcs_per_pc: dict[str, int] = {}
452
+ for lc in c.lcs:
453
+ lcs_per_pc[str(lc.get("component"))] = lcs_per_pc.get(str(lc.get("component")), 0) + 1
454
+ pc_by_id = {str(x.get("id")): x for x in c.pcs}
455
+
456
+ seen: set[tuple[str, str]] = set()
457
+ for wave, _, story in c.stories():
458
+ if str(wave.get("status")) != "closed":
459
+ continue
460
+ for area in listy(story, "touches"):
461
+ if area not in areas:
462
+ r.fail("V12", str(story.get("id")),
463
+ f"its wave is already closed, but `{area}` is not registered as an `area` "
464
+ f"in components.yaml")
465
+ pid = str(story.get("component") or "")
466
+ row = pc_by_id.get(pid)
467
+ if row is None or (str(wave.get("id")), pid) in seen:
468
+ continue
469
+ seen.add((str(wave.get("id")), pid))
470
+ if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
471
+ r.fail("V12", f"{wave.get('id')} / {pid}",
472
+ f"wave closed and component with mode `{c.mode_of(row)}` has not one "
473
+ f"`LC` registered")
474
+
475
+
476
+ LENS_BY_RISK = {
477
+ "low": {"edge-case-hunter"},
478
+ "medium": {"edge-case-hunter"},
479
+ "high": set(),
480
+ }
481
+ FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
482
+
483
+
484
+ def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
485
+ if not isinstance(block, dict) or not block.get("sha") or not block.get("date"):
486
+ r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
487
+ return
488
+ lenses = {str(x) for x in (block.get("lenses") or [])}
489
+ if not lenses:
490
+ r.fail("V13", rel, "the `reviewed` trace names not one lens")
491
+ missing = sorted(need - lenses)
492
+ if missing:
493
+ r.fail("V13", rel,
494
+ f"lenses {missing} MUST be included that is what the component's `risk_accepted` demands")
495
+
496
+
497
+ def _only_reviewed_block(diff: str) -> bool:
498
+ """True if a commit's diff on one file ONLY touches the `reviewed:` block.
499
+
500
+ This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
501
+ the file but the commit that WRITES the `reviewed:` block always changes the file, and
502
+ writing its own hash into a git commit is cryptographically impossible. As a result every
503
+ artifact that had just been stamped immediately read as "stale review", forever.
504
+ """
505
+ touched = [ln for ln in diff.splitlines()
506
+ if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
507
+ if not touched:
508
+ return True
509
+ for ln in touched:
510
+ body = ln[1:].strip()
511
+ if not body or body.startswith("#"):
512
+ continue
513
+ if not body.startswith(FRONTMATTER_KEYS):
514
+ return False
515
+ return True
516
+
517
+
518
+ def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
519
+ """First commit after `sha` that changes this file for a reason other than a review stamp."""
520
+ log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
521
+ if not log:
522
+ return None
523
+ for head in log.splitlines():
524
+ head = head.strip()
525
+ if not head:
526
+ continue
527
+ diff = git(c.root, "show", "--format=", "--unified=0", head, "--", rel)
528
+ if diff is None:
529
+ return head
530
+ if _only_reviewed_block(diff):
531
+ continue
532
+ return head
533
+ return None
534
+
535
+
536
+ def v13(c: Corpus, r: Result) -> None:
537
+ """Review trace follows review INTENSITY, not document depth.
538
+
539
+ Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
540
+ stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
541
+ """
542
+ watched = [pc for pc in c.pcs
543
+ if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
544
+ if not watched:
545
+ r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
546
+ targets: list[tuple[Path, set[str]]] = []
547
+ if watched:
548
+ targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
549
+ for pc in watched:
550
+ pid = str(pc.get("id"))
551
+ need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
552
+ # The SRS exists and is meaningful at EVERY mode: it carries the Actor Register and UC
553
+ # Catalogue, and both are born at G3, which the depth knob does not touch.
554
+ targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
555
+ # The SDD is guarded only when it HAS content worth guarding. Two states exempt it, and
556
+ # both are FINISHED states, not neglected ones:
557
+ # mode: catalog the skeleton is its final form; G4 is skipped there
558
+ # g4_passed not set G4 has not run yet, so not one section is written
559
+ # Demanding a review trace on a file whose content is 13 lines of template comments is
560
+ # theater exactly the ceremony this redesign cut, and a review that cannot fail proves
561
+ # nothing. Once G4 passes, the demand comes back and it is meaningful.
562
+ passed = str(pc.get("g4_passed") or "").strip().lower()
563
+ if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
564
+ targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
565
+
566
+ for path, need in targets:
567
+ fm = frontmatter(path)
568
+ if fm is None:
569
+ continue # not born yet not V13's business
570
+ rel = path.relative_to(c.root).as_posix()
571
+ _reviewed_ok(r, rel, fm.get("reviewed"), need)
572
+ block = fm.get("reviewed")
573
+ if isinstance(block, dict) and block.get("sha"):
574
+ stale = _stale_since(c, rel, str(block["sha"]))
575
+ if stale:
576
+ r.fail("V13", rel,
577
+ f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]} — "
578
+ f"stale review")
579
+
580
+ for wave in c.wave_list:
581
+ if not wave.get("epics"):
582
+ continue
583
+ _reviewed_ok(r, f"waves.yaml:{wave.get('id')}", wave.get("spec_reviewed"),
584
+ {"edge-case-hunter"})
585
+
586
+
587
+ def cap_stories(c: Corpus) -> dict[str, list[dict]]:
588
+ """CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
589
+ frs_of: dict[str, list[str]] = {}
590
+ for fr in c.frs:
591
+ frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
592
+ ucs_of: dict[str, list[str]] = {}
593
+ for uc in c.ucs:
594
+ for fid in listy(uc, "satisfies"):
595
+ ucs_of.setdefault(fid, []).append(str(uc.get("id")))
596
+ out: dict[str, list[dict]] = {}
597
+ for cap in c.caps:
598
+ cid = str(cap.get("id"))
599
+ wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
600
+ out[cid] = [s for _, _, s in c.stories()
601
+ if wanted & set(listy(s, "satisfies"))]
602
+ return out
603
+
604
+
605
+ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
606
+ """Overdue-ness is computed from the registry itselfthe timeline only reinforces, never gates."""
607
+ by_cap = cap_stories(c)
608
+ timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
609
+ listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
610
+ if str(row.get("state")) == "overdue"} if timeline else None
611
+ if listed is None:
612
+ r.skip("V14", "generated/timeline.yaml does not exist yet overdue-ness is still computed "
613
+ "from the registry, but its presence in generated/report is not checked")
614
+
615
+ for cap in c.caps:
616
+ cid = str(cap.get("id"))
617
+ end = str(cap.get("planned_end") or "")
618
+ if not end:
619
+ continue
620
+ try:
621
+ due = dt.date.fromisoformat(end)
622
+ except ValueError:
623
+ r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
624
+ continue
625
+ items = by_cap.get(cid, [])
626
+ closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
627
+ if closed or due >= asof:
628
+ continue
629
+ late = (asof - due).days
630
+ if listed is not None and cid not in listed:
631
+ r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
632
+ f"`overdue` in generated/timeline")
633
+ else:
634
+ r.fail("V14", cid, f"{late} days overdue with nothing closed")
635
+
636
+
637
+ def v15(c: Corpus, r: Result) -> None:
638
+ for cap in c.caps:
639
+ if not str(cap.get("goal") or "").strip():
640
+ r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
641
+ for fr in c.frs:
642
+ if not str(fr.get("capability") or "").strip():
643
+ r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
644
+
645
+
646
+ def v16(c: Corpus, r: Result) -> None:
647
+ for path in sorted((c.root / ".control/memlog").glob("*.md")):
648
+ fm = frontmatter(path) or {}
649
+ rel = path.relative_to(c.root).as_posix()
650
+ artifact = str(fm.get("artifact") or "")
651
+ if not artifact:
652
+ r.fail("V16", rel, "has no `artifact:` in frontmatter")
653
+ elif not (c.root / artifact).exists():
654
+ r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
655
+ for layer in (".what", ".how"):
656
+ for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
657
+ r.fail("V16", stray.relative_to(c.root).as_posix(),
658
+ "a memlog MUST NOT live inside the corpus")
659
+
660
+
661
+ def v17(c: Corpus, r: Result) -> None:
662
+ for wave in c.wave_list:
663
+ wid = str(wave.get("id"))
664
+ if not str(wave.get("release") or "").strip():
665
+ r.fail("V17", wid, "does not name a `release`")
666
+ slugs = listy(wave, "prd")
667
+ if not slugs:
668
+ r.fail("V17", wid, "does not name a `prd`")
669
+ for slug in slugs:
670
+ if not (c.root / ".what/_prd" / slug).is_dir():
671
+ r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
672
+
673
+
674
+ def v18(c: Corpus, r: Result) -> None:
675
+ for _, _, story in c.stories():
676
+ sid = str(story.get("id"))
677
+ folder = str(story.get("spec_folder") or "").strip()
678
+ if not folder:
679
+ r.fail("V18", sid, "does not name a `spec_folder`")
680
+ continue
681
+ matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
682
+ if not matches:
683
+ r.fail("V18", sid, f"has no story file in {folder}stories/")
684
+ continue
685
+ fm = frontmatter(matches[0]) or {}
686
+ if not str(fm.get("status") or "").strip():
687
+ r.fail("V18", sid, "story file has no `status` in frontmatter")
688
+
689
+
690
+ def v19(c: Corpus, r: Result) -> None:
691
+ """The retrospective archive is tied to WAVE SIZE, not to `mode`.
692
+
693
+ Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
694
+ different things, and demanding a retrospective for a three-story wave is ceremony.
695
+ """
696
+ names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
697
+ advisory: list[str] = []
698
+ for wave in c.wave_list:
699
+ if str(wave.get("status")) != "closed":
700
+ continue
701
+ wid = str(wave.get("id"))
702
+ if any(wid in name for name in names):
703
+ continue
704
+ if str(wave.get("size")).upper() == "L":
705
+ r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
706
+ else:
707
+ advisory.append(wid)
708
+ if advisory:
709
+ r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
710
+ else:
711
+ r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
712
+
713
+
714
+ PLATFORM = "_platform"
715
+ CROSS_CUTTING = ".how/_platform/cross-cutting.md"
716
+ PLATFORM_DATA_HEADING = "Milik platform"
717
+
718
+
719
+ def v21(c: Corpus, r: Result) -> None:
720
+ """One domain entity has EXACTLY ONE owner authorized to write it.
721
+
722
+ The owner is a Product Component, OR `_platform` for an entity with no single component
723
+ promise behind it. Semantic collisions across PRDs have already happened for real: one
724
+ component took a business-rule numbering range from a shared global sequence. Two `FR`s
725
+ that both claim write authority over the same entity, with neither pointing at the other,
726
+ are a defect the moment they are written.
727
+
728
+ `_platform` is NOT a Product Component and therefore has no `mode`, `risk_accepted`, SRS,
729
+ or G4. It is a home for ownership, not a domain slice and so it does not become a dumping
730
+ ground, every entity it claims MUST be explained in `cross-cutting.md`: if the platform
731
+ owns the data, the platform documents it.
732
+ """
733
+ owner: dict[str, str] = {}
734
+ for pc in c.pcs:
735
+ pid = str(pc.get("id"))
736
+ for entity in listy(pc, "owns"):
737
+ if entity in owner and owner[entity] != pid:
738
+ r.fail("V21", entity,
739
+ f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
740
+ f"have exactly one owner")
741
+ else:
742
+ owner.setdefault(entity, pid)
743
+
744
+ platform = listy(c.components, "platform_owns")
745
+ for entity in platform:
746
+ if entity in owner:
747
+ r.fail("V21", entity,
748
+ f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
749
+ f"`{PLATFORM}` is not a second path for an entity that already has an owner")
750
+ else:
751
+ owner[entity] = PLATFORM
752
+
753
+ _platform_documented(c, r, platform + _platform_inventory_rows(c))
754
+
755
+ cap_home = {str(x.get("id")): str(x.get("component") or "") for x in c.caps}
756
+ for fr in c.frs:
757
+ fid = str(fr.get("id"))
758
+ home = str(fr.get("component") or cap_home.get(str(fr.get("capability", "")), ""))
759
+ for entity in listy(fr, "writes"):
760
+ own = owner.get(entity)
761
+ if not own or not home or own == home:
762
+ continue
763
+ if own == PLATFORM:
764
+ # The platform has no `FR`, so there is nothing a `defers_to` could point to. What
765
+ # stands in for "one writer" here is ONE DOCUMENTED FORM, and that is what
766
+ # _platform_documented checks above.
767
+ continue
768
+ if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
769
+ r.fail("V21", fid,
770
+ f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
771
+ f"pointing to an `FR` owned by that owner")
772
+
773
+
774
+ def _platform_inventory_rows(c: Corpus) -> list[str]:
775
+ """Inventory rows owned by `_platform`, read from `platform_rows:` in each inventory.
776
+
777
+ `_platform` is a valid value at EVERY ownership position, so the guard applies at every
778
+ position too: whatever it owns MUST be documented in `cross-cutting.md`.
779
+ """
780
+ out: list[str] = []
781
+ for kind in ("db", "api", "screen"):
782
+ path = c.root / f".how/_platform/inventory-{kind}.md"
783
+ fm = frontmatter(path)
784
+ if not fm:
785
+ continue
786
+ out += [str(x) for x in (fm.get("platform_rows") or [])]
787
+ return out
788
+
789
+
790
+ def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
791
+ """Every entity with `platform_owns` MUST be named in `cross-cutting.md`.
792
+
793
+ Skipped while the file does not yet carry that section: `cross-cutting.md` is a G3 output, and
794
+ an artifact the next gate will produce MUST NOT be reported missing.
795
+ """
796
+ if not entities:
797
+ return
798
+ path = c.root / CROSS_CUTTING
799
+ text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
800
+ if PLATFORM_DATA_HEADING.lower() not in text.lower():
801
+ r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
802
+ f"{len(entities)} entities with platform_owns are not documented yet: "
803
+ + ", ".join(sorted(entities)))
804
+ return
805
+ for entity in sorted(entities):
806
+ if entity not in text:
807
+ r.fail("V21", entity,
808
+ f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
809
+ f"a platform that owns data MUST document it")
810
+
811
+
812
+ def v22(c: Corpus, r: Result) -> None:
813
+ """A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
814
+
815
+ `catalog` skips G4 on purpose, so it is not an exception it is part of the rule.
816
+ """
817
+ pc_by_id = {str(x.get("id")): x for x in c.pcs}
818
+ seen: set[tuple[str, str]] = set()
819
+ for wave, _, story in c.stories():
820
+ pid = str(story.get("component") or "")
821
+ row = pc_by_id.get(pid)
822
+ if row is None:
823
+ continue
824
+ key = (str(wave.get("id")), pid)
825
+ if key in seen:
826
+ continue
827
+ seen.add(key)
828
+ mode = c.mode_of(row)
829
+ if mode == "catalog":
830
+ continue
831
+ if mode not in MODES:
832
+ r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
833
+ continue
834
+ passed = row.get("g4_passed")
835
+ if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
836
+ r.fail("V22", f"{wave.get('id')} / {pid}",
837
+ f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
838
+
839
+
840
+ def v23(c: Corpus, r: Result) -> None:
841
+ """`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
842
+
843
+ On a component that touches nothing on that list, `high` is FREE. The control is
844
+ disclosure, not veto — the owner may still choose quickly, just not without knowing what
845
+ they are wagering.
846
+ """
847
+ known = {str(x.get("id")) for x in c.decs}
848
+ for pc in c.pcs:
849
+ pid = str(pc.get("id"))
850
+ if str(pc.get("risk_accepted") or "").strip() != "high":
851
+ continue
852
+ note = str(pc.get("risk_note") or "").lower()
853
+ hits = sorted({m for m in SENSITIVE_MARKERS if m in note})
854
+ if not hits:
855
+ continue
856
+ ref = str(pc.get("risk_accepted_by") or "").strip()
857
+ if not ref:
858
+ r.fail("V23", pid,
859
+ f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
860
+ f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
861
+ elif ref not in known:
862
+ r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
863
+
864
+
865
+ def v20(c: Corpus, r: Result) -> None:
866
+ needs_link = {"requirement", "architecture"}
867
+ for defect in c.defect_list:
868
+ did = str(defect.get("id"))
869
+ cause = str(defect.get("root_cause") or "")
870
+ if cause not in needs_link:
871
+ continue
872
+ if not listy(defect, "violates"):
873
+ r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
874
+ if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
875
+ r.fail("V20", did,
876
+ f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
877
+
878
+
879
+ # Files that DESCRIBE the past, not STATE what currently holds. A dangling citation here is
880
+ # not a finding — corpus-guide.md owns that rule, and rewriting it would falsify history.
881
+ PAST_RECORD = (
882
+ ".control/memlog/",
883
+ ".control/decisions/",
884
+ ".control/questions/answered.md",
885
+ ".control/reports/",
886
+ )
887
+ # Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
888
+ FROZEN = (".what/",)
889
+ # Derived output. A finding here is UNACTIONABLE by construction the folder MUST NOT be written
890
+ # by hand, so nobody may fix it where it is reported. It also renders registry values inside
891
+ # backticks, which makes a frozen `DEC-` `touches:` entry look like a live citation: the 0.5.0
892
+ # layout move surfaced three of those, all of them correct history. Fix the source or leave it.
893
+ DERIVED = (".control/generated/",)
894
+ # A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
895
+ # memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
896
+ # happened.
897
+ DESTINATION = (
898
+ ".control/memlog/",
899
+ ".control/meetings/",
900
+ ".control/reports/",
901
+ "_bmad-output/",
902
+ )
903
+
904
+ CITE_RE = re.compile(
905
+ r"`((?:\.constitution|\.control|\.what|\.how|_bmad-output|\.work|src|web|public|deploy)"
906
+ r"/[A-Za-z0-9_./-]+\.(?:md|yaml|yml|py|go|tsx|ts|js|mjs|sql|html|css|json))`")
907
+
908
+
909
+ def v24(c: Corpus, r: Result) -> None:
910
+ """A path citation inside a document that STATES what currently holds MUST resolve.
911
+
912
+ This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
913
+ that a migration stayed complete. Its failure class is distinctive: a file gets deleted or moved,
914
+ while the routing line that points at it stays behind — no other validator sees it, because no
915
+ id moved.
916
+
917
+ Deliberately SKIPPED: files that describe the past, corpus that has been frozen, and derived
918
+ output. A `DEC-` Trace that names material that has since been retired describes what was read on
919
+ that date; reporting it would demand history be rewritten to match the present. Derived output is
920
+ skipped for a second reason on top of that: it MUST NOT be edited by hand, so a finding reported
921
+ there names a file nobody is allowed to fix.
922
+ """
923
+ scanned = 0
924
+ for path in sorted(c.root.rglob("*.md")) + sorted(c.root.rglob("*.yaml")):
925
+ rel = path.relative_to(c.root).as_posix()
926
+ if rel.startswith((".git/", "node_modules/", "_bmad-output/", ".claude/skills/bmad-")):
927
+ continue
928
+ if rel.startswith(PAST_RECORD) or rel.startswith(FROZEN) or rel.startswith(DERIVED):
929
+ continue
930
+ scanned += 1
931
+ text = path.read_text(encoding="utf-8", errors="replace")
932
+ for cited in sorted(set(CITE_RE.findall(text))):
933
+ if "<" in cited or "{" in cited:
934
+ continue # placeholder, not a path
935
+ if cited.startswith(DESTINATION):
936
+ continue
937
+ if not (c.root / cited).exists():
938
+ r.fail("V24", rel, f"cites `{cited}` which does not exist")
939
+ if not scanned:
940
+ r.skip("V24", "no file was scanned")
941
+
942
+
943
+ CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
944
+
945
+
946
+ def map_container_headings(root: Path) -> list[str] | None:
947
+ """Heading `### x` under `## Containers` in the code map. None if the map does not exist."""
948
+ path = root / ".control" / "structure-codebase.md"
949
+ if not path.exists():
950
+ return None
951
+ text = path.read_text(encoding="utf-8", errors="replace")
952
+ start = text.find("\n## Containers")
953
+ if start < 0:
954
+ return []
955
+ rest = text[start + 1:]
956
+ nxt = re.search(r"^##\s+(?!#)", rest[len("## Containers"):], re.M)
957
+ if nxt:
958
+ rest = rest[:len("## Containers") + nxt.start()]
959
+ return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
960
+
961
+
962
+ def v25(c: Corpus, r: Result) -> None:
963
+ """A container's `built` and its four consequences, plus the PC x container matrix.
964
+
965
+ A container EXISTS inside the boundary whether or not we write its content, and that is what
966
+ used to make the rule unsatisfiable: `structure-guide.md` demands every code-map heading match
967
+ the registry, while a database or web server MUST be registered and MUST NOT have a heading.
968
+ `built` separates the two, and this check is what makes that separation hold instead of the
969
+ argument being repeated on every project. `DEC-017` records its definition.
970
+
971
+ Anything whose runtime we do not deploy is an external system: it lives in C4 L1 and MUST NOT
972
+ be registered here at all — its absence from the registry is the check.
973
+ """
974
+ containers = rows(c.components, "containers")
975
+ if not containers:
976
+ r.skip("V25", "`containers:` is not registered yet")
977
+ return
978
+
979
+ built: dict[str, bool] = {}
980
+ for ctr in containers:
981
+ cid = str(ctr.get("id") or "").strip()
982
+ if not cid:
983
+ r.fail("V25", "containers", "a container has no `id`")
984
+ continue
985
+ flag = ctr.get("built")
986
+ if not isinstance(flag, bool):
987
+ r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
988
+ continue
989
+ built[cid] = flag
990
+
991
+ # (1) code-map heading = EXACTLY a container with `built: true`
992
+ headings = map_container_headings(c.root)
993
+ if headings is None:
994
+ r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
995
+ else:
996
+ for h in headings:
997
+ if h not in built:
998
+ r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
999
+ elif not built[h]:
1000
+ r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
1001
+ for cid, flag in sorted(built.items()):
1002
+ if flag and cid not in headings:
1003
+ r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
1004
+
1005
+ # (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
1006
+ for lc in c.lcs:
1007
+ ctr = str(lc.get("container") or "").strip()
1008
+ if ctr and built.get(ctr) is False:
1009
+ r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
1010
+ elif ctr and ctr not in built:
1011
+ r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
1012
+
1013
+ # (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
1014
+ for pc in c.pcs:
1015
+ pid = str(pc.get("id") or "?")
1016
+ listed = listy(pc, "containers")
1017
+ if not listed:
1018
+ r.fail("V25", pid, "`containers:` is empty every PC MUST live in at least one container (a G3 debt)")
1019
+ continue
1020
+ for ctr in listed:
1021
+ if ctr not in built:
1022
+ r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
1023
+ elif not built[ctr]:
1024
+ r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
1025
+
1026
+ # (5) L3 — only for `built: true`, and only ones that hold more than one PC
1027
+ pcs_per: dict[str, list[str]] = {}
1028
+ for pc in c.pcs:
1029
+ for ctr in listy(pc, "containers"):
1030
+ pcs_per.setdefault(ctr, []).append(str(pc.get("id") or "?"))
1031
+ for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
1032
+ cid = path.name[len("c4-l3-"):-len(".md")]
1033
+ if cid not in built:
1034
+ r.fail("V25", path.relative_to(c.root).as_posix(),
1035
+ f"L3 for `{cid}` which is not a registered container")
1036
+ elif not built[cid]:
1037
+ r.fail("V25", path.relative_to(c.root).as_posix(),
1038
+ f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
1039
+ for cid, pids in sorted(pcs_per.items()):
1040
+ if built.get(cid) and len(pids) > 1:
1041
+ l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
1042
+ if not l3.exists():
1043
+ r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
1044
+
1045
+
1046
+ UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
1047
+
1048
+ # The `critical` column value is machine-matched, so it is machine-facing and its canonical form
1049
+ # is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
1050
+ # MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
1051
+ # matching inside other words.
1052
+ CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
1053
+
1054
+
1055
+ def v26(c: Corpus, r: Result) -> None:
1056
+ """The UC catalogue in every SRS MUST agree with `usecases.yaml` both its id AND its `critical`.
1057
+
1058
+ This is the most expensive gap this pass closes, because it is the only one that **had already
1059
+ happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
1060
+ narrowed definition money, personal data, irreversible action and the seven catalogue tables
1061
+ in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
1062
+ discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
1063
+ registry held three.
1064
+
1065
+ The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
1066
+ and two homes for one fact are only safe if something compares them. This is what compares them.
1067
+
1068
+ What is NOT checked here: title and actor. Both are prose, and prose with different words is
1069
+ not prose with a different meaning — comparing them would report style as a defect.
1070
+ """
1071
+ reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
1072
+ reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
1073
+ checked = 0
1074
+ for pc in c.pcs:
1075
+ pid = str(pc.get("id"))
1076
+ path = c.root / f".what/{pid}/SRS-{pid}.md"
1077
+ if not path.exists():
1078
+ continue
1079
+ checked += 1
1080
+ text = path.read_text(encoding="utf-8", errors="replace")
1081
+ seen: set[str] = set()
1082
+ for match in UC_ROW_RE.finditer(text):
1083
+ uid = match.group(1)
1084
+ cells = [x.strip() for x in match.group(2).split("|")]
1085
+ if len(cells) < 4:
1086
+ continue
1087
+ seen.add(uid)
1088
+ if uid not in reg:
1089
+ r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
1090
+ continue
1091
+ if reg_pc[uid] != pid:
1092
+ r.fail("V26", f"{pid}/{uid}",
1093
+ f"the registry places it in `{reg_pc[uid]}`, not in this component")
1094
+ marked = CRITICAL_YES.search(cells[3]) is not None
1095
+ if marked != reg[uid]:
1096
+ r.fail("V26", f"{pid}/{uid}",
1097
+ f"`critical` in the SRS {'yes' if marked else 'no'}, "
1098
+ f"in the registry {'yes' if reg[uid] else 'no'}")
1099
+ for uid, owner in sorted(reg_pc.items()):
1100
+ if owner == pid and uid not in seen:
1101
+ r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
1102
+ if not checked:
1103
+ r.skip("V26", "no SRS could be read")
1104
+
1105
+
1106
+ def v27(c: Corpus, r: Result) -> None:
1107
+ """Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
1108
+
1109
+ The `.constitution/project/` room exists so product-specific rules have a home that `update`
1110
+ does not overwrite and `promote` does not publish. The cost that comes with it: it is also the
1111
+ easiest place to break a generic rule without a trace. Its frontmatter is what holds that back.
1112
+
1113
+ A file here MAY narrow or add without naming anything. To REBUT a generic rule it MUST name it
1114
+ in `overrides:` and carry a `decision:` because a method that can be rebutted without a
1115
+ decision stops being trustworthy in the next repo.
1116
+
1117
+ Four files in the room are STRUCTURAL and are skipped, because they are not ad-hoc rules and
1118
+ carry their own frontmatter conventions instead:
1119
+
1120
+ README.md authored in the package, not in the product
1121
+ constitution.md Articles 1, 2, 5 — carries `status:`, and Article 4 governs it
1122
+ codebase-*-guide.md the stack, conventions, and brownfield guides — `status:` plus
1123
+ `ratified_by:`, and they are filled by a wave's distillation
1124
+
1125
+ Demanding `scope:` and `purpose:` of those would be demanding a declaration of files whose
1126
+ role is already fixed by the layout. What V27 exists to guard is the file somebody ADDS.
1127
+ """
1128
+ room = c.root / ".constitution" / "project"
1129
+ if not room.is_dir():
1130
+ r.skip("V27", "the `.constitution/project/` room does not exist yet — it is seeded at install")
1131
+ return
1132
+ structural = {"README.md", "constitution.md"}
1133
+ files = [p for p in sorted(room.rglob("*.md"))
1134
+ if p.name not in structural and not p.name.startswith("codebase-")]
1135
+ if not files:
1136
+ r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
1137
+ "a generic rule MUST NOT be moved here just to give the room content")
1138
+ return
1139
+ dec_ids = {str(d.get("id")) for d in c.decs}
1140
+ for path in files:
1141
+ rel = path.relative_to(c.root).as_posix()
1142
+ fm = frontmatter(path)
1143
+ if fm is None:
1144
+ r.fail("V27", rel, "has no frontmatter")
1145
+ continue
1146
+ if str(fm.get("scope") or "").strip() != "project":
1147
+ r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
1148
+ if not str(fm.get("purpose") or "").strip():
1149
+ r.fail("V27", rel, "`purpose:` is empty one line: what this rule guards")
1150
+ over = str(fm.get("overrides") or "").strip()
1151
+ dec = str(fm.get("decision") or "").strip()
1152
+ if over:
1153
+ if not (c.root / over).exists():
1154
+ r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
1155
+ f"the rebutted rule may already be gone")
1156
+ if not dec:
1157
+ r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
1158
+ "a rebuttal MUST have a `DEC-` that decided it")
1159
+ elif dec not in dec_ids:
1160
+ r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
1161
+ elif dec:
1162
+ r.fail("V27", rel, "`decision:` is set without `overrides:` — "
1163
+ "name which rule is rebutted, or drop `decision:`")
1164
+
1165
+
1166
+ def run_checks(c: Corpus, asof: dt.date) -> Result:
1167
+ r = Result()
1168
+ for fn in (v1, v2, v3, v4, v5, v6, v7, v8, v9, v11, v12, v13, v15, v16, v17, v18, v19, v20,
1169
+ v21, v22, v23, v24, v25, v26, v27):
1170
+ fn(c, r)
1171
+ v14(c, r, asof)
1172
+ return r
1173
+
1174
+
1175
+ # ------------------------------------------------------------------ generator
1176
+
1177
+
1178
+ def _story_status(c: Corpus, story: dict) -> str:
1179
+ folder = str(story.get("spec_folder") or "").strip()
1180
+ if not folder:
1181
+ return "unknown"
1182
+ matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
1183
+ if not matches:
1184
+ return "unknown"
1185
+ return str((frontmatter(matches[0]) or {}).get("status") or "unknown")
1186
+
1187
+
1188
+ def gen_components(c: Corpus) -> dict:
1189
+ return {
1190
+ "product_components": [
1191
+ {"id": pc.get("id"), "name": pc.get("name"),
1192
+ "containers": listy(pc, "containers"),
1193
+ "logical_components": sorted(
1194
+ str(lc.get("id")) for lc in c.lcs
1195
+ if str(lc.get("component")) == str(pc.get("id")))}
1196
+ for pc in c.pcs
1197
+ ],
1198
+ "logical_components": [
1199
+ {"id": lc.get("id"), "type": lc.get("type"), "component": lc.get("component"),
1200
+ "area": lc.get("area"), "owner": lc.get("owner")}
1201
+ for lc in c.lcs
1202
+ ],
1203
+ }
1204
+
1205
+
1206
+ def gen_risks(c: Corpus) -> dict:
1207
+ return {"risks": [
1208
+ {"id": x.get("id"), "impact": x.get("impact"), "likelihood": x.get("likelihood"),
1209
+ "owner": x.get("owner"), "status": x.get("status"),
1210
+ "pivot_trigger": x.get("pivot_trigger")}
1211
+ for x in rows(c.risks, "risks") if str(x.get("status")) != "closed"
1212
+ ]}
1213
+
1214
+
1215
+ def gen_dag(c: Corpus) -> dict:
1216
+ out = []
1217
+ per_wave: dict[str, list[dict]] = {}
1218
+ for wave, _, story in c.stories():
1219
+ per_wave.setdefault(str(wave.get("id")), []).append(story)
1220
+ for wid in sorted(per_wave):
1221
+ items = per_wave[wid]
1222
+ done: set[str] = set()
1223
+ pending = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
1224
+ waves_out = []
1225
+ while pending:
1226
+ ready = sorted(k for k, deps in pending.items() if not (deps - done))
1227
+ if not ready: # cycle — V7 has already reported it
1228
+ waves_out.append({"blocked": sorted(pending)})
1229
+ break
1230
+ waves_out.append({"parallel": ready})
1231
+ done |= set(ready)
1232
+ for k in ready:
1233
+ pending.pop(k)
1234
+ out.append({"wave": wid, "order": waves_out})
1235
+ return {"dag": out}
1236
+
1237
+
1238
+ def gen_rtm(c: Corpus) -> dict:
1239
+ cap_goal = {str(x.get("id")): str(x.get("goal", "")) for x in c.caps}
1240
+ ucs_for_fr: dict[str, list[str]] = {}
1241
+ for uc in c.ucs:
1242
+ for fr in listy(uc, "satisfies"):
1243
+ ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
1244
+ stories_for_uc: dict[str, list[tuple[dict, dict]]] = {}
1245
+ for wave, _, story in c.stories():
1246
+ for uc in listy(story, "satisfies"):
1247
+ stories_for_uc.setdefault(uc, []).append((wave, story))
1248
+ decs_for: dict[str, list[str]] = {}
1249
+ for dec in c.decs:
1250
+ for target in listy(dec, "serves"):
1251
+ decs_for.setdefault(target, []).append(str(dec.get("id")))
1252
+
1253
+ lines = []
1254
+ for fr in c.frs:
1255
+ fid = str(fr.get("id"))
1256
+ cap = str(fr.get("capability", ""))
1257
+ base = {"BG": cap_goal.get(cap, ""), "CAP": cap, "FR": fid,
1258
+ "DEC": sorted(decs_for.get(fid, []))}
1259
+ ucs = sorted(ucs_for_fr.get(fid, []))
1260
+ if not ucs:
1261
+ exempt = bool(str(fr.get("no_uc") or "").strip())
1262
+ lines.append({**base, "UC": "", "story": "", "wave": "", "release": "",
1263
+ "test": [], "status": "", "green": False,
1264
+ "exempt": exempt,
1265
+ "broken_at": "no_uc" if exempt else "UC"})
1266
+ continue
1267
+ for uid in ucs:
1268
+ pairs = sorted(stories_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
1269
+ if not pairs:
1270
+ lines.append({**base, "UC": uid, "story": "", "wave": "", "release": "",
1271
+ "test": [], "status": "", "green": False, "exempt": False,
1272
+ "broken_at": "story"})
1273
+ continue
1274
+ for wave, story in pairs:
1275
+ status = _story_status(c, story)
1276
+ tests = listy(story, "tests")
1277
+ broken = ""
1278
+ if not tests:
1279
+ broken = "test"
1280
+ elif status != "done":
1281
+ broken = "status"
1282
+ lines.append({**base, "UC": uid, "story": str(story.get("id")),
1283
+ "wave": str(wave.get("id")), "release": str(wave.get("release", "")),
1284
+ "test": tests, "status": status, "exempt": False,
1285
+ "green": broken == "", "broken_at": broken})
1286
+ return {"rtm": lines}
1287
+
1288
+
1289
+ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1290
+ lines = rtm.get("rtm") or []
1291
+ counted = [line for line in lines if not line.get("exempt")]
1292
+ exempt = len(lines) - len(counted)
1293
+ green = sum(1 for line in counted if line.get("green"))
1294
+ per_wave = []
1295
+ for wave in c.wave_list:
1296
+ wid = str(wave.get("id"))
1297
+ items = [s for w, _, s in c.stories() if str(w.get("id")) == wid]
1298
+ done = sum(1 for s in items if _story_status(c, s) == "done")
1299
+ per_wave.append({"wave": wid, "status": wave.get("status"),
1300
+ "stories_done": done, "stories_total": len(items),
1301
+ "work_progress": _pct(done, len(items))})
1302
+ applicable = 26 # V1..V27 minus V10, which was retired
1303
+ return {
1304
+ "promise_progress": _pct(green, len(counted)),
1305
+ "rtm_rows": {"green": green, "counted": len(counted),
1306
+ "excluded_no_uc": exempt},
1307
+ "work_progress": per_wave,
1308
+ "gate_readiness": _pct(applicable - len(result.red), applicable),
1309
+ "validators_red": result.red,
1310
+ "validators_skipped": dict(sorted(result.skipped.items())),
1311
+ "open_questions": _question_budget(c),
1312
+ }
1313
+
1314
+
1315
+ def _question_budget(c: Corpus) -> dict:
1316
+ """Counts of all four question lists, compared against the budget in index.yaml.
1317
+
1318
+ The budget is NOT a hard gate. It is reported when a batch exceeds it, because a larger
1319
+ batch is a signal about the pass, not about the corpus.
1320
+ """
1321
+ budget = c.index.get("question_budget") or {}
1322
+ out: dict[str, object] = {}
1323
+ for name in ("blocking", "assumptions", "external", "answered"):
1324
+ path = c.root / ".control/questions" / f"{name}.md"
1325
+ rows_n = 0
1326
+ if path.exists():
1327
+ rows_n = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
1328
+ if line.startswith("| OQ-"))
1329
+ out[name] = rows_n
1330
+ cap_block = budget.get("blocking_per_component")
1331
+ if cap_block and c.pcs:
1332
+ allowed = int(cap_block) * len(c.pcs)
1333
+ out["blocking_budget"] = allowed
1334
+ out["blocking_over_budget"] = out["blocking"] > allowed
1335
+ cap_assume = budget.get("assumptions_per_gate")
1336
+ if cap_assume:
1337
+ out["assumptions_budget_per_gate"] = int(cap_assume)
1338
+ return out
1339
+
1340
+
1341
+ def _pct(part: int, total: int) -> str:
1342
+ return "n/a" if total == 0 else f"{round(100 * part / total)}%"
1343
+
1344
+
1345
+ def as_markdown(name: str, payload: dict) -> str:
1346
+ body = dump(payload)
1347
+ return (f"# {name}\n\n"
1348
+ f"> Generated by `.constitution/method/scripts/validate.py --generate`. "
1349
+ f"MUST NOT be hand-edited.\n\n"
1350
+ f"```yaml\n{body}```\n")
1351
+
1352
+
1353
+ # ------------------------------------------------------- pages for humans
1354
+
1355
+ PAGE_HEADER = ("> Generated by `.constitution/method/scripts/validate.py --generate`. "
1356
+ "MUST NOT be hand-edited.\n")
1357
+
1358
+
1359
+ def _section(path: Path, heading: str) -> str:
1360
+ """Extract one `## <heading>` section from a markdown file, as-is."""
1361
+ if not path.exists():
1362
+ return ""
1363
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
1364
+ out: list[str] = []
1365
+ inside = False
1366
+ for line in lines:
1367
+ if line.startswith("## "):
1368
+ if inside:
1369
+ break
1370
+ inside = line[3:].strip().lower().startswith(heading.lower())
1371
+ continue
1372
+ if inside:
1373
+ out.append(line)
1374
+ return "\n".join(out).strip("\n")
1375
+
1376
+
1377
+ def _body(path: Path) -> str:
1378
+ """File content without frontmatter and without template comments."""
1379
+ if not path.exists():
1380
+ return ""
1381
+ text = path.read_text(encoding="utf-8", errors="replace")
1382
+ match = FM.match(text)
1383
+ if match:
1384
+ text = text[match.end():]
1385
+ while "<!--" in text and "-->" in text:
1386
+ head, _, rest = text.partition("<!--")
1387
+ _, _, tail = rest.partition("-->")
1388
+ text = head + tail
1389
+ return text.strip("\n")
1390
+
1391
+
1392
+ def page_decisions(c: Corpus) -> str:
1393
+ """Flat table of every `DEC-`. This is what replaces looking up decisions through the memlog."""
1394
+ rows_out = ["| id | Title | Status | Type | Touches | File |",
1395
+ "| --- | --- | --- | --- | --- | --- |"]
1396
+ for dec in c.decs:
1397
+ touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
1398
+ rows_out.append(
1399
+ f"| `{dec.get('id')}` | {_cell(dec.get('title'))} | `{dec.get('status', '')}` "
1400
+ f"| {dec.get('type') or '—'} | {touches} | `{dec.get('file', '')}` |")
1401
+ counts: dict[str, int] = {}
1402
+ for dec in c.decs:
1403
+ key = str(dec.get("status"))
1404
+ counts[key] = counts.get(key, 0) + 1
1405
+ tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "no decisions yet"
1406
+ return ("# decisions\n\n" + PAGE_HEADER +
1407
+ "\nDecisions are no longer looked up through the memlog the memlog goes back to being just a pass log.\n"
1408
+ f"\n**{len(c.decs)} decisions** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
1409
+
1410
+
1411
+ def page_blueprint(c: Corpus) -> str:
1412
+ """One-page roll-up reviewed at G3. Seven files become one read.
1413
+
1414
+ The UC catalogue, actor list, and domain model stay put in their own component's kernel as
1415
+ their permanent home. This is their view. One fact, one home, one view.
1416
+ """
1417
+ parts = ["# blueprint\n", PAGE_HEADER,
1418
+ "\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
1419
+ "content is affected by neither `mode` nor `risk_accepted`.\n"]
1420
+
1421
+ crit = sum(1 for uc in c.ucs if uc.get("critical"))
1422
+ parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
1423
+ f"`critical`.\n")
1424
+ parts.append("| id | Use case | Component | Satisfies | critical |")
1425
+ parts.append("| --- | --- | --- | --- | --- |")
1426
+ for uc in c.ucs:
1427
+ sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
1428
+ flag = "yes" if uc.get("critical") else "no"
1429
+ parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
1430
+ f"`{uc.get('component', '')}` | {sat} | {flag} |")
1431
+
1432
+ parts.append("\n## Actor list\n")
1433
+ for pc in c.pcs:
1434
+ pid = str(pc.get("id"))
1435
+ block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
1436
+ parts.append(f"\n### {pid} {pc.get('name', '')}\n")
1437
+ parts.append(_demote(block) if block
1438
+ else "_no § Actor Register in this component's SRS yet._")
1439
+
1440
+ parts.append("\n## Domain model\n")
1441
+ for pc in c.pcs:
1442
+ pid = str(pc.get("id"))
1443
+ block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
1444
+ parts.append(f"\n### {pid}\n")
1445
+ parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
1446
+
1447
+ parts.append("\n## Three inventories\n")
1448
+ for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
1449
+ block = _body(c.root / f".how/_platform/inventory-{kind}.md")
1450
+ parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
1451
+ parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
1452
+
1453
+ return "\n".join(parts) + "\n"
1454
+
1455
+
1456
+ def _cell(value: object, limit: int = 110) -> str:
1457
+ """One table row, shortened. The full-length source stays in the registry — this is just a view."""
1458
+ text = " ".join(str(value or "").split()).replace("|", "\\|")
1459
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
1460
+
1461
+
1462
+ def _demote(block: str, by: int = 2) -> str:
1463
+ """Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
1464
+ out = []
1465
+ for line in block.splitlines():
1466
+ stripped = line.lstrip()
1467
+ if stripped.startswith("#"):
1468
+ hashes = len(stripped) - len(stripped.lstrip("#"))
1469
+ out.append("#" * min(6, hashes + by) + stripped[hashes:])
1470
+ else:
1471
+ out.append(line)
1472
+ return "\n".join(out)
1473
+
1474
+
1475
+ def page_estimate(c: Corpus) -> str:
1476
+ """Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
1477
+ mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
1478
+ risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
1479
+ str(pc.get("risk_note") or "")) for pc in c.pcs}
1480
+ cap_by_id = {str(x.get("id")): x for x in c.caps}
1481
+ fr_per_cap: dict[str, int] = {}
1482
+ for fr in c.frs:
1483
+ key = str(fr.get("capability", ""))
1484
+ fr_per_cap[key] = fr_per_cap.get(key, 0) + 1
1485
+
1486
+ have_mandays = any(x.get("estimate_mandays") for x in c.caps)
1487
+ parts = ["# estimate\n", PAGE_HEADER,
1488
+ "\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
1489
+ "task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
1490
+ "neighboring rows MAY be merged into one that merge is a human decision made when the "
1491
+ "wave is opened.\n"]
1492
+ if not have_mandays:
1493
+ parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
1494
+ "this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
1495
+
1496
+ parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
1497
+ parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
1498
+ for fr in c.frs:
1499
+ cap_id = str(fr.get("capability", ""))
1500
+ cap = cap_by_id.get(cap_id, {})
1501
+ pid = str(fr.get("component") or cap.get("component") or "")
1502
+ risk, note = risk_of.get(pid, ("—", "—"))
1503
+ exposure = "not set yet" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
1504
+ mandays = cap.get("estimate_mandays")
1505
+ share = ""
1506
+ if mandays:
1507
+ try:
1508
+ share = f"{float(mandays) / max(1, fr_per_cap.get(cap_id, 1)):.1f}"
1509
+ except (TypeError, ValueError):
1510
+ share = "—"
1511
+ deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
1512
+ parts.append(
1513
+ f"| {_cell(fr.get('text') or fr.get('title'))} | `{fr.get('id')}` | `{pid or '—'}` "
1514
+ f"| `{mode_of.get(pid, 'catalog')}` | {exposure} | {share} "
1515
+ f"| {cap.get('priority', '—')} | {deps} | {cap.get('target_release', '—')} |")
1516
+ return "\n".join(parts) + "\n"
1517
+
1518
+
1519
+ def generate(c: Corpus, result: Result) -> list[Path]:
1520
+ out_dir = c.root / ".control" / "generated"
1521
+ out_dir.mkdir(parents=True, exist_ok=True)
1522
+ rtm = gen_rtm(c)
1523
+ payloads = {
1524
+ "components": gen_components(c),
1525
+ "risks": gen_risks(c),
1526
+ "dag": gen_dag(c),
1527
+ "rtm": rtm,
1528
+ "status": gen_status(c, rtm, result),
1529
+ }
1530
+ written = []
1531
+ for name in GENERATED_ORDER:
1532
+ payload = payloads[name]
1533
+ yaml_path = out_dir / f"{name}.yaml"
1534
+ yaml_path.write_text(dump(payload), encoding="utf-8")
1535
+ md_path = out_dir / f"{name}.md"
1536
+ md_path.write_text(as_markdown(name, payload), encoding="utf-8")
1537
+ written += [yaml_path, md_path]
1538
+
1539
+ # Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
1540
+ # not wrapped in a yaml fence, and no machine reader demands a second version of it.
1541
+ for name, render in (("decisions", page_decisions),
1542
+ ("blueprint", page_blueprint),
1543
+ ("estimate", page_estimate)):
1544
+ page = out_dir / f"{name}.md"
1545
+ page.write_text(render(c), encoding="utf-8")
1546
+ written.append(page)
1547
+ return written
1548
+
1549
+
1550
+ # ------------------------------------------------------------------------ CLI
1551
+
1552
+
1553
+ def main(argv: list[str] | None = None) -> int:
1554
+ parser = argparse.ArgumentParser(
1555
+ prog="validate", description="V1..V27 and the .control/generated/ generator")
1556
+ parser.add_argument("--check", action="store_true",
1557
+ help="check only; exit non-zero if anything is red")
1558
+ parser.add_argument("--generate", action="store_true",
1559
+ help="rewrite .control/generated/ (still runs the check first)")
1560
+ parser.add_argument("--root", default=".", help="repo root (default: current directory)")
1561
+ parser.add_argument("--asof", default=None,
1562
+ help="reference date for V14, format YYYY-MM-DD (default: today). "
1563
+ "Stated explicitly so a run can be repeated exactly")
1564
+ args = parser.parse_args(argv)
1565
+
1566
+ if not args.check and not args.generate:
1567
+ args.check = True
1568
+
1569
+ root = Path(args.root).resolve()
1570
+ if not (root / ".control" / "registry").is_dir():
1571
+ print(f"validate: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
1572
+ return 2
1573
+
1574
+ asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
1575
+ corpus = Corpus.load(root)
1576
+ result = run_checks(corpus, asof)
1577
+
1578
+ if args.generate:
1579
+ for path in generate(corpus, result):
1580
+ print(f" wrote {path.relative_to(root).as_posix()}")
1581
+
1582
+ if result.findings:
1583
+ print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
1584
+ for finding in sorted(result.findings, key=lambda f: f.sort_key):
1585
+ print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
1586
+ else:
1587
+ print("\nGREEN — no findings")
1588
+
1589
+ if result.skipped:
1590
+ print("\nSkipped:")
1591
+ for vid, why in sorted(result.skipped.items()):
1592
+ print(f" {vid:<4} {why}")
1593
+
1594
+ print(f"\nV14 reference date: {asof.isoformat()}")
1595
+ return 1 if result.findings else 0
1596
+
1597
+
1598
+ if __name__ == "__main__":
1599
+ raise SystemExit(main())