wdi-method 0.5.10 → 0.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kit/.constitution/method/scripts/validate.py +1676 -1676
- package/package.json +1 -1
|
@@ -1,1676 +1,1676 @@
|
|
|
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 os
|
|
25
|
-
import re
|
|
26
|
-
import subprocess
|
|
27
|
-
import sys
|
|
28
|
-
from dataclasses import dataclass, field
|
|
29
|
-
from pathlib import Path
|
|
30
|
-
|
|
31
|
-
import yaml
|
|
32
|
-
|
|
33
|
-
REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
|
|
34
|
-
GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
|
|
35
|
-
|
|
36
|
-
# Pages read by HUMANS, not machines: written as real markdown tables, not yaml
|
|
37
|
-
# in a fence. All three are named in §22 and each has one clear reader.
|
|
38
|
-
GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
|
|
39
|
-
|
|
40
|
-
MODES = ("catalog", "outline", "guarded", "deep")
|
|
41
|
-
|
|
42
|
-
# Keywords that make a component "sensitive" for V23. Matched against `risk_note`, which is PROSE in
|
|
43
|
-
# whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
|
|
44
|
-
# than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
|
|
45
|
-
# it does not judge. Deliberately short.
|
|
46
|
-
SENSITIVE_MARKERS = (
|
|
47
|
-
# English
|
|
48
|
-
"money", "payment", "personal data", "pii",
|
|
49
|
-
"irreversible", "cannot be undone", "contractual", "contract", "integration",
|
|
50
|
-
# Bahasa Indonesia
|
|
51
|
-
"uang", "pembayaran", "data pribadi",
|
|
52
|
-
"tak-terbalikkan", "tak terbalikkan", "tidak dapat dibatalkan",
|
|
53
|
-
"kontraktual", "kontrak", "integrasi",
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# ---------------------------------------------------------------- infrastructure
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@dataclass(frozen=True)
|
|
61
|
-
class Finding:
|
|
62
|
-
vid: str
|
|
63
|
-
subject: str
|
|
64
|
-
message: str
|
|
65
|
-
|
|
66
|
-
@property
|
|
67
|
-
def sort_key(self) -> tuple[int, str, str]:
|
|
68
|
-
digits = "".join(ch for ch in self.vid if ch.isdigit())
|
|
69
|
-
return (int(digits or 0), self.subject, self.message)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@dataclass
|
|
73
|
-
class Result:
|
|
74
|
-
findings: list[Finding] = field(default_factory=list)
|
|
75
|
-
skipped: dict[str, str] = field(default_factory=dict)
|
|
76
|
-
|
|
77
|
-
def fail(self, vid: str, subject: str, message: str) -> None:
|
|
78
|
-
self.findings.append(Finding(vid, subject, message))
|
|
79
|
-
|
|
80
|
-
def skip(self, vid: str, why: str) -> None:
|
|
81
|
-
self.skipped[vid] = why
|
|
82
|
-
|
|
83
|
-
@property
|
|
84
|
-
def red(self) -> list[str]:
|
|
85
|
-
return sorted({f.vid for f in self.findings})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def load_yaml(path: Path) -> dict:
|
|
89
|
-
if not path.exists():
|
|
90
|
-
return {}
|
|
91
|
-
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
92
|
-
return data if isinstance(data, dict) else {}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def rows(data: dict, key: str) -> list[dict]:
|
|
96
|
-
"""Registry list, always sorted by id so the output is deterministic."""
|
|
97
|
-
value = data.get(key) or []
|
|
98
|
-
if not isinstance(value, list):
|
|
99
|
-
return []
|
|
100
|
-
items = [v for v in value if isinstance(v, dict)]
|
|
101
|
-
return sorted(items, key=lambda r: str(r.get("id", "")))
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
class Dumper(yaml.SafeDumper):
|
|
108
|
-
"""No anchors/aliases: output MUST be readable and diffable line by line."""
|
|
109
|
-
|
|
110
|
-
def ignore_aliases(self, data) -> bool: # noqa: ARG002
|
|
111
|
-
return True
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def dump(payload: dict) -> str:
|
|
115
|
-
return yaml.dump(payload, Dumper=Dumper, allow_unicode=True, sort_keys=False,
|
|
116
|
-
default_flow_style=False, width=100)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def frontmatter(path: Path) -> dict | None:
|
|
120
|
-
"""None if the file does not exist; {} if it exists but has no frontmatter."""
|
|
121
|
-
if not path.exists():
|
|
122
|
-
return None
|
|
123
|
-
match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
|
|
124
|
-
if not match:
|
|
125
|
-
return {}
|
|
126
|
-
data = yaml.safe_load(match.group(1))
|
|
127
|
-
return data if isinstance(data, dict) else {}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def git(root: Path, *args: str) -> str | None:
|
|
131
|
-
try:
|
|
132
|
-
out = subprocess.run(
|
|
133
|
-
["git", "-C", str(root), *args],
|
|
134
|
-
capture_output=True, text=True, timeout=30, check=False,
|
|
135
|
-
)
|
|
136
|
-
except (OSError, subprocess.SubprocessError):
|
|
137
|
-
return None
|
|
138
|
-
return out.stdout.strip() if out.returncode == 0 else None
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# ------------------------------------------------------------------- loading
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
@dataclass
|
|
145
|
-
class Corpus:
|
|
146
|
-
root: Path
|
|
147
|
-
requirements: dict
|
|
148
|
-
usecases: dict
|
|
149
|
-
decisions: dict
|
|
150
|
-
risks: dict
|
|
151
|
-
components: dict
|
|
152
|
-
waves: dict
|
|
153
|
-
defects: dict
|
|
154
|
-
index: dict
|
|
155
|
-
|
|
156
|
-
@classmethod
|
|
157
|
-
def load(cls, root: Path) -> "Corpus":
|
|
158
|
-
reg = root / ".control" / "registry"
|
|
159
|
-
return cls(
|
|
160
|
-
root=root,
|
|
161
|
-
requirements=load_yaml(reg / "requirements.yaml"),
|
|
162
|
-
usecases=load_yaml(reg / "usecases.yaml"),
|
|
163
|
-
decisions=load_yaml(reg / "decisions.yaml"),
|
|
164
|
-
risks=load_yaml(reg / "risks.yaml"),
|
|
165
|
-
components=load_yaml(reg / "components.yaml"),
|
|
166
|
-
waves=load_yaml(reg / "waves.yaml"),
|
|
167
|
-
defects=load_yaml(reg / "defects.yaml"),
|
|
168
|
-
index=load_yaml(reg / "index.yaml"),
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
# --- shortcuts used repeatedly
|
|
172
|
-
@property
|
|
173
|
-
def goals(self) -> list[dict]:
|
|
174
|
-
return rows(self.requirements, "goals")
|
|
175
|
-
|
|
176
|
-
@property
|
|
177
|
-
def caps(self) -> list[dict]:
|
|
178
|
-
return rows(self.requirements, "capabilities")
|
|
179
|
-
|
|
180
|
-
@property
|
|
181
|
-
def frs(self) -> list[dict]:
|
|
182
|
-
return rows(self.requirements, "functional")
|
|
183
|
-
|
|
184
|
-
@property
|
|
185
|
-
def nfrs(self) -> list[dict]:
|
|
186
|
-
return rows(self.requirements, "nonfunctional")
|
|
187
|
-
|
|
188
|
-
@property
|
|
189
|
-
def ucs(self) -> list[dict]:
|
|
190
|
-
return rows(self.usecases, "usecases")
|
|
191
|
-
|
|
192
|
-
@property
|
|
193
|
-
def decs(self) -> list[dict]:
|
|
194
|
-
return rows(self.decisions, "decisions")
|
|
195
|
-
|
|
196
|
-
def mode_of(self, pc: dict) -> str:
|
|
197
|
-
"""Per-component `mode` wins over the global one; with neither, default `catalog`."""
|
|
198
|
-
own = str(pc.get("mode") or "").strip()
|
|
199
|
-
if own:
|
|
200
|
-
return own
|
|
201
|
-
return str(self.index.get("mode") or "").strip() or "catalog"
|
|
202
|
-
|
|
203
|
-
@property
|
|
204
|
-
def lcs(self) -> list[dict]:
|
|
205
|
-
return rows(self.components, "logical_components")
|
|
206
|
-
|
|
207
|
-
@property
|
|
208
|
-
def pcs(self) -> list[dict]:
|
|
209
|
-
return rows(self.components, "product_components")
|
|
210
|
-
|
|
211
|
-
@property
|
|
212
|
-
def wave_list(self) -> list[dict]:
|
|
213
|
-
return rows(self.waves, "waves")
|
|
214
|
-
|
|
215
|
-
@property
|
|
216
|
-
def defect_list(self) -> list[dict]:
|
|
217
|
-
return rows(self.defects, "defects")
|
|
218
|
-
|
|
219
|
-
def stories(self) -> list[tuple[dict, dict, dict]]:
|
|
220
|
-
"""(wave, epic, story) — sorted by id at each level."""
|
|
221
|
-
out = []
|
|
222
|
-
for wave in self.wave_list:
|
|
223
|
-
for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
|
|
224
|
-
if not isinstance(epic, dict):
|
|
225
|
-
continue
|
|
226
|
-
for story in sorted(epic.get("stories") or [], key=lambda s: str(s.get("id", ""))):
|
|
227
|
-
if isinstance(story, dict):
|
|
228
|
-
out.append((wave, epic, story))
|
|
229
|
-
return out
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def listy(row: dict, key: str) -> list[str]:
|
|
233
|
-
value = row.get(key) or []
|
|
234
|
-
if isinstance(value, str):
|
|
235
|
-
return [value]
|
|
236
|
-
return [str(v) for v in value if v is not None]
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
# ------------------------------------------------------------------ validators
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def v1(c: Corpus, r: Result) -> None:
|
|
243
|
-
"""Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
|
|
244
|
-
|
|
245
|
-
A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
|
|
246
|
-
deployment foundation can be extended without being torn down — is measured by two architectural
|
|
247
|
-
properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
|
|
248
|
-
one `FR` there produces a false promise, and a false promise is more expensive than a finding.
|
|
249
|
-
|
|
250
|
-
The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
|
|
251
|
-
"""
|
|
252
|
-
cap_by_goal: dict[str, list[str]] = {}
|
|
253
|
-
for cap in c.caps:
|
|
254
|
-
cap_by_goal.setdefault(str(cap.get("goal", "")), []).append(str(cap.get("id")))
|
|
255
|
-
fr_caps = {str(fr.get("capability", "")) for fr in c.frs}
|
|
256
|
-
for goal in c.goals:
|
|
257
|
-
gid = str(goal.get("id"))
|
|
258
|
-
reachable = [cid for cid in cap_by_goal.get(gid, []) if cid in fr_caps]
|
|
259
|
-
if reachable:
|
|
260
|
-
continue
|
|
261
|
-
if str(goal.get("no_fr") or "").strip():
|
|
262
|
-
continue
|
|
263
|
-
r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def v2(c: Corpus, r: Result) -> None:
|
|
267
|
-
covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
|
|
268
|
-
for fr in c.frs:
|
|
269
|
-
fid = str(fr.get("id"))
|
|
270
|
-
if fid in covered:
|
|
271
|
-
continue
|
|
272
|
-
if str(fr.get("no_uc") or "").strip():
|
|
273
|
-
continue
|
|
274
|
-
r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def v3(c: Corpus, r: Result) -> None:
|
|
278
|
-
"""A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
|
|
279
|
-
|
|
280
|
-
The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
|
|
281
|
-
entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
|
|
282
|
-
not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
|
|
283
|
-
findings under fifty-six expected ones stops being read, and a validator that is not read
|
|
284
|
-
guards nothing.
|
|
285
|
-
|
|
286
|
-
What is guarded now is the actual omission: a wave touches a component, and a UC of that
|
|
287
|
-
component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
|
|
288
|
-
and `wdi-build` owns it — the same way V12 was shifted to wave closing.
|
|
289
|
-
"""
|
|
290
|
-
scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
|
|
291
|
-
touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
|
|
292
|
-
if not c.wave_list:
|
|
293
|
-
r.skip("V3", "no wave yet, so no story yet — every unscheduled UC is the correct "
|
|
294
|
-
"state. Full catalogue coverage is checked at G5")
|
|
295
|
-
return
|
|
296
|
-
for uc in c.ucs:
|
|
297
|
-
uid = str(uc.get("id"))
|
|
298
|
-
if uid in scheduled or str(uc.get("component")) not in touched:
|
|
299
|
-
continue
|
|
300
|
-
r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
|
|
301
|
-
f"but this UC is not scheduled to any story")
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def v4(c: Corpus, r: Result) -> None:
|
|
305
|
-
for _, _, story in c.stories():
|
|
306
|
-
if not [t for t in listy(story, "tests") if t.strip()]:
|
|
307
|
-
r.fail("V4", str(story.get("id")), "has not one named test")
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def v5(c: Corpus, r: Result) -> None:
|
|
311
|
-
"""Every NFR has an enforcer, OR states its reason in `no_enforcer`.
|
|
312
|
-
|
|
313
|
-
Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
|
|
314
|
-
**retired**, and the other states of itself that it is a **design measure, not a gate**. Demanding
|
|
315
|
-
a test for both produces a test that cannot fail, and a test that cannot fail is theater.
|
|
316
|
-
"""
|
|
317
|
-
for nfr in c.nfrs:
|
|
318
|
-
if [e for e in listy(nfr, "enforced_by") if e.strip()]:
|
|
319
|
-
continue
|
|
320
|
-
if str(nfr.get("no_enforcer") or "").strip():
|
|
321
|
-
continue
|
|
322
|
-
r.fail("V5", str(nfr.get("id")),
|
|
323
|
-
"has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def v6(c: Corpus, r: Result) -> None:
|
|
327
|
-
defined: set[str] = set()
|
|
328
|
-
for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
|
|
329
|
-
rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
|
|
330
|
-
defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
|
|
331
|
-
for wave in c.wave_list:
|
|
332
|
-
defined.add(str(wave.get("id")))
|
|
333
|
-
for _, epic, story in c.stories():
|
|
334
|
-
defined.add(str(epic.get("id")))
|
|
335
|
-
defined.add(str(story.get("id")))
|
|
336
|
-
|
|
337
|
-
refs: list[tuple[str, str]] = []
|
|
338
|
-
for cap in c.caps:
|
|
339
|
-
refs.append((str(cap.get("id")), str(cap.get("goal", ""))))
|
|
340
|
-
refs += [(str(cap.get("id")), d) for d in listy(cap, "depends_on")]
|
|
341
|
-
for fr in c.frs:
|
|
342
|
-
refs.append((str(fr.get("id")), str(fr.get("capability", ""))))
|
|
343
|
-
for nfr in c.nfrs:
|
|
344
|
-
refs.append((str(nfr.get("id")), str(nfr.get("goal", ""))))
|
|
345
|
-
for uc in c.ucs:
|
|
346
|
-
refs += [(str(uc.get("id")), f) for f in listy(uc, "satisfies")]
|
|
347
|
-
for dec in c.decs:
|
|
348
|
-
refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
|
|
349
|
-
for defect in c.defect_list:
|
|
350
|
-
refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
|
|
351
|
-
for _, _, story in c.stories():
|
|
352
|
-
refs += [(str(story.get("id")), u) for u in listy(story, "satisfies")]
|
|
353
|
-
refs += [(str(story.get("id")), d) for d in listy(story, "depends_on")]
|
|
354
|
-
|
|
355
|
-
for owner, target in sorted(set(refs)):
|
|
356
|
-
if target and target not in defined:
|
|
357
|
-
r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
361
|
-
state: dict[str, int] = {}
|
|
362
|
-
bad: list[str] = []
|
|
363
|
-
|
|
364
|
-
def walk(node: str) -> None:
|
|
365
|
-
state[node] = 1
|
|
366
|
-
for nxt in sorted(graph.get(node, [])):
|
|
367
|
-
if state.get(nxt) == 1:
|
|
368
|
-
bad.append(node)
|
|
369
|
-
elif state.get(nxt) is None and nxt in graph:
|
|
370
|
-
walk(nxt)
|
|
371
|
-
state[node] = 2
|
|
372
|
-
|
|
373
|
-
for node in sorted(graph):
|
|
374
|
-
if state.get(node) is None:
|
|
375
|
-
walk(node)
|
|
376
|
-
return sorted(set(bad))
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def v7(c: Corpus, r: Result) -> None:
|
|
380
|
-
caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
|
|
381
|
-
for node in _cycles(caps):
|
|
382
|
-
r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
|
|
383
|
-
stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
|
|
384
|
-
for node in _cycles(stories):
|
|
385
|
-
r.fail("V7", node, "is part of a `depends_on` cycle among stories")
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
def v8(c: Corpus, r: Result) -> None:
|
|
389
|
-
"""Every `applied` decision names a non-empty `touches`.
|
|
390
|
-
|
|
391
|
-
Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
|
|
392
|
-
"the filter MUST work like this" serves no FR at all, and that is VALID — it is exactly
|
|
393
|
-
decisions like that which most need remembering, and the old rule discarded them.
|
|
394
|
-
"""
|
|
395
|
-
for dec in c.decs:
|
|
396
|
-
if str(dec.get("status")) != "applied":
|
|
397
|
-
continue
|
|
398
|
-
if not [x for x in listy(dec, "touches") if str(x).strip()]:
|
|
399
|
-
r.fail("V8", str(dec.get("id")),
|
|
400
|
-
"is applied but `touches` is empty — an application with no file trace")
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def v9(c: Corpus, r: Result) -> None:
|
|
404
|
-
passed = {str(g) for g in (c.index.get("gates_passed") or [])}
|
|
405
|
-
for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
|
|
406
|
-
fm = frontmatter(path) or {}
|
|
407
|
-
if str(fm.get("status")) != "locked":
|
|
408
|
-
continue
|
|
409
|
-
gate = str(fm.get("locked_at_gate") or "")
|
|
410
|
-
if gate not in passed:
|
|
411
|
-
rel = path.relative_to(c.root).as_posix()
|
|
412
|
-
r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
def v11(c: Corpus, r: Result) -> None:
|
|
416
|
-
per_wave: dict[str, list[dict]] = {}
|
|
417
|
-
for wave, _, story in c.stories():
|
|
418
|
-
per_wave.setdefault(str(wave.get("id")), []).append(story)
|
|
419
|
-
|
|
420
|
-
for wid in sorted(per_wave):
|
|
421
|
-
items = per_wave[wid]
|
|
422
|
-
edges = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
|
|
423
|
-
|
|
424
|
-
def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
|
|
425
|
-
seen = seen or set()
|
|
426
|
-
if a in seen:
|
|
427
|
-
return False
|
|
428
|
-
seen.add(a)
|
|
429
|
-
if b in edges.get(a, set()):
|
|
430
|
-
return True
|
|
431
|
-
return any(reaches(n, b, seen) for n in sorted(edges.get(a, set())))
|
|
432
|
-
|
|
433
|
-
for i, left in enumerate(items):
|
|
434
|
-
for right in items[i + 1:]:
|
|
435
|
-
lid, rid = str(left.get("id")), str(right.get("id"))
|
|
436
|
-
shared = sorted(set(listy(left, "touches")) & set(listy(right, "touches")))
|
|
437
|
-
if not shared:
|
|
438
|
-
continue
|
|
439
|
-
if reaches(lid, rid) or reaches(rid, lid):
|
|
440
|
-
continue
|
|
441
|
-
r.fail("V11", f"{lid} + {rid}",
|
|
442
|
-
f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
def v12(c: Corpus, r: Result) -> None:
|
|
446
|
-
"""LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
|
|
447
|
-
|
|
448
|
-
The old shape demanded the answer when the information was thinnest. At wave closing,
|
|
449
|
-
every `touches` already has an area and every boundary already has a name.
|
|
450
|
-
"""
|
|
451
|
-
areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
|
|
452
|
-
lcs_per_pc: dict[str, int] = {}
|
|
453
|
-
for lc in c.lcs:
|
|
454
|
-
lcs_per_pc[str(lc.get("component"))] = lcs_per_pc.get(str(lc.get("component")), 0) + 1
|
|
455
|
-
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
456
|
-
|
|
457
|
-
seen: set[tuple[str, str]] = set()
|
|
458
|
-
for wave, _, story in c.stories():
|
|
459
|
-
if str(wave.get("status")) != "closed":
|
|
460
|
-
continue
|
|
461
|
-
for area in listy(story, "touches"):
|
|
462
|
-
if area not in areas:
|
|
463
|
-
r.fail("V12", str(story.get("id")),
|
|
464
|
-
f"its wave is already closed, but `{area}` is not registered as an `area` "
|
|
465
|
-
f"in components.yaml")
|
|
466
|
-
pid = str(story.get("component") or "")
|
|
467
|
-
row = pc_by_id.get(pid)
|
|
468
|
-
if row is None or (str(wave.get("id")), pid) in seen:
|
|
469
|
-
continue
|
|
470
|
-
seen.add((str(wave.get("id")), pid))
|
|
471
|
-
if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
|
|
472
|
-
r.fail("V12", f"{wave.get('id')} / {pid}",
|
|
473
|
-
f"wave closed and component with mode `{c.mode_of(row)}` has not one "
|
|
474
|
-
f"`LC` registered")
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
LENS_BY_RISK = {
|
|
478
|
-
"low": {"edge-case-hunter"},
|
|
479
|
-
"medium": {"edge-case-hunter"},
|
|
480
|
-
"high": set(),
|
|
481
|
-
}
|
|
482
|
-
FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
|
|
486
|
-
# str() before the truth test: an unquoted sha of all digits — `0000000`, and roughly one
|
|
487
|
-
# short sha in twenty-seven is all digits — is read by YAML as the INTEGER 0, which is falsy.
|
|
488
|
-
# The old test then reported "carries no reviewed trace" about a file that plainly carries one,
|
|
489
|
-
# which is the worst kind of finding: correct-looking, and wrong.
|
|
490
|
-
if not isinstance(block, dict):
|
|
491
|
-
r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
|
|
492
|
-
return
|
|
493
|
-
# NOT `block.get("sha") or ""` — for the integer 0 that yields "" and reintroduces the very
|
|
494
|
-
# bug this guards. `.get(key, "")` returns the 0, and str(0) is "0", which is truthy.
|
|
495
|
-
if not str(block.get("sha", "")).strip() or not str(block.get("date", "")).strip():
|
|
496
|
-
r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
|
|
497
|
-
return
|
|
498
|
-
lenses = {str(x) for x in (block.get("lenses") or [])}
|
|
499
|
-
if not lenses:
|
|
500
|
-
r.fail("V13", rel, "the `reviewed` trace names not one lens")
|
|
501
|
-
missing = sorted(need - lenses)
|
|
502
|
-
if missing:
|
|
503
|
-
r.fail("V13", rel,
|
|
504
|
-
f"lenses {missing} MUST be included — that is what the component's `risk_accepted` demands")
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
def _only_reviewed_block(diff: str) -> bool:
|
|
508
|
-
"""True if a commit's diff on one file ONLY touches the `reviewed:` block.
|
|
509
|
-
|
|
510
|
-
This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
|
|
511
|
-
the file — but the commit that WRITES the `reviewed:` block always changes the file, and
|
|
512
|
-
writing its own hash into a git commit is cryptographically impossible. As a result every
|
|
513
|
-
artifact that had just been stamped immediately read as "stale review", forever.
|
|
514
|
-
"""
|
|
515
|
-
touched = [ln for ln in diff.splitlines()
|
|
516
|
-
if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
|
|
517
|
-
if not touched:
|
|
518
|
-
return True
|
|
519
|
-
for ln in touched:
|
|
520
|
-
body = ln[1:].strip()
|
|
521
|
-
if not body or body.startswith("#"):
|
|
522
|
-
continue
|
|
523
|
-
if not body.startswith(FRONTMATTER_KEYS):
|
|
524
|
-
return False
|
|
525
|
-
return True
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
|
|
529
|
-
"""First commit after `sha` that changes this file for a reason other than a review stamp."""
|
|
530
|
-
log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
|
|
531
|
-
if not log:
|
|
532
|
-
return None
|
|
533
|
-
for head in log.splitlines():
|
|
534
|
-
head = head.strip()
|
|
535
|
-
if not head:
|
|
536
|
-
continue
|
|
537
|
-
diff = git(c.root, "show", "--format=", "--unified=0", head, "--", rel)
|
|
538
|
-
if diff is None:
|
|
539
|
-
return head
|
|
540
|
-
if _only_reviewed_block(diff):
|
|
541
|
-
continue
|
|
542
|
-
return head
|
|
543
|
-
return None
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
def v13(c: Corpus, r: Result) -> None:
|
|
547
|
-
"""Review trace follows review INTENSITY, not document depth.
|
|
548
|
-
|
|
549
|
-
Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
|
|
550
|
-
stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
|
|
551
|
-
"""
|
|
552
|
-
watched = [pc for pc in c.pcs
|
|
553
|
-
if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
|
|
554
|
-
if not watched:
|
|
555
|
-
r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
|
|
556
|
-
targets: list[tuple[Path, set[str]]] = []
|
|
557
|
-
if watched:
|
|
558
|
-
targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
|
|
559
|
-
for pc in watched:
|
|
560
|
-
pid = str(pc.get("id"))
|
|
561
|
-
need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
|
|
562
|
-
# The SRS exists and is meaningful at EVERY mode: it carries the Actor Register and UC
|
|
563
|
-
# Catalogue, and both are born at G3, which the depth knob does not touch.
|
|
564
|
-
targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
|
|
565
|
-
# The SDD is guarded only when it HAS content worth guarding. Two states exempt it, and
|
|
566
|
-
# both are FINISHED states, not neglected ones:
|
|
567
|
-
# mode: catalog the skeleton is its final form; G4 is skipped there
|
|
568
|
-
# g4_passed not set G4 has not run yet, so not one section is written
|
|
569
|
-
# Demanding a review trace on a file whose content is 13 lines of template comments is
|
|
570
|
-
# theater — exactly the ceremony this redesign cut, and a review that cannot fail proves
|
|
571
|
-
# nothing. Once G4 passes, the demand comes back and it is meaningful.
|
|
572
|
-
passed = str(pc.get("g4_passed") or "").strip().lower()
|
|
573
|
-
if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
|
|
574
|
-
targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
|
|
575
|
-
|
|
576
|
-
for path, need in targets:
|
|
577
|
-
fm = frontmatter(path)
|
|
578
|
-
if fm is None:
|
|
579
|
-
continue # not born yet — not V13's business
|
|
580
|
-
rel = path.relative_to(c.root).as_posix()
|
|
581
|
-
_reviewed_ok(r, rel, fm.get("reviewed"), need)
|
|
582
|
-
block = fm.get("reviewed")
|
|
583
|
-
if isinstance(block, dict) and block.get("sha"):
|
|
584
|
-
stale = _stale_since(c, rel, str(block["sha"]))
|
|
585
|
-
if stale:
|
|
586
|
-
r.fail("V13", rel,
|
|
587
|
-
f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]} — "
|
|
588
|
-
f"stale review")
|
|
589
|
-
|
|
590
|
-
for wave in c.wave_list:
|
|
591
|
-
if not wave.get("epics"):
|
|
592
|
-
continue
|
|
593
|
-
_reviewed_ok(r, f"waves.yaml:{wave.get('id')}", wave.get("spec_reviewed"),
|
|
594
|
-
{"edge-case-hunter"})
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
def cap_stories(c: Corpus) -> dict[str, list[dict]]:
|
|
598
|
-
"""CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
|
|
599
|
-
frs_of: dict[str, list[str]] = {}
|
|
600
|
-
for fr in c.frs:
|
|
601
|
-
frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
|
|
602
|
-
ucs_of: dict[str, list[str]] = {}
|
|
603
|
-
for uc in c.ucs:
|
|
604
|
-
for fid in listy(uc, "satisfies"):
|
|
605
|
-
ucs_of.setdefault(fid, []).append(str(uc.get("id")))
|
|
606
|
-
out: dict[str, list[dict]] = {}
|
|
607
|
-
for cap in c.caps:
|
|
608
|
-
cid = str(cap.get("id"))
|
|
609
|
-
wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
|
|
610
|
-
out[cid] = [s for _, _, s in c.stories()
|
|
611
|
-
if wanted & set(listy(s, "satisfies"))]
|
|
612
|
-
return out
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
616
|
-
"""Overdue-ness is computed from the registry itself — the timeline only reinforces, never gates."""
|
|
617
|
-
by_cap = cap_stories(c)
|
|
618
|
-
timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
|
|
619
|
-
listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
|
|
620
|
-
if str(row.get("state")) == "overdue"} if timeline else None
|
|
621
|
-
if listed is None:
|
|
622
|
-
r.skip("V14", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
|
|
623
|
-
"from the registry, but its presence in generated/report is not checked")
|
|
624
|
-
|
|
625
|
-
for cap in c.caps:
|
|
626
|
-
cid = str(cap.get("id"))
|
|
627
|
-
end = str(cap.get("planned_end") or "")
|
|
628
|
-
if not end:
|
|
629
|
-
continue
|
|
630
|
-
try:
|
|
631
|
-
due = dt.date.fromisoformat(end)
|
|
632
|
-
except ValueError:
|
|
633
|
-
r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
|
|
634
|
-
continue
|
|
635
|
-
items = by_cap.get(cid, [])
|
|
636
|
-
closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
|
|
637
|
-
if closed or due >= asof:
|
|
638
|
-
continue
|
|
639
|
-
late = (asof - due).days
|
|
640
|
-
if listed is not None and cid not in listed:
|
|
641
|
-
r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
|
|
642
|
-
f"`overdue` in generated/timeline")
|
|
643
|
-
else:
|
|
644
|
-
r.fail("V14", cid, f"{late} days overdue with nothing closed")
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
def v15(c: Corpus, r: Result) -> None:
|
|
648
|
-
for cap in c.caps:
|
|
649
|
-
if not str(cap.get("goal") or "").strip():
|
|
650
|
-
r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
|
|
651
|
-
for fr in c.frs:
|
|
652
|
-
if not str(fr.get("capability") or "").strip():
|
|
653
|
-
r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
def v16(c: Corpus, r: Result) -> None:
|
|
657
|
-
for path in sorted((c.root / ".control/memlog").glob("*.md")):
|
|
658
|
-
fm = frontmatter(path) or {}
|
|
659
|
-
rel = path.relative_to(c.root).as_posix()
|
|
660
|
-
artifact = str(fm.get("artifact") or "")
|
|
661
|
-
if not artifact:
|
|
662
|
-
r.fail("V16", rel, "has no `artifact:` in frontmatter")
|
|
663
|
-
elif not (c.root / artifact).exists():
|
|
664
|
-
r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
|
|
665
|
-
for layer in (".what", ".how"):
|
|
666
|
-
for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
|
|
667
|
-
r.fail("V16", stray.relative_to(c.root).as_posix(),
|
|
668
|
-
"a memlog MUST NOT live inside the corpus")
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def v17(c: Corpus, r: Result) -> None:
|
|
672
|
-
for wave in c.wave_list:
|
|
673
|
-
wid = str(wave.get("id"))
|
|
674
|
-
if not str(wave.get("release") or "").strip():
|
|
675
|
-
r.fail("V17", wid, "does not name a `release`")
|
|
676
|
-
slugs = listy(wave, "prd")
|
|
677
|
-
if not slugs:
|
|
678
|
-
r.fail("V17", wid, "does not name a `prd`")
|
|
679
|
-
for slug in slugs:
|
|
680
|
-
if not (c.root / ".what/_prd" / slug).is_dir():
|
|
681
|
-
r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
def v18(c: Corpus, r: Result) -> None:
|
|
685
|
-
for _, _, story in c.stories():
|
|
686
|
-
sid = str(story.get("id"))
|
|
687
|
-
folder = str(story.get("spec_folder") or "").strip()
|
|
688
|
-
if not folder:
|
|
689
|
-
r.fail("V18", sid, "does not name a `spec_folder`")
|
|
690
|
-
continue
|
|
691
|
-
matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
|
|
692
|
-
if not matches:
|
|
693
|
-
r.fail("V18", sid, f"has no story file in {folder}stories/")
|
|
694
|
-
continue
|
|
695
|
-
fm = frontmatter(matches[0]) or {}
|
|
696
|
-
if not str(fm.get("status") or "").strip():
|
|
697
|
-
r.fail("V18", sid, "story file has no `status` in frontmatter")
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def v19(c: Corpus, r: Result) -> None:
|
|
701
|
-
"""The retrospective archive is tied to WAVE SIZE, not to `mode`.
|
|
702
|
-
|
|
703
|
-
Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
|
|
704
|
-
different things, and demanding a retrospective for a three-story wave is ceremony.
|
|
705
|
-
"""
|
|
706
|
-
names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
|
|
707
|
-
advisory: list[str] = []
|
|
708
|
-
for wave in c.wave_list:
|
|
709
|
-
if str(wave.get("status")) != "closed":
|
|
710
|
-
continue
|
|
711
|
-
wid = str(wave.get("id"))
|
|
712
|
-
if any(wid in name for name in names):
|
|
713
|
-
continue
|
|
714
|
-
if str(wave.get("size")).upper() == "L":
|
|
715
|
-
r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
|
|
716
|
-
else:
|
|
717
|
-
advisory.append(wid)
|
|
718
|
-
if advisory:
|
|
719
|
-
r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
|
|
720
|
-
else:
|
|
721
|
-
r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
PLATFORM = "_platform"
|
|
725
|
-
CROSS_CUTTING = ".how/_platform/cross-cutting.md"
|
|
726
|
-
# The section heading V21 looks for. A heading a SCRIPT matches is a machine-facing key, and
|
|
727
|
-
# `language-guide.md` says a key is always English — so the template writes the English one and
|
|
728
|
-
# this is what a new corpus carries. The Indonesian form is kept as a READER-side alias, exactly
|
|
729
|
-
# like `yes|ya`: a corpus written before this MUST NOT be migrated for a regex.
|
|
730
|
-
PLATFORM_DATA_HEADINGS = ("Platform-owned", "Milik platform")
|
|
731
|
-
PLATFORM_DATA_HEADING = PLATFORM_DATA_HEADINGS[0]
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
def v21(c: Corpus, r: Result) -> None:
|
|
735
|
-
"""One domain entity has EXACTLY ONE owner authorized to write it.
|
|
736
|
-
|
|
737
|
-
The owner is a Product Component, OR `_platform` for an entity with no single component
|
|
738
|
-
promise behind it. Semantic collisions across PRDs have already happened for real: one
|
|
739
|
-
component took a business-rule numbering range from a shared global sequence. Two `FR`s
|
|
740
|
-
that both claim write authority over the same entity, with neither pointing at the other,
|
|
741
|
-
are a defect the moment they are written.
|
|
742
|
-
|
|
743
|
-
`_platform` is NOT a Product Component and therefore has no `mode`, `risk_accepted`, SRS,
|
|
744
|
-
or G4. It is a home for ownership, not a domain slice — and so it does not become a dumping
|
|
745
|
-
ground, every entity it claims MUST be explained in `cross-cutting.md`: if the platform
|
|
746
|
-
owns the data, the platform documents it.
|
|
747
|
-
"""
|
|
748
|
-
owner: dict[str, str] = {}
|
|
749
|
-
for pc in c.pcs:
|
|
750
|
-
pid = str(pc.get("id"))
|
|
751
|
-
for entity in listy(pc, "owns"):
|
|
752
|
-
if entity in owner and owner[entity] != pid:
|
|
753
|
-
r.fail("V21", entity,
|
|
754
|
-
f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
|
|
755
|
-
f"have exactly one owner")
|
|
756
|
-
else:
|
|
757
|
-
owner.setdefault(entity, pid)
|
|
758
|
-
|
|
759
|
-
platform = listy(c.components, "platform_owns")
|
|
760
|
-
for entity in platform:
|
|
761
|
-
if entity in owner:
|
|
762
|
-
r.fail("V21", entity,
|
|
763
|
-
f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
|
|
764
|
-
f"`{PLATFORM}` is not a second path for an entity that already has an owner")
|
|
765
|
-
else:
|
|
766
|
-
owner[entity] = PLATFORM
|
|
767
|
-
|
|
768
|
-
_platform_documented(c, r, platform + _platform_inventory_rows(c))
|
|
769
|
-
|
|
770
|
-
cap_home = {str(x.get("id")): str(x.get("component") or "") for x in c.caps}
|
|
771
|
-
for fr in c.frs:
|
|
772
|
-
fid = str(fr.get("id"))
|
|
773
|
-
home = str(fr.get("component") or cap_home.get(str(fr.get("capability", "")), ""))
|
|
774
|
-
for entity in listy(fr, "writes"):
|
|
775
|
-
own = owner.get(entity)
|
|
776
|
-
if not own or not home or own == home:
|
|
777
|
-
continue
|
|
778
|
-
if own == PLATFORM:
|
|
779
|
-
# The platform has no `FR`, so there is nothing a `defers_to` could point to. What
|
|
780
|
-
# stands in for "one writer" here is ONE DOCUMENTED FORM, and that is what
|
|
781
|
-
# _platform_documented checks above.
|
|
782
|
-
continue
|
|
783
|
-
if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
|
|
784
|
-
r.fail("V21", fid,
|
|
785
|
-
f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
|
|
786
|
-
f"pointing to an `FR` owned by that owner")
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
def _platform_inventory_rows(c: Corpus) -> list[str]:
|
|
790
|
-
"""Inventory rows owned by `_platform`, read from `platform_rows:` in each inventory.
|
|
791
|
-
|
|
792
|
-
`_platform` is a valid value at EVERY ownership position, so the guard applies at every
|
|
793
|
-
position too: whatever it owns MUST be documented in `cross-cutting.md`.
|
|
794
|
-
"""
|
|
795
|
-
out: list[str] = []
|
|
796
|
-
for kind in ("db", "api", "screen"):
|
|
797
|
-
path = c.root / f".how/_platform/inventory-{kind}.md"
|
|
798
|
-
fm = frontmatter(path)
|
|
799
|
-
if not fm:
|
|
800
|
-
continue
|
|
801
|
-
out += [str(x) for x in (fm.get("platform_rows") or [])]
|
|
802
|
-
return out
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
|
|
806
|
-
"""Every entity with `platform_owns` MUST be named in `cross-cutting.md`.
|
|
807
|
-
|
|
808
|
-
Skipped while the file does not yet carry that section: `cross-cutting.md` is a G3 output, and
|
|
809
|
-
an artifact the next gate will produce MUST NOT be reported missing.
|
|
810
|
-
"""
|
|
811
|
-
if not entities:
|
|
812
|
-
return
|
|
813
|
-
path = c.root / CROSS_CUTTING
|
|
814
|
-
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
|
815
|
-
if not any(h.lower() in text.lower() for h in PLATFORM_DATA_HEADINGS):
|
|
816
|
-
r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
|
|
817
|
-
f"{len(entities)} entities with platform_owns are not documented yet: "
|
|
818
|
-
+ ", ".join(sorted(entities)))
|
|
819
|
-
return
|
|
820
|
-
for entity in sorted(entities):
|
|
821
|
-
if entity not in text:
|
|
822
|
-
r.fail("V21", entity,
|
|
823
|
-
f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
|
|
824
|
-
f"a platform that owns data MUST document it")
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
def v22(c: Corpus, r: Result) -> None:
|
|
828
|
-
"""A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
|
|
829
|
-
|
|
830
|
-
`catalog` skips G4 on purpose, so it is not an exception — it is part of the rule.
|
|
831
|
-
"""
|
|
832
|
-
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
833
|
-
seen: set[tuple[str, str]] = set()
|
|
834
|
-
for wave, _, story in c.stories():
|
|
835
|
-
pid = str(story.get("component") or "")
|
|
836
|
-
row = pc_by_id.get(pid)
|
|
837
|
-
if row is None:
|
|
838
|
-
continue
|
|
839
|
-
key = (str(wave.get("id")), pid)
|
|
840
|
-
if key in seen:
|
|
841
|
-
continue
|
|
842
|
-
seen.add(key)
|
|
843
|
-
mode = c.mode_of(row)
|
|
844
|
-
if mode == "catalog":
|
|
845
|
-
continue
|
|
846
|
-
if mode not in MODES:
|
|
847
|
-
r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
|
|
848
|
-
continue
|
|
849
|
-
passed = row.get("g4_passed")
|
|
850
|
-
if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
|
|
851
|
-
r.fail("V22", f"{wave.get('id')} / {pid}",
|
|
852
|
-
f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
def v23(c: Corpus, r: Result) -> None:
|
|
856
|
-
"""`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
|
|
857
|
-
|
|
858
|
-
On a component that touches nothing on that list, `high` is FREE. The control is
|
|
859
|
-
disclosure, not veto — the owner may still choose quickly, just not without knowing what
|
|
860
|
-
they are wagering.
|
|
861
|
-
"""
|
|
862
|
-
known = {str(x.get("id")) for x in c.decs}
|
|
863
|
-
for pc in c.pcs:
|
|
864
|
-
pid = str(pc.get("id"))
|
|
865
|
-
if str(pc.get("risk_accepted") or "").strip() != "high":
|
|
866
|
-
continue
|
|
867
|
-
note = str(pc.get("risk_note") or "").lower()
|
|
868
|
-
hits = sorted({m for m in SENSITIVE_MARKERS if m in note})
|
|
869
|
-
if not hits:
|
|
870
|
-
continue
|
|
871
|
-
ref = str(pc.get("risk_accepted_by") or "").strip()
|
|
872
|
-
if not ref:
|
|
873
|
-
r.fail("V23", pid,
|
|
874
|
-
f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
|
|
875
|
-
f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
|
|
876
|
-
elif ref not in known:
|
|
877
|
-
r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
def v20(c: Corpus, r: Result) -> None:
|
|
881
|
-
needs_link = {"requirement", "architecture"}
|
|
882
|
-
for defect in c.defect_list:
|
|
883
|
-
did = str(defect.get("id"))
|
|
884
|
-
cause = str(defect.get("root_cause") or "")
|
|
885
|
-
if cause not in needs_link:
|
|
886
|
-
continue
|
|
887
|
-
if not listy(defect, "violates"):
|
|
888
|
-
r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
|
|
889
|
-
if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
|
|
890
|
-
r.fail("V20", did,
|
|
891
|
-
f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
# Files that DESCRIBE the past, not STATE what currently holds. A dangling citation here is
|
|
895
|
-
# not a finding — corpus-guide.md owns that rule, and rewriting it would falsify history.
|
|
896
|
-
PAST_RECORD = (
|
|
897
|
-
".control/memlog/",
|
|
898
|
-
".control/decisions/",
|
|
899
|
-
".control/questions/answered.md",
|
|
900
|
-
".control/reports/",
|
|
901
|
-
)
|
|
902
|
-
# Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
|
|
903
|
-
FROZEN = (".what/",)
|
|
904
|
-
# Derived output. A finding here is UNACTIONABLE by construction — the folder MUST NOT be written
|
|
905
|
-
# by hand, so nobody may fix it where it is reported. It also renders registry values inside
|
|
906
|
-
# backticks, which makes a frozen `DEC-` `touches:` entry look like a live citation: the 0.5.0
|
|
907
|
-
# layout move surfaced three of those, all of them correct history. Fix the source or leave it.
|
|
908
|
-
DERIVED = (".control/generated/",)
|
|
909
|
-
# A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
|
|
910
|
-
# memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
|
|
911
|
-
# happened.
|
|
912
|
-
DESTINATION = (
|
|
913
|
-
".control/memlog/",
|
|
914
|
-
".control/meetings/",
|
|
915
|
-
".control/reports/",
|
|
916
|
-
"_bmad-output/",
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
# Material the INSTALLER wrote, which this product neither authored nor may edit.
|
|
920
|
-
#
|
|
921
|
-
# `.constitution/method/` is portable explanation. Its citations teach where a thing GOES — "the
|
|
922
|
-
# glossary lives at `.control/product-glossary.md`" — and are not this product's claim that it has
|
|
923
|
-
# one yet. Scanning it made V24 unsatisfiable in both directions: a fresh install went RED on 69
|
|
924
|
-
# such lines before G1 had run, and a mature one stayed quiet only by accident. A method guide that
|
|
925
|
-
# cites a method file IS checked, but here in the package where it can be fixed — see
|
|
926
|
-
# tests/kit-integrity.test.mjs. A product cannot fix a guide `update` overwrites.
|
|
927
|
-
#
|
|
928
|
-
# The BMad skill trees are the same class under whichever host the installer wrote them to. Both
|
|
929
|
-
# hosts MUST be listed: `.claude/skills/bmad-` alone left the `.agents/` copy of one identical
|
|
930
|
-
# template failing, which reads as a defect in that product rather than an omission here.
|
|
931
|
-
#
|
|
932
|
-
# `wdi-*` skills are OURS and are deliberately NOT here. They MUST NOT cite a product file that
|
|
933
|
-
# does not exist unless the cite is a placeholder.
|
|
934
|
-
INSTALLED = (
|
|
935
|
-
".constitution/method/",
|
|
936
|
-
".claude/skills/bmad-",
|
|
937
|
-
".agents/skills/bmad-",
|
|
938
|
-
)
|
|
939
|
-
|
|
940
|
-
# The extension list is deliberately WIDE. A narrow one does not make V24 safer — it makes it
|
|
941
|
-
# silent: a product written in a language missing from the list has its code citations
|
|
942
|
-
# unchecked, and nothing says so. Adding one is cheap; a gap is invisible.
|
|
943
|
-
CITE_RE = re.compile(
|
|
944
|
-
r"`((?:\.constitution|\.control|\.what|\.how|_bmad-output|\.work|src|web|public|deploy)"
|
|
945
|
-
r"/[A-Za-z0-9_./-]+\.(?:md|txt|yaml|yml|toml|json|sql|html|css|scss|"
|
|
946
|
-
r"py|go|rs|rb|php|java|kt|cs|swift|ts|tsx|js|jsx|mjs|cjs|vue|svelte|ex|exs))`")
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
# Directories that MUST be pruned DURING traversal, not filtered afterwards.
|
|
950
|
-
#
|
|
951
|
-
# The old form was `c.root.rglob("*.md")` plus a `rel.startswith(...)` filter, and it had two faults
|
|
952
|
-
# that only showed up on a real machine:
|
|
953
|
-
#
|
|
954
|
-
# The filter ran too late. rglob had already walked in, so a dangling symlink inside
|
|
955
|
-
# node_modules — an npm workspace link left behind by an abandoned git worktree — raised
|
|
956
|
-
# FileNotFoundError and took the whole run down. A validator that CRASHES on somebody's build
|
|
957
|
-
# output reports nothing about the corpus at all.
|
|
958
|
-
#
|
|
959
|
-
# `node_modules/` matched only at the ROOT. `web/node_modules/` sailed straight through, which is
|
|
960
|
-
# where a monorepo actually keeps it.
|
|
961
|
-
PRUNE_DIRS = frozenset({
|
|
962
|
-
".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
963
|
-
".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".turbo", ".idea", ".vscode",
|
|
964
|
-
"worktrees", # .claude/worktrees/ — another checkout's tree is not this corpus
|
|
965
|
-
})
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
969
|
-
"""Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS as it goes.
|
|
970
|
-
|
|
971
|
-
Sorted because determinism is this script's contract: two runs over the same tree MUST report the
|
|
972
|
-
same thing in the same order.
|
|
973
|
-
"""
|
|
974
|
-
out: list[Path] = []
|
|
975
|
-
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
|
|
976
|
-
dirnames[:] = sorted(d for d in dirnames if d not in PRUNE_DIRS)
|
|
977
|
-
for name in filenames:
|
|
978
|
-
if name.endswith(suffixes):
|
|
979
|
-
out.append(Path(dirpath) / name)
|
|
980
|
-
return sorted(out)
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
def v24(c: Corpus, r: Result) -> None:
|
|
984
|
-
"""A path citation inside a document that STATES what currently holds MUST resolve.
|
|
985
|
-
|
|
986
|
-
This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
|
|
987
|
-
that a migration stayed complete. Its failure class is distinctive: a file gets deleted or moved,
|
|
988
|
-
while the routing line that points at it stays behind — no other validator sees it, because no
|
|
989
|
-
id moved.
|
|
990
|
-
|
|
991
|
-
Deliberately SKIPPED: files that describe the past, corpus that has been frozen, derived
|
|
992
|
-
output, and material the installer wrote (see INSTALLED). A `DEC-` Trace that names material that has since been retired describes what was read on
|
|
993
|
-
that date; reporting it would demand history be rewritten to match the present. Derived output is
|
|
994
|
-
skipped for a second reason on top of that: it MUST NOT be edited by hand, so a finding reported
|
|
995
|
-
there names a file nobody is allowed to fix.
|
|
996
|
-
"""
|
|
997
|
-
scanned = 0
|
|
998
|
-
for path in _walk_corpus(c.root, (".md", ".yaml")):
|
|
999
|
-
rel = path.relative_to(c.root).as_posix()
|
|
1000
|
-
if rel.startswith("_bmad-output/") or rel.startswith(INSTALLED):
|
|
1001
|
-
continue
|
|
1002
|
-
if rel.startswith(PAST_RECORD) or rel.startswith(FROZEN) or rel.startswith(DERIVED):
|
|
1003
|
-
continue
|
|
1004
|
-
scanned += 1
|
|
1005
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1006
|
-
for cited in sorted(set(CITE_RE.findall(text))):
|
|
1007
|
-
if "<" in cited or "{" in cited:
|
|
1008
|
-
continue # placeholder, not a path
|
|
1009
|
-
if cited.startswith(DESTINATION):
|
|
1010
|
-
continue
|
|
1011
|
-
if not (c.root / cited).exists():
|
|
1012
|
-
r.fail("V24", rel, f"cites `{cited}` which does not exist")
|
|
1013
|
-
if not scanned:
|
|
1014
|
-
r.skip("V24", "no file was scanned")
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
def map_container_headings(root: Path) -> list[str] | None:
|
|
1021
|
-
"""Heading `### x` under `## Containers` in the code map. None if the map does not exist."""
|
|
1022
|
-
path = root / ".control" / "structure-codebase.md"
|
|
1023
|
-
if not path.exists():
|
|
1024
|
-
return None
|
|
1025
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1026
|
-
start = text.find("\n## Containers")
|
|
1027
|
-
if start < 0:
|
|
1028
|
-
return []
|
|
1029
|
-
rest = text[start + 1:]
|
|
1030
|
-
nxt = re.search(r"^##\s+(?!#)", rest[len("## Containers"):], re.M)
|
|
1031
|
-
if nxt:
|
|
1032
|
-
rest = rest[:len("## Containers") + nxt.start()]
|
|
1033
|
-
return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
def v25(c: Corpus, r: Result) -> None:
|
|
1037
|
-
"""A container's `built` and its four consequences, plus the PC x container matrix.
|
|
1038
|
-
|
|
1039
|
-
A container EXISTS inside the boundary whether or not we write its content, and that is what
|
|
1040
|
-
used to make the rule unsatisfiable: `structure-guide.md` demands every code-map heading match
|
|
1041
|
-
the registry, while a database or web server MUST be registered and MUST NOT have a heading.
|
|
1042
|
-
`built` separates the two, and this check is what makes that separation hold instead of the
|
|
1043
|
-
argument being repeated on every project. `DEC-017` records its definition.
|
|
1044
|
-
|
|
1045
|
-
Anything whose runtime we do not deploy is an external system: it lives in C4 L1 and MUST NOT
|
|
1046
|
-
be registered here at all — its absence from the registry is the check.
|
|
1047
|
-
"""
|
|
1048
|
-
containers = rows(c.components, "containers")
|
|
1049
|
-
if not containers:
|
|
1050
|
-
r.skip("V25", "`containers:` is not registered yet")
|
|
1051
|
-
return
|
|
1052
|
-
|
|
1053
|
-
built: dict[str, bool] = {}
|
|
1054
|
-
for ctr in containers:
|
|
1055
|
-
cid = str(ctr.get("id") or "").strip()
|
|
1056
|
-
if not cid:
|
|
1057
|
-
r.fail("V25", "containers", "a container has no `id`")
|
|
1058
|
-
continue
|
|
1059
|
-
flag = ctr.get("built")
|
|
1060
|
-
if not isinstance(flag, bool):
|
|
1061
|
-
r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
|
|
1062
|
-
continue
|
|
1063
|
-
built[cid] = flag
|
|
1064
|
-
|
|
1065
|
-
# (1) code-map heading = EXACTLY a container with `built: true`
|
|
1066
|
-
headings = map_container_headings(c.root)
|
|
1067
|
-
if headings is None:
|
|
1068
|
-
r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
|
|
1069
|
-
else:
|
|
1070
|
-
for h in headings:
|
|
1071
|
-
if h not in built:
|
|
1072
|
-
r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
|
|
1073
|
-
elif not built[h]:
|
|
1074
|
-
r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
|
|
1075
|
-
for cid, flag in sorted(built.items()):
|
|
1076
|
-
if flag and cid not in headings:
|
|
1077
|
-
r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
|
|
1078
|
-
|
|
1079
|
-
# (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
|
|
1080
|
-
for lc in c.lcs:
|
|
1081
|
-
ctr = str(lc.get("container") or "").strip()
|
|
1082
|
-
if ctr and built.get(ctr) is False:
|
|
1083
|
-
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
|
|
1084
|
-
elif ctr and ctr not in built:
|
|
1085
|
-
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
|
|
1086
|
-
|
|
1087
|
-
# (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
|
|
1088
|
-
for pc in c.pcs:
|
|
1089
|
-
pid = str(pc.get("id") or "?")
|
|
1090
|
-
listed = listy(pc, "containers")
|
|
1091
|
-
if not listed:
|
|
1092
|
-
r.fail("V25", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
|
|
1093
|
-
continue
|
|
1094
|
-
for ctr in listed:
|
|
1095
|
-
if ctr not in built:
|
|
1096
|
-
r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
|
|
1097
|
-
elif not built[ctr]:
|
|
1098
|
-
r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
|
|
1099
|
-
|
|
1100
|
-
# (5) L3 — only for `built: true`, and only ones that hold more than one PC
|
|
1101
|
-
pcs_per: dict[str, list[str]] = {}
|
|
1102
|
-
for pc in c.pcs:
|
|
1103
|
-
for ctr in listy(pc, "containers"):
|
|
1104
|
-
pcs_per.setdefault(ctr, []).append(str(pc.get("id") or "?"))
|
|
1105
|
-
for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
|
|
1106
|
-
cid = path.name[len("c4-l3-"):-len(".md")]
|
|
1107
|
-
if cid not in built:
|
|
1108
|
-
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1109
|
-
f"L3 for `{cid}` which is not a registered container")
|
|
1110
|
-
elif not built[cid]:
|
|
1111
|
-
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1112
|
-
f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
|
|
1113
|
-
for cid, pids in sorted(pcs_per.items()):
|
|
1114
|
-
if built.get(cid) and len(pids) > 1:
|
|
1115
|
-
l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
|
|
1116
|
-
if not l3.exists():
|
|
1117
|
-
r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
|
|
1121
|
-
|
|
1122
|
-
# The `critical` column value is machine-matched, so it is machine-facing and its canonical form
|
|
1123
|
-
# is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
|
|
1124
|
-
# MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
|
|
1125
|
-
# matching inside other words.
|
|
1126
|
-
CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
def v26(c: Corpus, r: Result) -> None:
|
|
1130
|
-
"""The UC catalogue in every SRS MUST agree with `usecases.yaml` — both its id AND its `critical`.
|
|
1131
|
-
|
|
1132
|
-
This is the most expensive gap this pass closes, because it is the only one that **had already
|
|
1133
|
-
happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
|
|
1134
|
-
narrowed definition — money, personal data, irreversible action — and the seven catalogue tables
|
|
1135
|
-
in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
|
|
1136
|
-
discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
|
|
1137
|
-
registry held three.
|
|
1138
|
-
|
|
1139
|
-
The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
|
|
1140
|
-
and two homes for one fact are only safe if something compares them. This is what compares them.
|
|
1141
|
-
|
|
1142
|
-
What is NOT checked here: title and actor. Both are prose, and prose with different words is
|
|
1143
|
-
not prose with a different meaning — comparing them would report style as a defect.
|
|
1144
|
-
"""
|
|
1145
|
-
reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
|
|
1146
|
-
reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
|
|
1147
|
-
checked = 0
|
|
1148
|
-
for pc in c.pcs:
|
|
1149
|
-
pid = str(pc.get("id"))
|
|
1150
|
-
path = c.root / f".what/{pid}/SRS-{pid}.md"
|
|
1151
|
-
if not path.exists():
|
|
1152
|
-
continue
|
|
1153
|
-
checked += 1
|
|
1154
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1155
|
-
seen: set[str] = set()
|
|
1156
|
-
for match in UC_ROW_RE.finditer(text):
|
|
1157
|
-
uid = match.group(1)
|
|
1158
|
-
cells = [x.strip() for x in match.group(2).split("|")]
|
|
1159
|
-
if len(cells) < 4:
|
|
1160
|
-
continue
|
|
1161
|
-
seen.add(uid)
|
|
1162
|
-
if uid not in reg:
|
|
1163
|
-
r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
|
|
1164
|
-
continue
|
|
1165
|
-
if reg_pc[uid] != pid:
|
|
1166
|
-
r.fail("V26", f"{pid}/{uid}",
|
|
1167
|
-
f"the registry places it in `{reg_pc[uid]}`, not in this component")
|
|
1168
|
-
marked = CRITICAL_YES.search(cells[3]) is not None
|
|
1169
|
-
if marked != reg[uid]:
|
|
1170
|
-
r.fail("V26", f"{pid}/{uid}",
|
|
1171
|
-
f"`critical` in the SRS {'yes' if marked else 'no'}, "
|
|
1172
|
-
f"in the registry {'yes' if reg[uid] else 'no'}")
|
|
1173
|
-
for uid, owner in sorted(reg_pc.items()):
|
|
1174
|
-
if owner == pid and uid not in seen:
|
|
1175
|
-
r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
|
|
1176
|
-
if not checked:
|
|
1177
|
-
r.skip("V26", "no SRS could be read")
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
def v27(c: Corpus, r: Result) -> None:
|
|
1181
|
-
"""Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
|
|
1182
|
-
|
|
1183
|
-
The `.constitution/project/` room exists so product-specific rules have a home that `update`
|
|
1184
|
-
does not overwrite and `promote` does not publish. The cost that comes with it: it is also the
|
|
1185
|
-
easiest place to break a generic rule without a trace. Its frontmatter is what holds that back.
|
|
1186
|
-
|
|
1187
|
-
A file here MAY narrow or add without naming anything. To REBUT a generic rule it MUST name it
|
|
1188
|
-
in `overrides:` and carry a `decision:` — because a method that can be rebutted without a
|
|
1189
|
-
decision stops being trustworthy in the next repo.
|
|
1190
|
-
|
|
1191
|
-
Four files in the room are STRUCTURAL and are skipped, because they are not ad-hoc rules and
|
|
1192
|
-
carry their own frontmatter conventions instead:
|
|
1193
|
-
|
|
1194
|
-
README.md authored in the package, not in the product
|
|
1195
|
-
constitution.md Articles 1, 2, 5 — carries `status:`, and Article 4 governs it
|
|
1196
|
-
codebase-*-guide.md the stack, conventions, and brownfield guides — `status:` plus
|
|
1197
|
-
`ratified_by:`, and they are filled by a wave's distillation
|
|
1198
|
-
|
|
1199
|
-
Demanding `scope:` and `purpose:` of those would be demanding a declaration of files whose
|
|
1200
|
-
role is already fixed by the layout. What V27 exists to guard is the file somebody ADDS.
|
|
1201
|
-
|
|
1202
|
-
Only `.md` is looked at. A script in the room — `inventory-readers.py` is the one the package
|
|
1203
|
-
seeds — is not an ad-hoc rule and has nowhere to put frontmatter.
|
|
1204
|
-
"""
|
|
1205
|
-
room = c.root / ".constitution" / "project"
|
|
1206
|
-
if not room.is_dir():
|
|
1207
|
-
r.skip("V27", "the `.constitution/project/` room does not exist yet — it is seeded at install")
|
|
1208
|
-
return
|
|
1209
|
-
structural = {"README.md", "constitution.md"}
|
|
1210
|
-
files = [p for p in sorted(room.rglob("*.md"))
|
|
1211
|
-
if p.name not in structural and not p.name.startswith("codebase-")]
|
|
1212
|
-
if not files:
|
|
1213
|
-
r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
|
|
1214
|
-
"a generic rule MUST NOT be moved here just to give the room content")
|
|
1215
|
-
return
|
|
1216
|
-
dec_ids = {str(d.get("id")) for d in c.decs}
|
|
1217
|
-
for path in files:
|
|
1218
|
-
rel = path.relative_to(c.root).as_posix()
|
|
1219
|
-
fm = frontmatter(path)
|
|
1220
|
-
if fm is None:
|
|
1221
|
-
r.fail("V27", rel, "has no frontmatter")
|
|
1222
|
-
continue
|
|
1223
|
-
if str(fm.get("scope") or "").strip() != "project":
|
|
1224
|
-
r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
|
|
1225
|
-
if not str(fm.get("purpose") or "").strip():
|
|
1226
|
-
r.fail("V27", rel, "`purpose:` is empty — one line: what this rule guards")
|
|
1227
|
-
over = str(fm.get("overrides") or "").strip()
|
|
1228
|
-
dec = str(fm.get("decision") or "").strip()
|
|
1229
|
-
if over:
|
|
1230
|
-
if not (c.root / over).exists():
|
|
1231
|
-
r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
|
|
1232
|
-
f"the rebutted rule may already be gone")
|
|
1233
|
-
if not dec:
|
|
1234
|
-
r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
|
|
1235
|
-
"a rebuttal MUST have a `DEC-` that decided it")
|
|
1236
|
-
elif dec not in dec_ids:
|
|
1237
|
-
r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
|
|
1238
|
-
elif dec:
|
|
1239
|
-
r.fail("V27", rel, "`decision:` is set without `overrides:` — "
|
|
1240
|
-
"name which rule is rebutted, or drop `decision:`")
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
def run_checks(c: Corpus, asof: dt.date) -> Result:
|
|
1244
|
-
r = Result()
|
|
1245
|
-
for fn in (v1, v2, v3, v4, v5, v6, v7, v8, v9, v11, v12, v13, v15, v16, v17, v18, v19, v20,
|
|
1246
|
-
v21, v22, v23, v24, v25, v26, v27):
|
|
1247
|
-
fn(c, r)
|
|
1248
|
-
v14(c, r, asof)
|
|
1249
|
-
return r
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
# ------------------------------------------------------------------ generator
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
def _story_status(c: Corpus, story: dict) -> str:
|
|
1256
|
-
folder = str(story.get("spec_folder") or "").strip()
|
|
1257
|
-
if not folder:
|
|
1258
|
-
return "unknown"
|
|
1259
|
-
matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
|
|
1260
|
-
if not matches:
|
|
1261
|
-
return "unknown"
|
|
1262
|
-
return str((frontmatter(matches[0]) or {}).get("status") or "unknown")
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
def gen_components(c: Corpus) -> dict:
|
|
1266
|
-
return {
|
|
1267
|
-
"product_components": [
|
|
1268
|
-
{"id": pc.get("id"), "name": pc.get("name"),
|
|
1269
|
-
"containers": listy(pc, "containers"),
|
|
1270
|
-
"logical_components": sorted(
|
|
1271
|
-
str(lc.get("id")) for lc in c.lcs
|
|
1272
|
-
if str(lc.get("component")) == str(pc.get("id")))}
|
|
1273
|
-
for pc in c.pcs
|
|
1274
|
-
],
|
|
1275
|
-
"logical_components": [
|
|
1276
|
-
{"id": lc.get("id"), "type": lc.get("type"), "component": lc.get("component"),
|
|
1277
|
-
"area": lc.get("area"), "owner": lc.get("owner")}
|
|
1278
|
-
for lc in c.lcs
|
|
1279
|
-
],
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
def gen_risks(c: Corpus) -> dict:
|
|
1284
|
-
return {"risks": [
|
|
1285
|
-
{"id": x.get("id"), "impact": x.get("impact"), "likelihood": x.get("likelihood"),
|
|
1286
|
-
"owner": x.get("owner"), "status": x.get("status"),
|
|
1287
|
-
"pivot_trigger": x.get("pivot_trigger")}
|
|
1288
|
-
for x in rows(c.risks, "risks") if str(x.get("status")) != "closed"
|
|
1289
|
-
]}
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
def gen_dag(c: Corpus) -> dict:
|
|
1293
|
-
out = []
|
|
1294
|
-
per_wave: dict[str, list[dict]] = {}
|
|
1295
|
-
for wave, _, story in c.stories():
|
|
1296
|
-
per_wave.setdefault(str(wave.get("id")), []).append(story)
|
|
1297
|
-
for wid in sorted(per_wave):
|
|
1298
|
-
items = per_wave[wid]
|
|
1299
|
-
done: set[str] = set()
|
|
1300
|
-
pending = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
|
|
1301
|
-
waves_out = []
|
|
1302
|
-
while pending:
|
|
1303
|
-
ready = sorted(k for k, deps in pending.items() if not (deps - done))
|
|
1304
|
-
if not ready: # cycle — V7 has already reported it
|
|
1305
|
-
waves_out.append({"blocked": sorted(pending)})
|
|
1306
|
-
break
|
|
1307
|
-
waves_out.append({"parallel": ready})
|
|
1308
|
-
done |= set(ready)
|
|
1309
|
-
for k in ready:
|
|
1310
|
-
pending.pop(k)
|
|
1311
|
-
out.append({"wave": wid, "order": waves_out})
|
|
1312
|
-
return {"dag": out}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
def gen_rtm(c: Corpus) -> dict:
|
|
1316
|
-
cap_goal = {str(x.get("id")): str(x.get("goal", "")) for x in c.caps}
|
|
1317
|
-
ucs_for_fr: dict[str, list[str]] = {}
|
|
1318
|
-
for uc in c.ucs:
|
|
1319
|
-
for fr in listy(uc, "satisfies"):
|
|
1320
|
-
ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
|
|
1321
|
-
stories_for_uc: dict[str, list[tuple[dict, dict]]] = {}
|
|
1322
|
-
for wave, _, story in c.stories():
|
|
1323
|
-
for uc in listy(story, "satisfies"):
|
|
1324
|
-
stories_for_uc.setdefault(uc, []).append((wave, story))
|
|
1325
|
-
decs_for: dict[str, list[str]] = {}
|
|
1326
|
-
for dec in c.decs:
|
|
1327
|
-
for target in listy(dec, "serves"):
|
|
1328
|
-
decs_for.setdefault(target, []).append(str(dec.get("id")))
|
|
1329
|
-
|
|
1330
|
-
lines = []
|
|
1331
|
-
for fr in c.frs:
|
|
1332
|
-
fid = str(fr.get("id"))
|
|
1333
|
-
cap = str(fr.get("capability", ""))
|
|
1334
|
-
base = {"BG": cap_goal.get(cap, ""), "CAP": cap, "FR": fid,
|
|
1335
|
-
"DEC": sorted(decs_for.get(fid, []))}
|
|
1336
|
-
ucs = sorted(ucs_for_fr.get(fid, []))
|
|
1337
|
-
if not ucs:
|
|
1338
|
-
exempt = bool(str(fr.get("no_uc") or "").strip())
|
|
1339
|
-
lines.append({**base, "UC": "", "story": "", "wave": "", "release": "",
|
|
1340
|
-
"test": [], "status": "", "green": False,
|
|
1341
|
-
"exempt": exempt,
|
|
1342
|
-
"broken_at": "no_uc" if exempt else "UC"})
|
|
1343
|
-
continue
|
|
1344
|
-
for uid in ucs:
|
|
1345
|
-
pairs = sorted(stories_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
|
|
1346
|
-
if not pairs:
|
|
1347
|
-
lines.append({**base, "UC": uid, "story": "", "wave": "", "release": "",
|
|
1348
|
-
"test": [], "status": "", "green": False, "exempt": False,
|
|
1349
|
-
"broken_at": "story"})
|
|
1350
|
-
continue
|
|
1351
|
-
for wave, story in pairs:
|
|
1352
|
-
status = _story_status(c, story)
|
|
1353
|
-
tests = listy(story, "tests")
|
|
1354
|
-
broken = ""
|
|
1355
|
-
if not tests:
|
|
1356
|
-
broken = "test"
|
|
1357
|
-
elif status != "done":
|
|
1358
|
-
broken = "status"
|
|
1359
|
-
lines.append({**base, "UC": uid, "story": str(story.get("id")),
|
|
1360
|
-
"wave": str(wave.get("id")), "release": str(wave.get("release", "")),
|
|
1361
|
-
"test": tests, "status": status, "exempt": False,
|
|
1362
|
-
"green": broken == "", "broken_at": broken})
|
|
1363
|
-
return {"rtm": lines}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
|
|
1367
|
-
lines = rtm.get("rtm") or []
|
|
1368
|
-
counted = [line for line in lines if not line.get("exempt")]
|
|
1369
|
-
exempt = len(lines) - len(counted)
|
|
1370
|
-
green = sum(1 for line in counted if line.get("green"))
|
|
1371
|
-
per_wave = []
|
|
1372
|
-
for wave in c.wave_list:
|
|
1373
|
-
wid = str(wave.get("id"))
|
|
1374
|
-
items = [s for w, _, s in c.stories() if str(w.get("id")) == wid]
|
|
1375
|
-
done = sum(1 for s in items if _story_status(c, s) == "done")
|
|
1376
|
-
per_wave.append({"wave": wid, "status": wave.get("status"),
|
|
1377
|
-
"stories_done": done, "stories_total": len(items),
|
|
1378
|
-
"work_progress": _pct(done, len(items))})
|
|
1379
|
-
applicable = 26 # V1..V27 minus V10, which was retired
|
|
1380
|
-
return {
|
|
1381
|
-
"promise_progress": _pct(green, len(counted)),
|
|
1382
|
-
"rtm_rows": {"green": green, "counted": len(counted),
|
|
1383
|
-
"excluded_no_uc": exempt},
|
|
1384
|
-
"work_progress": per_wave,
|
|
1385
|
-
"gate_readiness": _pct(applicable - len(result.red), applicable),
|
|
1386
|
-
"validators_red": result.red,
|
|
1387
|
-
"validators_skipped": dict(sorted(result.skipped.items())),
|
|
1388
|
-
"open_questions": _question_budget(c),
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
def _question_budget(c: Corpus) -> dict:
|
|
1393
|
-
"""Counts of all four question lists, compared against the budget in index.yaml.
|
|
1394
|
-
|
|
1395
|
-
The budget is NOT a hard gate. It is reported when a batch exceeds it, because a larger
|
|
1396
|
-
batch is a signal about the pass, not about the corpus.
|
|
1397
|
-
"""
|
|
1398
|
-
budget = c.index.get("question_budget") or {}
|
|
1399
|
-
out: dict[str, object] = {}
|
|
1400
|
-
for name in ("blocking", "assumptions", "external", "answered"):
|
|
1401
|
-
path = c.root / ".control/questions" / f"{name}.md"
|
|
1402
|
-
rows_n = 0
|
|
1403
|
-
if path.exists():
|
|
1404
|
-
rows_n = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1405
|
-
if line.startswith("| OQ-"))
|
|
1406
|
-
out[name] = rows_n
|
|
1407
|
-
cap_block = budget.get("blocking_per_component")
|
|
1408
|
-
if cap_block and c.pcs:
|
|
1409
|
-
allowed = int(cap_block) * len(c.pcs)
|
|
1410
|
-
out["blocking_budget"] = allowed
|
|
1411
|
-
out["blocking_over_budget"] = out["blocking"] > allowed
|
|
1412
|
-
cap_assume = budget.get("assumptions_per_gate")
|
|
1413
|
-
if cap_assume:
|
|
1414
|
-
out["assumptions_budget_per_gate"] = int(cap_assume)
|
|
1415
|
-
return out
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
def _pct(part: int, total: int) -> str:
|
|
1419
|
-
return "n/a" if total == 0 else f"{round(100 * part / total)}%"
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
def as_markdown(name: str, payload: dict) -> str:
|
|
1423
|
-
body = dump(payload)
|
|
1424
|
-
return (f"# {name}\n\n"
|
|
1425
|
-
f"> Generated by `.constitution/method/scripts/validate.py --generate`. "
|
|
1426
|
-
f"MUST NOT be hand-edited.\n\n"
|
|
1427
|
-
f"```yaml\n{body}```\n")
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
# ------------------------------------------------------- pages for humans
|
|
1431
|
-
|
|
1432
|
-
PAGE_HEADER = ("> Generated by `.constitution/method/scripts/validate.py --generate`. "
|
|
1433
|
-
"MUST NOT be hand-edited.\n")
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
def _section(path: Path, heading: str) -> str:
|
|
1437
|
-
"""Extract one `## <heading>` section from a markdown file, as-is."""
|
|
1438
|
-
if not path.exists():
|
|
1439
|
-
return ""
|
|
1440
|
-
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1441
|
-
out: list[str] = []
|
|
1442
|
-
inside = False
|
|
1443
|
-
for line in lines:
|
|
1444
|
-
if line.startswith("## "):
|
|
1445
|
-
if inside:
|
|
1446
|
-
break
|
|
1447
|
-
inside = line[3:].strip().lower().startswith(heading.lower())
|
|
1448
|
-
continue
|
|
1449
|
-
if inside:
|
|
1450
|
-
out.append(line)
|
|
1451
|
-
return "\n".join(out).strip("\n")
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
def _body(path: Path) -> str:
|
|
1455
|
-
"""File content without frontmatter and without template comments."""
|
|
1456
|
-
if not path.exists():
|
|
1457
|
-
return ""
|
|
1458
|
-
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1459
|
-
match = FM.match(text)
|
|
1460
|
-
if match:
|
|
1461
|
-
text = text[match.end():]
|
|
1462
|
-
while "<!--" in text and "-->" in text:
|
|
1463
|
-
head, _, rest = text.partition("<!--")
|
|
1464
|
-
_, _, tail = rest.partition("-->")
|
|
1465
|
-
text = head + tail
|
|
1466
|
-
return text.strip("\n")
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
def page_decisions(c: Corpus) -> str:
|
|
1470
|
-
"""Flat table of every `DEC-`. This is what replaces looking up decisions through the memlog."""
|
|
1471
|
-
rows_out = ["| id | Title | Status | Type | Touches | File |",
|
|
1472
|
-
"| --- | --- | --- | --- | --- | --- |"]
|
|
1473
|
-
for dec in c.decs:
|
|
1474
|
-
touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
|
|
1475
|
-
rows_out.append(
|
|
1476
|
-
f"| `{dec.get('id')}` | {_cell(dec.get('title'))} | `{dec.get('status', '')}` "
|
|
1477
|
-
f"| {dec.get('type') or '—'} | {touches} | `{dec.get('file', '')}` |")
|
|
1478
|
-
counts: dict[str, int] = {}
|
|
1479
|
-
for dec in c.decs:
|
|
1480
|
-
key = str(dec.get("status"))
|
|
1481
|
-
counts[key] = counts.get(key, 0) + 1
|
|
1482
|
-
tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "no decisions yet"
|
|
1483
|
-
return ("# decisions\n\n" + PAGE_HEADER +
|
|
1484
|
-
"\nDecisions are no longer looked up through the memlog — the memlog goes back to being just a pass log.\n"
|
|
1485
|
-
f"\n**{len(c.decs)} decisions** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
def page_blueprint(c: Corpus) -> str:
|
|
1489
|
-
"""One-page roll-up reviewed at G3. Seven files become one read.
|
|
1490
|
-
|
|
1491
|
-
The UC catalogue, actor list, and domain model stay put in their own component's kernel as
|
|
1492
|
-
their permanent home. This is their view. One fact, one home, one view.
|
|
1493
|
-
"""
|
|
1494
|
-
parts = ["# blueprint\n", PAGE_HEADER,
|
|
1495
|
-
"\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
|
|
1496
|
-
"content is affected by neither `mode` nor `risk_accepted`.\n"]
|
|
1497
|
-
|
|
1498
|
-
crit = sum(1 for uc in c.ucs if uc.get("critical"))
|
|
1499
|
-
parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
|
|
1500
|
-
f"`critical`.\n")
|
|
1501
|
-
parts.append("| id | Use case | Component | Satisfies | critical |")
|
|
1502
|
-
parts.append("| --- | --- | --- | --- | --- |")
|
|
1503
|
-
for uc in c.ucs:
|
|
1504
|
-
sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
|
|
1505
|
-
flag = "yes" if uc.get("critical") else "no"
|
|
1506
|
-
parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
|
|
1507
|
-
f"`{uc.get('component', '')}` | {sat} | {flag} |")
|
|
1508
|
-
|
|
1509
|
-
parts.append("\n## Actor list\n")
|
|
1510
|
-
for pc in c.pcs:
|
|
1511
|
-
pid = str(pc.get("id"))
|
|
1512
|
-
block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
|
|
1513
|
-
parts.append(f"\n### {pid} — {pc.get('name', '')}\n")
|
|
1514
|
-
parts.append(_demote(block) if block
|
|
1515
|
-
else "_no § Actor Register in this component's SRS yet._")
|
|
1516
|
-
|
|
1517
|
-
parts.append("\n## Domain model\n")
|
|
1518
|
-
for pc in c.pcs:
|
|
1519
|
-
pid = str(pc.get("id"))
|
|
1520
|
-
block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
|
|
1521
|
-
parts.append(f"\n### {pid}\n")
|
|
1522
|
-
parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
|
|
1523
|
-
|
|
1524
|
-
parts.append("\n## Three inventories\n")
|
|
1525
|
-
for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
|
|
1526
|
-
block = _body(c.root / f".how/_platform/inventory-{kind}.md")
|
|
1527
|
-
parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
|
|
1528
|
-
parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
|
|
1529
|
-
|
|
1530
|
-
return "\n".join(parts) + "\n"
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
def _cell(value: object, limit: int = 110) -> str:
|
|
1534
|
-
"""One table row, shortened. The full-length source stays in the registry — this is just a view."""
|
|
1535
|
-
text = " ".join(str(value or "").split()).replace("|", "\\|")
|
|
1536
|
-
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
def _demote(block: str, by: int = 2) -> str:
|
|
1540
|
-
"""Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
|
|
1541
|
-
out = []
|
|
1542
|
-
for line in block.splitlines():
|
|
1543
|
-
stripped = line.lstrip()
|
|
1544
|
-
if stripped.startswith("#"):
|
|
1545
|
-
hashes = len(stripped) - len(stripped.lstrip("#"))
|
|
1546
|
-
out.append("#" * min(6, hashes + by) + stripped[hashes:])
|
|
1547
|
-
else:
|
|
1548
|
-
out.append(line)
|
|
1549
|
-
return "\n".join(out)
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
def page_estimate(c: Corpus) -> str:
|
|
1553
|
-
"""Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
|
|
1554
|
-
mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
|
|
1555
|
-
risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
|
|
1556
|
-
str(pc.get("risk_note") or "—")) for pc in c.pcs}
|
|
1557
|
-
cap_by_id = {str(x.get("id")): x for x in c.caps}
|
|
1558
|
-
fr_per_cap: dict[str, int] = {}
|
|
1559
|
-
for fr in c.frs:
|
|
1560
|
-
key = str(fr.get("capability", ""))
|
|
1561
|
-
fr_per_cap[key] = fr_per_cap.get(key, 0) + 1
|
|
1562
|
-
|
|
1563
|
-
have_mandays = any(x.get("estimate_mandays") for x in c.caps)
|
|
1564
|
-
parts = ["# estimate\n", PAGE_HEADER,
|
|
1565
|
-
"\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
|
|
1566
|
-
"task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
|
|
1567
|
-
"neighboring rows MAY be merged into one — that merge is a human decision made when the "
|
|
1568
|
-
"wave is opened.\n"]
|
|
1569
|
-
if not have_mandays:
|
|
1570
|
-
parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
|
|
1571
|
-
"this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
|
|
1572
|
-
|
|
1573
|
-
parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
|
|
1574
|
-
parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
1575
|
-
for fr in c.frs:
|
|
1576
|
-
cap_id = str(fr.get("capability", ""))
|
|
1577
|
-
cap = cap_by_id.get(cap_id, {})
|
|
1578
|
-
pid = str(fr.get("component") or cap.get("component") or "")
|
|
1579
|
-
risk, note = risk_of.get(pid, ("—", "—"))
|
|
1580
|
-
exposure = "not set yet" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
|
|
1581
|
-
mandays = cap.get("estimate_mandays")
|
|
1582
|
-
share = "—"
|
|
1583
|
-
if mandays:
|
|
1584
|
-
try:
|
|
1585
|
-
share = f"{float(mandays) / max(1, fr_per_cap.get(cap_id, 1)):.1f}"
|
|
1586
|
-
except (TypeError, ValueError):
|
|
1587
|
-
share = "—"
|
|
1588
|
-
deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
|
|
1589
|
-
parts.append(
|
|
1590
|
-
f"| {_cell(fr.get('text') or fr.get('title'))} | `{fr.get('id')}` | `{pid or '—'}` "
|
|
1591
|
-
f"| `{mode_of.get(pid, 'catalog')}` | {exposure} | {share} "
|
|
1592
|
-
f"| {cap.get('priority', '—')} | {deps} | {cap.get('target_release', '—')} |")
|
|
1593
|
-
return "\n".join(parts) + "\n"
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
def generate(c: Corpus, result: Result) -> list[Path]:
|
|
1597
|
-
out_dir = c.root / ".control" / "generated"
|
|
1598
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1599
|
-
rtm = gen_rtm(c)
|
|
1600
|
-
payloads = {
|
|
1601
|
-
"components": gen_components(c),
|
|
1602
|
-
"risks": gen_risks(c),
|
|
1603
|
-
"dag": gen_dag(c),
|
|
1604
|
-
"rtm": rtm,
|
|
1605
|
-
"status": gen_status(c, rtm, result),
|
|
1606
|
-
}
|
|
1607
|
-
written = []
|
|
1608
|
-
for name in GENERATED_ORDER:
|
|
1609
|
-
payload = payloads[name]
|
|
1610
|
-
yaml_path = out_dir / f"{name}.yaml"
|
|
1611
|
-
yaml_path.write_text(dump(payload), encoding="utf-8")
|
|
1612
|
-
md_path = out_dir / f"{name}.md"
|
|
1613
|
-
md_path.write_text(as_markdown(name, payload), encoding="utf-8")
|
|
1614
|
-
written += [yaml_path, md_path]
|
|
1615
|
-
|
|
1616
|
-
# Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
|
|
1617
|
-
# not wrapped in a yaml fence, and no machine reader demands a second version of it.
|
|
1618
|
-
for name, render in (("decisions", page_decisions),
|
|
1619
|
-
("blueprint", page_blueprint),
|
|
1620
|
-
("estimate", page_estimate)):
|
|
1621
|
-
page = out_dir / f"{name}.md"
|
|
1622
|
-
page.write_text(render(c), encoding="utf-8")
|
|
1623
|
-
written.append(page)
|
|
1624
|
-
return written
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
# ------------------------------------------------------------------------ CLI
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
def main(argv: list[str] | None = None) -> int:
|
|
1631
|
-
parser = argparse.ArgumentParser(
|
|
1632
|
-
prog="validate", description="V1..V27 and the .control/generated/ generator")
|
|
1633
|
-
parser.add_argument("--check", action="store_true",
|
|
1634
|
-
help="check only; exit non-zero if anything is red")
|
|
1635
|
-
parser.add_argument("--generate", action="store_true",
|
|
1636
|
-
help="rewrite .control/generated/ (still runs the check first)")
|
|
1637
|
-
parser.add_argument("--root", default=".", help="repo root (default: current directory)")
|
|
1638
|
-
parser.add_argument("--asof", default=None,
|
|
1639
|
-
help="reference date for V14, format YYYY-MM-DD (default: today). "
|
|
1640
|
-
"Stated explicitly so a run can be repeated exactly")
|
|
1641
|
-
args = parser.parse_args(argv)
|
|
1642
|
-
|
|
1643
|
-
if not args.check and not args.generate:
|
|
1644
|
-
args.check = True
|
|
1645
|
-
|
|
1646
|
-
root = Path(args.root).resolve()
|
|
1647
|
-
if not (root / ".control" / "registry").is_dir():
|
|
1648
|
-
print(f"validate: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
|
|
1649
|
-
return 2
|
|
1650
|
-
|
|
1651
|
-
asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
|
|
1652
|
-
corpus = Corpus.load(root)
|
|
1653
|
-
result = run_checks(corpus, asof)
|
|
1654
|
-
|
|
1655
|
-
if args.generate:
|
|
1656
|
-
for path in generate(corpus, result):
|
|
1657
|
-
print(f" wrote {path.relative_to(root).as_posix()}")
|
|
1658
|
-
|
|
1659
|
-
if result.findings:
|
|
1660
|
-
print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
|
|
1661
|
-
for finding in sorted(result.findings, key=lambda f: f.sort_key):
|
|
1662
|
-
print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
|
|
1663
|
-
else:
|
|
1664
|
-
print("\nGREEN — no findings")
|
|
1665
|
-
|
|
1666
|
-
if result.skipped:
|
|
1667
|
-
print("\nSkipped:")
|
|
1668
|
-
for vid, why in sorted(result.skipped.items()):
|
|
1669
|
-
print(f" {vid:<4} {why}")
|
|
1670
|
-
|
|
1671
|
-
print(f"\nV14 reference date: {asof.isoformat()}")
|
|
1672
|
-
return 1 if result.findings else 0
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
if __name__ == "__main__":
|
|
1676
|
-
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 os
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
|
|
33
|
+
REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
|
|
34
|
+
GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
|
|
35
|
+
|
|
36
|
+
# Pages read by HUMANS, not machines: written as real markdown tables, not yaml
|
|
37
|
+
# in a fence. All three are named in §22 and each has one clear reader.
|
|
38
|
+
GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
|
|
39
|
+
|
|
40
|
+
MODES = ("catalog", "outline", "guarded", "deep")
|
|
41
|
+
|
|
42
|
+
# Keywords that make a component "sensitive" for V23. Matched against `risk_note`, which is PROSE in
|
|
43
|
+
# whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
|
|
44
|
+
# than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
|
|
45
|
+
# it does not judge. Deliberately short.
|
|
46
|
+
SENSITIVE_MARKERS = (
|
|
47
|
+
# English
|
|
48
|
+
"money", "payment", "personal data", "pii",
|
|
49
|
+
"irreversible", "cannot be undone", "contractual", "contract", "integration",
|
|
50
|
+
# Bahasa Indonesia
|
|
51
|
+
"uang", "pembayaran", "data pribadi",
|
|
52
|
+
"tak-terbalikkan", "tak terbalikkan", "tidak dapat dibatalkan",
|
|
53
|
+
"kontraktual", "kontrak", "integrasi",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------- infrastructure
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Finding:
|
|
62
|
+
vid: str
|
|
63
|
+
subject: str
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def sort_key(self) -> tuple[int, str, str]:
|
|
68
|
+
digits = "".join(ch for ch in self.vid if ch.isdigit())
|
|
69
|
+
return (int(digits or 0), self.subject, self.message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Result:
|
|
74
|
+
findings: list[Finding] = field(default_factory=list)
|
|
75
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def fail(self, vid: str, subject: str, message: str) -> None:
|
|
78
|
+
self.findings.append(Finding(vid, subject, message))
|
|
79
|
+
|
|
80
|
+
def skip(self, vid: str, why: str) -> None:
|
|
81
|
+
self.skipped[vid] = why
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def red(self) -> list[str]:
|
|
85
|
+
return sorted({f.vid for f in self.findings})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_yaml(path: Path) -> dict:
|
|
89
|
+
if not path.exists():
|
|
90
|
+
return {}
|
|
91
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
92
|
+
return data if isinstance(data, dict) else {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rows(data: dict, key: str) -> list[dict]:
|
|
96
|
+
"""Registry list, always sorted by id so the output is deterministic."""
|
|
97
|
+
value = data.get(key) or []
|
|
98
|
+
if not isinstance(value, list):
|
|
99
|
+
return []
|
|
100
|
+
items = [v for v in value if isinstance(v, dict)]
|
|
101
|
+
return sorted(items, key=lambda r: str(r.get("id", "")))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Dumper(yaml.SafeDumper):
|
|
108
|
+
"""No anchors/aliases: output MUST be readable and diffable line by line."""
|
|
109
|
+
|
|
110
|
+
def ignore_aliases(self, data) -> bool: # noqa: ARG002
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dump(payload: dict) -> str:
|
|
115
|
+
return yaml.dump(payload, Dumper=Dumper, allow_unicode=True, sort_keys=False,
|
|
116
|
+
default_flow_style=False, width=100)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def frontmatter(path: Path) -> dict | None:
|
|
120
|
+
"""None if the file does not exist; {} if it exists but has no frontmatter."""
|
|
121
|
+
if not path.exists():
|
|
122
|
+
return None
|
|
123
|
+
match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
|
|
124
|
+
if not match:
|
|
125
|
+
return {}
|
|
126
|
+
data = yaml.safe_load(match.group(1))
|
|
127
|
+
return data if isinstance(data, dict) else {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def git(root: Path, *args: str) -> str | None:
|
|
131
|
+
try:
|
|
132
|
+
out = subprocess.run(
|
|
133
|
+
["git", "-C", str(root), *args],
|
|
134
|
+
capture_output=True, text=True, timeout=30, check=False,
|
|
135
|
+
)
|
|
136
|
+
except (OSError, subprocess.SubprocessError):
|
|
137
|
+
return None
|
|
138
|
+
return out.stdout.strip() if out.returncode == 0 else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------- loading
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Corpus:
|
|
146
|
+
root: Path
|
|
147
|
+
requirements: dict
|
|
148
|
+
usecases: dict
|
|
149
|
+
decisions: dict
|
|
150
|
+
risks: dict
|
|
151
|
+
components: dict
|
|
152
|
+
waves: dict
|
|
153
|
+
defects: dict
|
|
154
|
+
index: dict
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def load(cls, root: Path) -> "Corpus":
|
|
158
|
+
reg = root / ".control" / "registry"
|
|
159
|
+
return cls(
|
|
160
|
+
root=root,
|
|
161
|
+
requirements=load_yaml(reg / "requirements.yaml"),
|
|
162
|
+
usecases=load_yaml(reg / "usecases.yaml"),
|
|
163
|
+
decisions=load_yaml(reg / "decisions.yaml"),
|
|
164
|
+
risks=load_yaml(reg / "risks.yaml"),
|
|
165
|
+
components=load_yaml(reg / "components.yaml"),
|
|
166
|
+
waves=load_yaml(reg / "waves.yaml"),
|
|
167
|
+
defects=load_yaml(reg / "defects.yaml"),
|
|
168
|
+
index=load_yaml(reg / "index.yaml"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# --- shortcuts used repeatedly
|
|
172
|
+
@property
|
|
173
|
+
def goals(self) -> list[dict]:
|
|
174
|
+
return rows(self.requirements, "goals")
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def caps(self) -> list[dict]:
|
|
178
|
+
return rows(self.requirements, "capabilities")
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def frs(self) -> list[dict]:
|
|
182
|
+
return rows(self.requirements, "functional")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def nfrs(self) -> list[dict]:
|
|
186
|
+
return rows(self.requirements, "nonfunctional")
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def ucs(self) -> list[dict]:
|
|
190
|
+
return rows(self.usecases, "usecases")
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def decs(self) -> list[dict]:
|
|
194
|
+
return rows(self.decisions, "decisions")
|
|
195
|
+
|
|
196
|
+
def mode_of(self, pc: dict) -> str:
|
|
197
|
+
"""Per-component `mode` wins over the global one; with neither, default `catalog`."""
|
|
198
|
+
own = str(pc.get("mode") or "").strip()
|
|
199
|
+
if own:
|
|
200
|
+
return own
|
|
201
|
+
return str(self.index.get("mode") or "").strip() or "catalog"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def lcs(self) -> list[dict]:
|
|
205
|
+
return rows(self.components, "logical_components")
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def pcs(self) -> list[dict]:
|
|
209
|
+
return rows(self.components, "product_components")
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def wave_list(self) -> list[dict]:
|
|
213
|
+
return rows(self.waves, "waves")
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def defect_list(self) -> list[dict]:
|
|
217
|
+
return rows(self.defects, "defects")
|
|
218
|
+
|
|
219
|
+
def stories(self) -> list[tuple[dict, dict, dict]]:
|
|
220
|
+
"""(wave, epic, story) — sorted by id at each level."""
|
|
221
|
+
out = []
|
|
222
|
+
for wave in self.wave_list:
|
|
223
|
+
for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
|
|
224
|
+
if not isinstance(epic, dict):
|
|
225
|
+
continue
|
|
226
|
+
for story in sorted(epic.get("stories") or [], key=lambda s: str(s.get("id", ""))):
|
|
227
|
+
if isinstance(story, dict):
|
|
228
|
+
out.append((wave, epic, story))
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def listy(row: dict, key: str) -> list[str]:
|
|
233
|
+
value = row.get(key) or []
|
|
234
|
+
if isinstance(value, str):
|
|
235
|
+
return [value]
|
|
236
|
+
return [str(v) for v in value if v is not None]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------ validators
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def v1(c: Corpus, r: Result) -> None:
|
|
243
|
+
"""Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
|
|
244
|
+
|
|
245
|
+
A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
|
|
246
|
+
deployment foundation can be extended without being torn down — is measured by two architectural
|
|
247
|
+
properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
|
|
248
|
+
one `FR` there produces a false promise, and a false promise is more expensive than a finding.
|
|
249
|
+
|
|
250
|
+
The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
|
|
251
|
+
"""
|
|
252
|
+
cap_by_goal: dict[str, list[str]] = {}
|
|
253
|
+
for cap in c.caps:
|
|
254
|
+
cap_by_goal.setdefault(str(cap.get("goal", "")), []).append(str(cap.get("id")))
|
|
255
|
+
fr_caps = {str(fr.get("capability", "")) for fr in c.frs}
|
|
256
|
+
for goal in c.goals:
|
|
257
|
+
gid = str(goal.get("id"))
|
|
258
|
+
reachable = [cid for cid in cap_by_goal.get(gid, []) if cid in fr_caps]
|
|
259
|
+
if reachable:
|
|
260
|
+
continue
|
|
261
|
+
if str(goal.get("no_fr") or "").strip():
|
|
262
|
+
continue
|
|
263
|
+
r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def v2(c: Corpus, r: Result) -> None:
|
|
267
|
+
covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
|
|
268
|
+
for fr in c.frs:
|
|
269
|
+
fid = str(fr.get("id"))
|
|
270
|
+
if fid in covered:
|
|
271
|
+
continue
|
|
272
|
+
if str(fr.get("no_uc") or "").strip():
|
|
273
|
+
continue
|
|
274
|
+
r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def v3(c: Corpus, r: Result) -> None:
|
|
278
|
+
"""A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
|
|
279
|
+
|
|
280
|
+
The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
|
|
281
|
+
entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
|
|
282
|
+
not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
|
|
283
|
+
findings under fifty-six expected ones stops being read, and a validator that is not read
|
|
284
|
+
guards nothing.
|
|
285
|
+
|
|
286
|
+
What is guarded now is the actual omission: a wave touches a component, and a UC of that
|
|
287
|
+
component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
|
|
288
|
+
and `wdi-build` owns it — the same way V12 was shifted to wave closing.
|
|
289
|
+
"""
|
|
290
|
+
scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
|
|
291
|
+
touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
|
|
292
|
+
if not c.wave_list:
|
|
293
|
+
r.skip("V3", "no wave yet, so no story yet — every unscheduled UC is the correct "
|
|
294
|
+
"state. Full catalogue coverage is checked at G5")
|
|
295
|
+
return
|
|
296
|
+
for uc in c.ucs:
|
|
297
|
+
uid = str(uc.get("id"))
|
|
298
|
+
if uid in scheduled or str(uc.get("component")) not in touched:
|
|
299
|
+
continue
|
|
300
|
+
r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
|
|
301
|
+
f"but this UC is not scheduled to any story")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def v4(c: Corpus, r: Result) -> None:
|
|
305
|
+
for _, _, story in c.stories():
|
|
306
|
+
if not [t for t in listy(story, "tests") if t.strip()]:
|
|
307
|
+
r.fail("V4", str(story.get("id")), "has not one named test")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def v5(c: Corpus, r: Result) -> None:
|
|
311
|
+
"""Every NFR has an enforcer, OR states its reason in `no_enforcer`.
|
|
312
|
+
|
|
313
|
+
Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
|
|
314
|
+
**retired**, and the other states of itself that it is a **design measure, not a gate**. Demanding
|
|
315
|
+
a test for both produces a test that cannot fail, and a test that cannot fail is theater.
|
|
316
|
+
"""
|
|
317
|
+
for nfr in c.nfrs:
|
|
318
|
+
if [e for e in listy(nfr, "enforced_by") if e.strip()]:
|
|
319
|
+
continue
|
|
320
|
+
if str(nfr.get("no_enforcer") or "").strip():
|
|
321
|
+
continue
|
|
322
|
+
r.fail("V5", str(nfr.get("id")),
|
|
323
|
+
"has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def v6(c: Corpus, r: Result) -> None:
|
|
327
|
+
defined: set[str] = set()
|
|
328
|
+
for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
|
|
329
|
+
rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
|
|
330
|
+
defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
|
|
331
|
+
for wave in c.wave_list:
|
|
332
|
+
defined.add(str(wave.get("id")))
|
|
333
|
+
for _, epic, story in c.stories():
|
|
334
|
+
defined.add(str(epic.get("id")))
|
|
335
|
+
defined.add(str(story.get("id")))
|
|
336
|
+
|
|
337
|
+
refs: list[tuple[str, str]] = []
|
|
338
|
+
for cap in c.caps:
|
|
339
|
+
refs.append((str(cap.get("id")), str(cap.get("goal", ""))))
|
|
340
|
+
refs += [(str(cap.get("id")), d) for d in listy(cap, "depends_on")]
|
|
341
|
+
for fr in c.frs:
|
|
342
|
+
refs.append((str(fr.get("id")), str(fr.get("capability", ""))))
|
|
343
|
+
for nfr in c.nfrs:
|
|
344
|
+
refs.append((str(nfr.get("id")), str(nfr.get("goal", ""))))
|
|
345
|
+
for uc in c.ucs:
|
|
346
|
+
refs += [(str(uc.get("id")), f) for f in listy(uc, "satisfies")]
|
|
347
|
+
for dec in c.decs:
|
|
348
|
+
refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
|
|
349
|
+
for defect in c.defect_list:
|
|
350
|
+
refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
|
|
351
|
+
for _, _, story in c.stories():
|
|
352
|
+
refs += [(str(story.get("id")), u) for u in listy(story, "satisfies")]
|
|
353
|
+
refs += [(str(story.get("id")), d) for d in listy(story, "depends_on")]
|
|
354
|
+
|
|
355
|
+
for owner, target in sorted(set(refs)):
|
|
356
|
+
if target and target not in defined:
|
|
357
|
+
r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
361
|
+
state: dict[str, int] = {}
|
|
362
|
+
bad: list[str] = []
|
|
363
|
+
|
|
364
|
+
def walk(node: str) -> None:
|
|
365
|
+
state[node] = 1
|
|
366
|
+
for nxt in sorted(graph.get(node, [])):
|
|
367
|
+
if state.get(nxt) == 1:
|
|
368
|
+
bad.append(node)
|
|
369
|
+
elif state.get(nxt) is None and nxt in graph:
|
|
370
|
+
walk(nxt)
|
|
371
|
+
state[node] = 2
|
|
372
|
+
|
|
373
|
+
for node in sorted(graph):
|
|
374
|
+
if state.get(node) is None:
|
|
375
|
+
walk(node)
|
|
376
|
+
return sorted(set(bad))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def v7(c: Corpus, r: Result) -> None:
|
|
380
|
+
caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
|
|
381
|
+
for node in _cycles(caps):
|
|
382
|
+
r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
|
|
383
|
+
stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
|
|
384
|
+
for node in _cycles(stories):
|
|
385
|
+
r.fail("V7", node, "is part of a `depends_on` cycle among stories")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def v8(c: Corpus, r: Result) -> None:
|
|
389
|
+
"""Every `applied` decision names a non-empty `touches`.
|
|
390
|
+
|
|
391
|
+
Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
|
|
392
|
+
"the filter MUST work like this" serves no FR at all, and that is VALID — it is exactly
|
|
393
|
+
decisions like that which most need remembering, and the old rule discarded them.
|
|
394
|
+
"""
|
|
395
|
+
for dec in c.decs:
|
|
396
|
+
if str(dec.get("status")) != "applied":
|
|
397
|
+
continue
|
|
398
|
+
if not [x for x in listy(dec, "touches") if str(x).strip()]:
|
|
399
|
+
r.fail("V8", str(dec.get("id")),
|
|
400
|
+
"is applied but `touches` is empty — an application with no file trace")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def v9(c: Corpus, r: Result) -> None:
|
|
404
|
+
passed = {str(g) for g in (c.index.get("gates_passed") or [])}
|
|
405
|
+
for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
|
|
406
|
+
fm = frontmatter(path) or {}
|
|
407
|
+
if str(fm.get("status")) != "locked":
|
|
408
|
+
continue
|
|
409
|
+
gate = str(fm.get("locked_at_gate") or "")
|
|
410
|
+
if gate not in passed:
|
|
411
|
+
rel = path.relative_to(c.root).as_posix()
|
|
412
|
+
r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def v11(c: Corpus, r: Result) -> None:
|
|
416
|
+
per_wave: dict[str, list[dict]] = {}
|
|
417
|
+
for wave, _, story in c.stories():
|
|
418
|
+
per_wave.setdefault(str(wave.get("id")), []).append(story)
|
|
419
|
+
|
|
420
|
+
for wid in sorted(per_wave):
|
|
421
|
+
items = per_wave[wid]
|
|
422
|
+
edges = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
|
|
423
|
+
|
|
424
|
+
def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
|
|
425
|
+
seen = seen or set()
|
|
426
|
+
if a in seen:
|
|
427
|
+
return False
|
|
428
|
+
seen.add(a)
|
|
429
|
+
if b in edges.get(a, set()):
|
|
430
|
+
return True
|
|
431
|
+
return any(reaches(n, b, seen) for n in sorted(edges.get(a, set())))
|
|
432
|
+
|
|
433
|
+
for i, left in enumerate(items):
|
|
434
|
+
for right in items[i + 1:]:
|
|
435
|
+
lid, rid = str(left.get("id")), str(right.get("id"))
|
|
436
|
+
shared = sorted(set(listy(left, "touches")) & set(listy(right, "touches")))
|
|
437
|
+
if not shared:
|
|
438
|
+
continue
|
|
439
|
+
if reaches(lid, rid) or reaches(rid, lid):
|
|
440
|
+
continue
|
|
441
|
+
r.fail("V11", f"{lid} + {rid}",
|
|
442
|
+
f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def v12(c: Corpus, r: Result) -> None:
|
|
446
|
+
"""LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
|
|
447
|
+
|
|
448
|
+
The old shape demanded the answer when the information was thinnest. At wave closing,
|
|
449
|
+
every `touches` already has an area and every boundary already has a name.
|
|
450
|
+
"""
|
|
451
|
+
areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
|
|
452
|
+
lcs_per_pc: dict[str, int] = {}
|
|
453
|
+
for lc in c.lcs:
|
|
454
|
+
lcs_per_pc[str(lc.get("component"))] = lcs_per_pc.get(str(lc.get("component")), 0) + 1
|
|
455
|
+
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
456
|
+
|
|
457
|
+
seen: set[tuple[str, str]] = set()
|
|
458
|
+
for wave, _, story in c.stories():
|
|
459
|
+
if str(wave.get("status")) != "closed":
|
|
460
|
+
continue
|
|
461
|
+
for area in listy(story, "touches"):
|
|
462
|
+
if area not in areas:
|
|
463
|
+
r.fail("V12", str(story.get("id")),
|
|
464
|
+
f"its wave is already closed, but `{area}` is not registered as an `area` "
|
|
465
|
+
f"in components.yaml")
|
|
466
|
+
pid = str(story.get("component") or "")
|
|
467
|
+
row = pc_by_id.get(pid)
|
|
468
|
+
if row is None or (str(wave.get("id")), pid) in seen:
|
|
469
|
+
continue
|
|
470
|
+
seen.add((str(wave.get("id")), pid))
|
|
471
|
+
if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
|
|
472
|
+
r.fail("V12", f"{wave.get('id')} / {pid}",
|
|
473
|
+
f"wave closed and component with mode `{c.mode_of(row)}` has not one "
|
|
474
|
+
f"`LC` registered")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
LENS_BY_RISK = {
|
|
478
|
+
"low": {"edge-case-hunter"},
|
|
479
|
+
"medium": {"edge-case-hunter"},
|
|
480
|
+
"high": set(),
|
|
481
|
+
}
|
|
482
|
+
FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
|
|
486
|
+
# str() before the truth test: an unquoted sha of all digits — `0000000`, and roughly one
|
|
487
|
+
# short sha in twenty-seven is all digits — is read by YAML as the INTEGER 0, which is falsy.
|
|
488
|
+
# The old test then reported "carries no reviewed trace" about a file that plainly carries one,
|
|
489
|
+
# which is the worst kind of finding: correct-looking, and wrong.
|
|
490
|
+
if not isinstance(block, dict):
|
|
491
|
+
r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
|
|
492
|
+
return
|
|
493
|
+
# NOT `block.get("sha") or ""` — for the integer 0 that yields "" and reintroduces the very
|
|
494
|
+
# bug this guards. `.get(key, "")` returns the 0, and str(0) is "0", which is truthy.
|
|
495
|
+
if not str(block.get("sha", "")).strip() or not str(block.get("date", "")).strip():
|
|
496
|
+
r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
|
|
497
|
+
return
|
|
498
|
+
lenses = {str(x) for x in (block.get("lenses") or [])}
|
|
499
|
+
if not lenses:
|
|
500
|
+
r.fail("V13", rel, "the `reviewed` trace names not one lens")
|
|
501
|
+
missing = sorted(need - lenses)
|
|
502
|
+
if missing:
|
|
503
|
+
r.fail("V13", rel,
|
|
504
|
+
f"lenses {missing} MUST be included — that is what the component's `risk_accepted` demands")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _only_reviewed_block(diff: str) -> bool:
|
|
508
|
+
"""True if a commit's diff on one file ONLY touches the `reviewed:` block.
|
|
509
|
+
|
|
510
|
+
This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
|
|
511
|
+
the file — but the commit that WRITES the `reviewed:` block always changes the file, and
|
|
512
|
+
writing its own hash into a git commit is cryptographically impossible. As a result every
|
|
513
|
+
artifact that had just been stamped immediately read as "stale review", forever.
|
|
514
|
+
"""
|
|
515
|
+
touched = [ln for ln in diff.splitlines()
|
|
516
|
+
if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
|
|
517
|
+
if not touched:
|
|
518
|
+
return True
|
|
519
|
+
for ln in touched:
|
|
520
|
+
body = ln[1:].strip()
|
|
521
|
+
if not body or body.startswith("#"):
|
|
522
|
+
continue
|
|
523
|
+
if not body.startswith(FRONTMATTER_KEYS):
|
|
524
|
+
return False
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
|
|
529
|
+
"""First commit after `sha` that changes this file for a reason other than a review stamp."""
|
|
530
|
+
log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
|
|
531
|
+
if not log:
|
|
532
|
+
return None
|
|
533
|
+
for head in log.splitlines():
|
|
534
|
+
head = head.strip()
|
|
535
|
+
if not head:
|
|
536
|
+
continue
|
|
537
|
+
diff = git(c.root, "show", "--format=", "--unified=0", head, "--", rel)
|
|
538
|
+
if diff is None:
|
|
539
|
+
return head
|
|
540
|
+
if _only_reviewed_block(diff):
|
|
541
|
+
continue
|
|
542
|
+
return head
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def v13(c: Corpus, r: Result) -> None:
|
|
547
|
+
"""Review trace follows review INTENSITY, not document depth.
|
|
548
|
+
|
|
549
|
+
Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
|
|
550
|
+
stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
|
|
551
|
+
"""
|
|
552
|
+
watched = [pc for pc in c.pcs
|
|
553
|
+
if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
|
|
554
|
+
if not watched:
|
|
555
|
+
r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
|
|
556
|
+
targets: list[tuple[Path, set[str]]] = []
|
|
557
|
+
if watched:
|
|
558
|
+
targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
|
|
559
|
+
for pc in watched:
|
|
560
|
+
pid = str(pc.get("id"))
|
|
561
|
+
need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
|
|
562
|
+
# The SRS exists and is meaningful at EVERY mode: it carries the Actor Register and UC
|
|
563
|
+
# Catalogue, and both are born at G3, which the depth knob does not touch.
|
|
564
|
+
targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
|
|
565
|
+
# The SDD is guarded only when it HAS content worth guarding. Two states exempt it, and
|
|
566
|
+
# both are FINISHED states, not neglected ones:
|
|
567
|
+
# mode: catalog the skeleton is its final form; G4 is skipped there
|
|
568
|
+
# g4_passed not set G4 has not run yet, so not one section is written
|
|
569
|
+
# Demanding a review trace on a file whose content is 13 lines of template comments is
|
|
570
|
+
# theater — exactly the ceremony this redesign cut, and a review that cannot fail proves
|
|
571
|
+
# nothing. Once G4 passes, the demand comes back and it is meaningful.
|
|
572
|
+
passed = str(pc.get("g4_passed") or "").strip().lower()
|
|
573
|
+
if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
|
|
574
|
+
targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
|
|
575
|
+
|
|
576
|
+
for path, need in targets:
|
|
577
|
+
fm = frontmatter(path)
|
|
578
|
+
if fm is None:
|
|
579
|
+
continue # not born yet — not V13's business
|
|
580
|
+
rel = path.relative_to(c.root).as_posix()
|
|
581
|
+
_reviewed_ok(r, rel, fm.get("reviewed"), need)
|
|
582
|
+
block = fm.get("reviewed")
|
|
583
|
+
if isinstance(block, dict) and block.get("sha"):
|
|
584
|
+
stale = _stale_since(c, rel, str(block["sha"]))
|
|
585
|
+
if stale:
|
|
586
|
+
r.fail("V13", rel,
|
|
587
|
+
f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]} — "
|
|
588
|
+
f"stale review")
|
|
589
|
+
|
|
590
|
+
for wave in c.wave_list:
|
|
591
|
+
if not wave.get("epics"):
|
|
592
|
+
continue
|
|
593
|
+
_reviewed_ok(r, f"waves.yaml:{wave.get('id')}", wave.get("spec_reviewed"),
|
|
594
|
+
{"edge-case-hunter"})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def cap_stories(c: Corpus) -> dict[str, list[dict]]:
|
|
598
|
+
"""CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
|
|
599
|
+
frs_of: dict[str, list[str]] = {}
|
|
600
|
+
for fr in c.frs:
|
|
601
|
+
frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
|
|
602
|
+
ucs_of: dict[str, list[str]] = {}
|
|
603
|
+
for uc in c.ucs:
|
|
604
|
+
for fid in listy(uc, "satisfies"):
|
|
605
|
+
ucs_of.setdefault(fid, []).append(str(uc.get("id")))
|
|
606
|
+
out: dict[str, list[dict]] = {}
|
|
607
|
+
for cap in c.caps:
|
|
608
|
+
cid = str(cap.get("id"))
|
|
609
|
+
wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
|
|
610
|
+
out[cid] = [s for _, _, s in c.stories()
|
|
611
|
+
if wanted & set(listy(s, "satisfies"))]
|
|
612
|
+
return out
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
616
|
+
"""Overdue-ness is computed from the registry itself — the timeline only reinforces, never gates."""
|
|
617
|
+
by_cap = cap_stories(c)
|
|
618
|
+
timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
|
|
619
|
+
listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
|
|
620
|
+
if str(row.get("state")) == "overdue"} if timeline else None
|
|
621
|
+
if listed is None:
|
|
622
|
+
r.skip("V14", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
|
|
623
|
+
"from the registry, but its presence in generated/report is not checked")
|
|
624
|
+
|
|
625
|
+
for cap in c.caps:
|
|
626
|
+
cid = str(cap.get("id"))
|
|
627
|
+
end = str(cap.get("planned_end") or "")
|
|
628
|
+
if not end:
|
|
629
|
+
continue
|
|
630
|
+
try:
|
|
631
|
+
due = dt.date.fromisoformat(end)
|
|
632
|
+
except ValueError:
|
|
633
|
+
r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
|
|
634
|
+
continue
|
|
635
|
+
items = by_cap.get(cid, [])
|
|
636
|
+
closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
|
|
637
|
+
if closed or due >= asof:
|
|
638
|
+
continue
|
|
639
|
+
late = (asof - due).days
|
|
640
|
+
if listed is not None and cid not in listed:
|
|
641
|
+
r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
|
|
642
|
+
f"`overdue` in generated/timeline")
|
|
643
|
+
else:
|
|
644
|
+
r.fail("V14", cid, f"{late} days overdue with nothing closed")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def v15(c: Corpus, r: Result) -> None:
|
|
648
|
+
for cap in c.caps:
|
|
649
|
+
if not str(cap.get("goal") or "").strip():
|
|
650
|
+
r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
|
|
651
|
+
for fr in c.frs:
|
|
652
|
+
if not str(fr.get("capability") or "").strip():
|
|
653
|
+
r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def v16(c: Corpus, r: Result) -> None:
|
|
657
|
+
for path in sorted((c.root / ".control/memlog").glob("*.md")):
|
|
658
|
+
fm = frontmatter(path) or {}
|
|
659
|
+
rel = path.relative_to(c.root).as_posix()
|
|
660
|
+
artifact = str(fm.get("artifact") or "")
|
|
661
|
+
if not artifact:
|
|
662
|
+
r.fail("V16", rel, "has no `artifact:` in frontmatter")
|
|
663
|
+
elif not (c.root / artifact).exists():
|
|
664
|
+
r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
|
|
665
|
+
for layer in (".what", ".how"):
|
|
666
|
+
for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
|
|
667
|
+
r.fail("V16", stray.relative_to(c.root).as_posix(),
|
|
668
|
+
"a memlog MUST NOT live inside the corpus")
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def v17(c: Corpus, r: Result) -> None:
|
|
672
|
+
for wave in c.wave_list:
|
|
673
|
+
wid = str(wave.get("id"))
|
|
674
|
+
if not str(wave.get("release") or "").strip():
|
|
675
|
+
r.fail("V17", wid, "does not name a `release`")
|
|
676
|
+
slugs = listy(wave, "prd")
|
|
677
|
+
if not slugs:
|
|
678
|
+
r.fail("V17", wid, "does not name a `prd`")
|
|
679
|
+
for slug in slugs:
|
|
680
|
+
if not (c.root / ".what/_prd" / slug).is_dir():
|
|
681
|
+
r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def v18(c: Corpus, r: Result) -> None:
|
|
685
|
+
for _, _, story in c.stories():
|
|
686
|
+
sid = str(story.get("id"))
|
|
687
|
+
folder = str(story.get("spec_folder") or "").strip()
|
|
688
|
+
if not folder:
|
|
689
|
+
r.fail("V18", sid, "does not name a `spec_folder`")
|
|
690
|
+
continue
|
|
691
|
+
matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
|
|
692
|
+
if not matches:
|
|
693
|
+
r.fail("V18", sid, f"has no story file in {folder}stories/")
|
|
694
|
+
continue
|
|
695
|
+
fm = frontmatter(matches[0]) or {}
|
|
696
|
+
if not str(fm.get("status") or "").strip():
|
|
697
|
+
r.fail("V18", sid, "story file has no `status` in frontmatter")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def v19(c: Corpus, r: Result) -> None:
|
|
701
|
+
"""The retrospective archive is tied to WAVE SIZE, not to `mode`.
|
|
702
|
+
|
|
703
|
+
Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
|
|
704
|
+
different things, and demanding a retrospective for a three-story wave is ceremony.
|
|
705
|
+
"""
|
|
706
|
+
names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
|
|
707
|
+
advisory: list[str] = []
|
|
708
|
+
for wave in c.wave_list:
|
|
709
|
+
if str(wave.get("status")) != "closed":
|
|
710
|
+
continue
|
|
711
|
+
wid = str(wave.get("id"))
|
|
712
|
+
if any(wid in name for name in names):
|
|
713
|
+
continue
|
|
714
|
+
if str(wave.get("size")).upper() == "L":
|
|
715
|
+
r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
|
|
716
|
+
else:
|
|
717
|
+
advisory.append(wid)
|
|
718
|
+
if advisory:
|
|
719
|
+
r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
|
|
720
|
+
else:
|
|
721
|
+
r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
PLATFORM = "_platform"
|
|
725
|
+
CROSS_CUTTING = ".how/_platform/cross-cutting.md"
|
|
726
|
+
# The section heading V21 looks for. A heading a SCRIPT matches is a machine-facing key, and
|
|
727
|
+
# `language-guide.md` says a key is always English — so the template writes the English one and
|
|
728
|
+
# this is what a new corpus carries. The Indonesian form is kept as a READER-side alias, exactly
|
|
729
|
+
# like `yes|ya`: a corpus written before this MUST NOT be migrated for a regex.
|
|
730
|
+
PLATFORM_DATA_HEADINGS = ("Platform-owned", "Milik platform")
|
|
731
|
+
PLATFORM_DATA_HEADING = PLATFORM_DATA_HEADINGS[0]
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def v21(c: Corpus, r: Result) -> None:
|
|
735
|
+
"""One domain entity has EXACTLY ONE owner authorized to write it.
|
|
736
|
+
|
|
737
|
+
The owner is a Product Component, OR `_platform` for an entity with no single component
|
|
738
|
+
promise behind it. Semantic collisions across PRDs have already happened for real: one
|
|
739
|
+
component took a business-rule numbering range from a shared global sequence. Two `FR`s
|
|
740
|
+
that both claim write authority over the same entity, with neither pointing at the other,
|
|
741
|
+
are a defect the moment they are written.
|
|
742
|
+
|
|
743
|
+
`_platform` is NOT a Product Component and therefore has no `mode`, `risk_accepted`, SRS,
|
|
744
|
+
or G4. It is a home for ownership, not a domain slice — and so it does not become a dumping
|
|
745
|
+
ground, every entity it claims MUST be explained in `cross-cutting.md`: if the platform
|
|
746
|
+
owns the data, the platform documents it.
|
|
747
|
+
"""
|
|
748
|
+
owner: dict[str, str] = {}
|
|
749
|
+
for pc in c.pcs:
|
|
750
|
+
pid = str(pc.get("id"))
|
|
751
|
+
for entity in listy(pc, "owns"):
|
|
752
|
+
if entity in owner and owner[entity] != pid:
|
|
753
|
+
r.fail("V21", entity,
|
|
754
|
+
f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
|
|
755
|
+
f"have exactly one owner")
|
|
756
|
+
else:
|
|
757
|
+
owner.setdefault(entity, pid)
|
|
758
|
+
|
|
759
|
+
platform = listy(c.components, "platform_owns")
|
|
760
|
+
for entity in platform:
|
|
761
|
+
if entity in owner:
|
|
762
|
+
r.fail("V21", entity,
|
|
763
|
+
f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
|
|
764
|
+
f"`{PLATFORM}` is not a second path for an entity that already has an owner")
|
|
765
|
+
else:
|
|
766
|
+
owner[entity] = PLATFORM
|
|
767
|
+
|
|
768
|
+
_platform_documented(c, r, platform + _platform_inventory_rows(c))
|
|
769
|
+
|
|
770
|
+
cap_home = {str(x.get("id")): str(x.get("component") or "") for x in c.caps}
|
|
771
|
+
for fr in c.frs:
|
|
772
|
+
fid = str(fr.get("id"))
|
|
773
|
+
home = str(fr.get("component") or cap_home.get(str(fr.get("capability", "")), ""))
|
|
774
|
+
for entity in listy(fr, "writes"):
|
|
775
|
+
own = owner.get(entity)
|
|
776
|
+
if not own or not home or own == home:
|
|
777
|
+
continue
|
|
778
|
+
if own == PLATFORM:
|
|
779
|
+
# The platform has no `FR`, so there is nothing a `defers_to` could point to. What
|
|
780
|
+
# stands in for "one writer" here is ONE DOCUMENTED FORM, and that is what
|
|
781
|
+
# _platform_documented checks above.
|
|
782
|
+
continue
|
|
783
|
+
if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
|
|
784
|
+
r.fail("V21", fid,
|
|
785
|
+
f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
|
|
786
|
+
f"pointing to an `FR` owned by that owner")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _platform_inventory_rows(c: Corpus) -> list[str]:
|
|
790
|
+
"""Inventory rows owned by `_platform`, read from `platform_rows:` in each inventory.
|
|
791
|
+
|
|
792
|
+
`_platform` is a valid value at EVERY ownership position, so the guard applies at every
|
|
793
|
+
position too: whatever it owns MUST be documented in `cross-cutting.md`.
|
|
794
|
+
"""
|
|
795
|
+
out: list[str] = []
|
|
796
|
+
for kind in ("db", "api", "screen"):
|
|
797
|
+
path = c.root / f".how/_platform/inventory-{kind}.md"
|
|
798
|
+
fm = frontmatter(path)
|
|
799
|
+
if not fm:
|
|
800
|
+
continue
|
|
801
|
+
out += [str(x) for x in (fm.get("platform_rows") or [])]
|
|
802
|
+
return out
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
|
|
806
|
+
"""Every entity with `platform_owns` MUST be named in `cross-cutting.md`.
|
|
807
|
+
|
|
808
|
+
Skipped while the file does not yet carry that section: `cross-cutting.md` is a G3 output, and
|
|
809
|
+
an artifact the next gate will produce MUST NOT be reported missing.
|
|
810
|
+
"""
|
|
811
|
+
if not entities:
|
|
812
|
+
return
|
|
813
|
+
path = c.root / CROSS_CUTTING
|
|
814
|
+
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
|
815
|
+
if not any(h.lower() in text.lower() for h in PLATFORM_DATA_HEADINGS):
|
|
816
|
+
r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
|
|
817
|
+
f"{len(entities)} entities with platform_owns are not documented yet: "
|
|
818
|
+
+ ", ".join(sorted(entities)))
|
|
819
|
+
return
|
|
820
|
+
for entity in sorted(entities):
|
|
821
|
+
if entity not in text:
|
|
822
|
+
r.fail("V21", entity,
|
|
823
|
+
f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
|
|
824
|
+
f"a platform that owns data MUST document it")
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def v22(c: Corpus, r: Result) -> None:
|
|
828
|
+
"""A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
|
|
829
|
+
|
|
830
|
+
`catalog` skips G4 on purpose, so it is not an exception — it is part of the rule.
|
|
831
|
+
"""
|
|
832
|
+
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
833
|
+
seen: set[tuple[str, str]] = set()
|
|
834
|
+
for wave, _, story in c.stories():
|
|
835
|
+
pid = str(story.get("component") or "")
|
|
836
|
+
row = pc_by_id.get(pid)
|
|
837
|
+
if row is None:
|
|
838
|
+
continue
|
|
839
|
+
key = (str(wave.get("id")), pid)
|
|
840
|
+
if key in seen:
|
|
841
|
+
continue
|
|
842
|
+
seen.add(key)
|
|
843
|
+
mode = c.mode_of(row)
|
|
844
|
+
if mode == "catalog":
|
|
845
|
+
continue
|
|
846
|
+
if mode not in MODES:
|
|
847
|
+
r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
|
|
848
|
+
continue
|
|
849
|
+
passed = row.get("g4_passed")
|
|
850
|
+
if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
|
|
851
|
+
r.fail("V22", f"{wave.get('id')} / {pid}",
|
|
852
|
+
f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def v23(c: Corpus, r: Result) -> None:
|
|
856
|
+
"""`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
|
|
857
|
+
|
|
858
|
+
On a component that touches nothing on that list, `high` is FREE. The control is
|
|
859
|
+
disclosure, not veto — the owner may still choose quickly, just not without knowing what
|
|
860
|
+
they are wagering.
|
|
861
|
+
"""
|
|
862
|
+
known = {str(x.get("id")) for x in c.decs}
|
|
863
|
+
for pc in c.pcs:
|
|
864
|
+
pid = str(pc.get("id"))
|
|
865
|
+
if str(pc.get("risk_accepted") or "").strip() != "high":
|
|
866
|
+
continue
|
|
867
|
+
note = str(pc.get("risk_note") or "").lower()
|
|
868
|
+
hits = sorted({m for m in SENSITIVE_MARKERS if m in note})
|
|
869
|
+
if not hits:
|
|
870
|
+
continue
|
|
871
|
+
ref = str(pc.get("risk_accepted_by") or "").strip()
|
|
872
|
+
if not ref:
|
|
873
|
+
r.fail("V23", pid,
|
|
874
|
+
f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
|
|
875
|
+
f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
|
|
876
|
+
elif ref not in known:
|
|
877
|
+
r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def v20(c: Corpus, r: Result) -> None:
|
|
881
|
+
needs_link = {"requirement", "architecture"}
|
|
882
|
+
for defect in c.defect_list:
|
|
883
|
+
did = str(defect.get("id"))
|
|
884
|
+
cause = str(defect.get("root_cause") or "")
|
|
885
|
+
if cause not in needs_link:
|
|
886
|
+
continue
|
|
887
|
+
if not listy(defect, "violates"):
|
|
888
|
+
r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
|
|
889
|
+
if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
|
|
890
|
+
r.fail("V20", did,
|
|
891
|
+
f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# Files that DESCRIBE the past, not STATE what currently holds. A dangling citation here is
|
|
895
|
+
# not a finding — corpus-guide.md owns that rule, and rewriting it would falsify history.
|
|
896
|
+
PAST_RECORD = (
|
|
897
|
+
".control/memlog/",
|
|
898
|
+
".control/decisions/",
|
|
899
|
+
".control/questions/answered.md",
|
|
900
|
+
".control/reports/",
|
|
901
|
+
)
|
|
902
|
+
# Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
|
|
903
|
+
FROZEN = (".what/",)
|
|
904
|
+
# Derived output. A finding here is UNACTIONABLE by construction — the folder MUST NOT be written
|
|
905
|
+
# by hand, so nobody may fix it where it is reported. It also renders registry values inside
|
|
906
|
+
# backticks, which makes a frozen `DEC-` `touches:` entry look like a live citation: the 0.5.0
|
|
907
|
+
# layout move surfaced three of those, all of them correct history. Fix the source or leave it.
|
|
908
|
+
DERIVED = (".control/generated/",)
|
|
909
|
+
# A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
|
|
910
|
+
# memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
|
|
911
|
+
# happened.
|
|
912
|
+
DESTINATION = (
|
|
913
|
+
".control/memlog/",
|
|
914
|
+
".control/meetings/",
|
|
915
|
+
".control/reports/",
|
|
916
|
+
"_bmad-output/",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
# Material the INSTALLER wrote, which this product neither authored nor may edit.
|
|
920
|
+
#
|
|
921
|
+
# `.constitution/method/` is portable explanation. Its citations teach where a thing GOES — "the
|
|
922
|
+
# glossary lives at `.control/product-glossary.md`" — and are not this product's claim that it has
|
|
923
|
+
# one yet. Scanning it made V24 unsatisfiable in both directions: a fresh install went RED on 69
|
|
924
|
+
# such lines before G1 had run, and a mature one stayed quiet only by accident. A method guide that
|
|
925
|
+
# cites a method file IS checked, but here in the package where it can be fixed — see
|
|
926
|
+
# tests/kit-integrity.test.mjs. A product cannot fix a guide `update` overwrites.
|
|
927
|
+
#
|
|
928
|
+
# The BMad skill trees are the same class under whichever host the installer wrote them to. Both
|
|
929
|
+
# hosts MUST be listed: `.claude/skills/bmad-` alone left the `.agents/` copy of one identical
|
|
930
|
+
# template failing, which reads as a defect in that product rather than an omission here.
|
|
931
|
+
#
|
|
932
|
+
# `wdi-*` skills are OURS and are deliberately NOT here. They MUST NOT cite a product file that
|
|
933
|
+
# does not exist unless the cite is a placeholder.
|
|
934
|
+
INSTALLED = (
|
|
935
|
+
".constitution/method/",
|
|
936
|
+
".claude/skills/bmad-",
|
|
937
|
+
".agents/skills/bmad-",
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# The extension list is deliberately WIDE. A narrow one does not make V24 safer — it makes it
|
|
941
|
+
# silent: a product written in a language missing from the list has its code citations
|
|
942
|
+
# unchecked, and nothing says so. Adding one is cheap; a gap is invisible.
|
|
943
|
+
CITE_RE = re.compile(
|
|
944
|
+
r"`((?:\.constitution|\.control|\.what|\.how|_bmad-output|\.work|src|web|public|deploy)"
|
|
945
|
+
r"/[A-Za-z0-9_./-]+\.(?:md|txt|yaml|yml|toml|json|sql|html|css|scss|"
|
|
946
|
+
r"py|go|rs|rb|php|java|kt|cs|swift|ts|tsx|js|jsx|mjs|cjs|vue|svelte|ex|exs))`")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
# Directories that MUST be pruned DURING traversal, not filtered afterwards.
|
|
950
|
+
#
|
|
951
|
+
# The old form was `c.root.rglob("*.md")` plus a `rel.startswith(...)` filter, and it had two faults
|
|
952
|
+
# that only showed up on a real machine:
|
|
953
|
+
#
|
|
954
|
+
# The filter ran too late. rglob had already walked in, so a dangling symlink inside
|
|
955
|
+
# node_modules — an npm workspace link left behind by an abandoned git worktree — raised
|
|
956
|
+
# FileNotFoundError and took the whole run down. A validator that CRASHES on somebody's build
|
|
957
|
+
# output reports nothing about the corpus at all.
|
|
958
|
+
#
|
|
959
|
+
# `node_modules/` matched only at the ROOT. `web/node_modules/` sailed straight through, which is
|
|
960
|
+
# where a monorepo actually keeps it.
|
|
961
|
+
PRUNE_DIRS = frozenset({
|
|
962
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build",
|
|
963
|
+
".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".turbo", ".idea", ".vscode",
|
|
964
|
+
"worktrees", # .claude/worktrees/ — another checkout's tree is not this corpus
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
969
|
+
"""Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS as it goes.
|
|
970
|
+
|
|
971
|
+
Sorted because determinism is this script's contract: two runs over the same tree MUST report the
|
|
972
|
+
same thing in the same order.
|
|
973
|
+
"""
|
|
974
|
+
out: list[Path] = []
|
|
975
|
+
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
|
|
976
|
+
dirnames[:] = sorted(d for d in dirnames if d not in PRUNE_DIRS)
|
|
977
|
+
for name in filenames:
|
|
978
|
+
if name.endswith(suffixes):
|
|
979
|
+
out.append(Path(dirpath) / name)
|
|
980
|
+
return sorted(out)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def v24(c: Corpus, r: Result) -> None:
|
|
984
|
+
"""A path citation inside a document that STATES what currently holds MUST resolve.
|
|
985
|
+
|
|
986
|
+
This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
|
|
987
|
+
that a migration stayed complete. Its failure class is distinctive: a file gets deleted or moved,
|
|
988
|
+
while the routing line that points at it stays behind — no other validator sees it, because no
|
|
989
|
+
id moved.
|
|
990
|
+
|
|
991
|
+
Deliberately SKIPPED: files that describe the past, corpus that has been frozen, derived
|
|
992
|
+
output, and material the installer wrote (see INSTALLED). A `DEC-` Trace that names material that has since been retired describes what was read on
|
|
993
|
+
that date; reporting it would demand history be rewritten to match the present. Derived output is
|
|
994
|
+
skipped for a second reason on top of that: it MUST NOT be edited by hand, so a finding reported
|
|
995
|
+
there names a file nobody is allowed to fix.
|
|
996
|
+
"""
|
|
997
|
+
scanned = 0
|
|
998
|
+
for path in _walk_corpus(c.root, (".md", ".yaml")):
|
|
999
|
+
rel = path.relative_to(c.root).as_posix()
|
|
1000
|
+
if rel.startswith("_bmad-output/") or rel.startswith(INSTALLED):
|
|
1001
|
+
continue
|
|
1002
|
+
if rel.startswith(PAST_RECORD) or rel.startswith(FROZEN) or rel.startswith(DERIVED):
|
|
1003
|
+
continue
|
|
1004
|
+
scanned += 1
|
|
1005
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1006
|
+
for cited in sorted(set(CITE_RE.findall(text))):
|
|
1007
|
+
if "<" in cited or "{" in cited:
|
|
1008
|
+
continue # placeholder, not a path
|
|
1009
|
+
if cited.startswith(DESTINATION):
|
|
1010
|
+
continue
|
|
1011
|
+
if not (c.root / cited).exists():
|
|
1012
|
+
r.fail("V24", rel, f"cites `{cited}` which does not exist")
|
|
1013
|
+
if not scanned:
|
|
1014
|
+
r.skip("V24", "no file was scanned")
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def map_container_headings(root: Path) -> list[str] | None:
|
|
1021
|
+
"""Heading `### x` under `## Containers` in the code map. None if the map does not exist."""
|
|
1022
|
+
path = root / ".control" / "structure-codebase.md"
|
|
1023
|
+
if not path.exists():
|
|
1024
|
+
return None
|
|
1025
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1026
|
+
start = text.find("\n## Containers")
|
|
1027
|
+
if start < 0:
|
|
1028
|
+
return []
|
|
1029
|
+
rest = text[start + 1:]
|
|
1030
|
+
nxt = re.search(r"^##\s+(?!#)", rest[len("## Containers"):], re.M)
|
|
1031
|
+
if nxt:
|
|
1032
|
+
rest = rest[:len("## Containers") + nxt.start()]
|
|
1033
|
+
return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def v25(c: Corpus, r: Result) -> None:
|
|
1037
|
+
"""A container's `built` and its four consequences, plus the PC x container matrix.
|
|
1038
|
+
|
|
1039
|
+
A container EXISTS inside the boundary whether or not we write its content, and that is what
|
|
1040
|
+
used to make the rule unsatisfiable: `structure-guide.md` demands every code-map heading match
|
|
1041
|
+
the registry, while a database or web server MUST be registered and MUST NOT have a heading.
|
|
1042
|
+
`built` separates the two, and this check is what makes that separation hold instead of the
|
|
1043
|
+
argument being repeated on every project. `DEC-017` records its definition.
|
|
1044
|
+
|
|
1045
|
+
Anything whose runtime we do not deploy is an external system: it lives in C4 L1 and MUST NOT
|
|
1046
|
+
be registered here at all — its absence from the registry is the check.
|
|
1047
|
+
"""
|
|
1048
|
+
containers = rows(c.components, "containers")
|
|
1049
|
+
if not containers:
|
|
1050
|
+
r.skip("V25", "`containers:` is not registered yet")
|
|
1051
|
+
return
|
|
1052
|
+
|
|
1053
|
+
built: dict[str, bool] = {}
|
|
1054
|
+
for ctr in containers:
|
|
1055
|
+
cid = str(ctr.get("id") or "").strip()
|
|
1056
|
+
if not cid:
|
|
1057
|
+
r.fail("V25", "containers", "a container has no `id`")
|
|
1058
|
+
continue
|
|
1059
|
+
flag = ctr.get("built")
|
|
1060
|
+
if not isinstance(flag, bool):
|
|
1061
|
+
r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
|
|
1062
|
+
continue
|
|
1063
|
+
built[cid] = flag
|
|
1064
|
+
|
|
1065
|
+
# (1) code-map heading = EXACTLY a container with `built: true`
|
|
1066
|
+
headings = map_container_headings(c.root)
|
|
1067
|
+
if headings is None:
|
|
1068
|
+
r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
|
|
1069
|
+
else:
|
|
1070
|
+
for h in headings:
|
|
1071
|
+
if h not in built:
|
|
1072
|
+
r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
|
|
1073
|
+
elif not built[h]:
|
|
1074
|
+
r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
|
|
1075
|
+
for cid, flag in sorted(built.items()):
|
|
1076
|
+
if flag and cid not in headings:
|
|
1077
|
+
r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
|
|
1078
|
+
|
|
1079
|
+
# (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
|
|
1080
|
+
for lc in c.lcs:
|
|
1081
|
+
ctr = str(lc.get("container") or "").strip()
|
|
1082
|
+
if ctr and built.get(ctr) is False:
|
|
1083
|
+
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
|
|
1084
|
+
elif ctr and ctr not in built:
|
|
1085
|
+
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
|
|
1086
|
+
|
|
1087
|
+
# (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
|
|
1088
|
+
for pc in c.pcs:
|
|
1089
|
+
pid = str(pc.get("id") or "?")
|
|
1090
|
+
listed = listy(pc, "containers")
|
|
1091
|
+
if not listed:
|
|
1092
|
+
r.fail("V25", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
|
|
1093
|
+
continue
|
|
1094
|
+
for ctr in listed:
|
|
1095
|
+
if ctr not in built:
|
|
1096
|
+
r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
|
|
1097
|
+
elif not built[ctr]:
|
|
1098
|
+
r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
|
|
1099
|
+
|
|
1100
|
+
# (5) L3 — only for `built: true`, and only ones that hold more than one PC
|
|
1101
|
+
pcs_per: dict[str, list[str]] = {}
|
|
1102
|
+
for pc in c.pcs:
|
|
1103
|
+
for ctr in listy(pc, "containers"):
|
|
1104
|
+
pcs_per.setdefault(ctr, []).append(str(pc.get("id") or "?"))
|
|
1105
|
+
for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
|
|
1106
|
+
cid = path.name[len("c4-l3-"):-len(".md")]
|
|
1107
|
+
if cid not in built:
|
|
1108
|
+
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1109
|
+
f"L3 for `{cid}` which is not a registered container")
|
|
1110
|
+
elif not built[cid]:
|
|
1111
|
+
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1112
|
+
f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
|
|
1113
|
+
for cid, pids in sorted(pcs_per.items()):
|
|
1114
|
+
if built.get(cid) and len(pids) > 1:
|
|
1115
|
+
l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
|
|
1116
|
+
if not l3.exists():
|
|
1117
|
+
r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
|
|
1121
|
+
|
|
1122
|
+
# The `critical` column value is machine-matched, so it is machine-facing and its canonical form
|
|
1123
|
+
# is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
|
|
1124
|
+
# MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
|
|
1125
|
+
# matching inside other words.
|
|
1126
|
+
CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def v26(c: Corpus, r: Result) -> None:
|
|
1130
|
+
"""The UC catalogue in every SRS MUST agree with `usecases.yaml` — both its id AND its `critical`.
|
|
1131
|
+
|
|
1132
|
+
This is the most expensive gap this pass closes, because it is the only one that **had already
|
|
1133
|
+
happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
|
|
1134
|
+
narrowed definition — money, personal data, irreversible action — and the seven catalogue tables
|
|
1135
|
+
in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
|
|
1136
|
+
discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
|
|
1137
|
+
registry held three.
|
|
1138
|
+
|
|
1139
|
+
The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
|
|
1140
|
+
and two homes for one fact are only safe if something compares them. This is what compares them.
|
|
1141
|
+
|
|
1142
|
+
What is NOT checked here: title and actor. Both are prose, and prose with different words is
|
|
1143
|
+
not prose with a different meaning — comparing them would report style as a defect.
|
|
1144
|
+
"""
|
|
1145
|
+
reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
|
|
1146
|
+
reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
|
|
1147
|
+
checked = 0
|
|
1148
|
+
for pc in c.pcs:
|
|
1149
|
+
pid = str(pc.get("id"))
|
|
1150
|
+
path = c.root / f".what/{pid}/SRS-{pid}.md"
|
|
1151
|
+
if not path.exists():
|
|
1152
|
+
continue
|
|
1153
|
+
checked += 1
|
|
1154
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1155
|
+
seen: set[str] = set()
|
|
1156
|
+
for match in UC_ROW_RE.finditer(text):
|
|
1157
|
+
uid = match.group(1)
|
|
1158
|
+
cells = [x.strip() for x in match.group(2).split("|")]
|
|
1159
|
+
if len(cells) < 4:
|
|
1160
|
+
continue
|
|
1161
|
+
seen.add(uid)
|
|
1162
|
+
if uid not in reg:
|
|
1163
|
+
r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
|
|
1164
|
+
continue
|
|
1165
|
+
if reg_pc[uid] != pid:
|
|
1166
|
+
r.fail("V26", f"{pid}/{uid}",
|
|
1167
|
+
f"the registry places it in `{reg_pc[uid]}`, not in this component")
|
|
1168
|
+
marked = CRITICAL_YES.search(cells[3]) is not None
|
|
1169
|
+
if marked != reg[uid]:
|
|
1170
|
+
r.fail("V26", f"{pid}/{uid}",
|
|
1171
|
+
f"`critical` in the SRS {'yes' if marked else 'no'}, "
|
|
1172
|
+
f"in the registry {'yes' if reg[uid] else 'no'}")
|
|
1173
|
+
for uid, owner in sorted(reg_pc.items()):
|
|
1174
|
+
if owner == pid and uid not in seen:
|
|
1175
|
+
r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
|
|
1176
|
+
if not checked:
|
|
1177
|
+
r.skip("V26", "no SRS could be read")
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def v27(c: Corpus, r: Result) -> None:
|
|
1181
|
+
"""Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
|
|
1182
|
+
|
|
1183
|
+
The `.constitution/project/` room exists so product-specific rules have a home that `update`
|
|
1184
|
+
does not overwrite and `promote` does not publish. The cost that comes with it: it is also the
|
|
1185
|
+
easiest place to break a generic rule without a trace. Its frontmatter is what holds that back.
|
|
1186
|
+
|
|
1187
|
+
A file here MAY narrow or add without naming anything. To REBUT a generic rule it MUST name it
|
|
1188
|
+
in `overrides:` and carry a `decision:` — because a method that can be rebutted without a
|
|
1189
|
+
decision stops being trustworthy in the next repo.
|
|
1190
|
+
|
|
1191
|
+
Four files in the room are STRUCTURAL and are skipped, because they are not ad-hoc rules and
|
|
1192
|
+
carry their own frontmatter conventions instead:
|
|
1193
|
+
|
|
1194
|
+
README.md authored in the package, not in the product
|
|
1195
|
+
constitution.md Articles 1, 2, 5 — carries `status:`, and Article 4 governs it
|
|
1196
|
+
codebase-*-guide.md the stack, conventions, and brownfield guides — `status:` plus
|
|
1197
|
+
`ratified_by:`, and they are filled by a wave's distillation
|
|
1198
|
+
|
|
1199
|
+
Demanding `scope:` and `purpose:` of those would be demanding a declaration of files whose
|
|
1200
|
+
role is already fixed by the layout. What V27 exists to guard is the file somebody ADDS.
|
|
1201
|
+
|
|
1202
|
+
Only `.md` is looked at. A script in the room — `inventory-readers.py` is the one the package
|
|
1203
|
+
seeds — is not an ad-hoc rule and has nowhere to put frontmatter.
|
|
1204
|
+
"""
|
|
1205
|
+
room = c.root / ".constitution" / "project"
|
|
1206
|
+
if not room.is_dir():
|
|
1207
|
+
r.skip("V27", "the `.constitution/project/` room does not exist yet — it is seeded at install")
|
|
1208
|
+
return
|
|
1209
|
+
structural = {"README.md", "constitution.md"}
|
|
1210
|
+
files = [p for p in sorted(room.rglob("*.md"))
|
|
1211
|
+
if p.name not in structural and not p.name.startswith("codebase-")]
|
|
1212
|
+
if not files:
|
|
1213
|
+
r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
|
|
1214
|
+
"a generic rule MUST NOT be moved here just to give the room content")
|
|
1215
|
+
return
|
|
1216
|
+
dec_ids = {str(d.get("id")) for d in c.decs}
|
|
1217
|
+
for path in files:
|
|
1218
|
+
rel = path.relative_to(c.root).as_posix()
|
|
1219
|
+
fm = frontmatter(path)
|
|
1220
|
+
if fm is None:
|
|
1221
|
+
r.fail("V27", rel, "has no frontmatter")
|
|
1222
|
+
continue
|
|
1223
|
+
if str(fm.get("scope") or "").strip() != "project":
|
|
1224
|
+
r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
|
|
1225
|
+
if not str(fm.get("purpose") or "").strip():
|
|
1226
|
+
r.fail("V27", rel, "`purpose:` is empty — one line: what this rule guards")
|
|
1227
|
+
over = str(fm.get("overrides") or "").strip()
|
|
1228
|
+
dec = str(fm.get("decision") or "").strip()
|
|
1229
|
+
if over:
|
|
1230
|
+
if not (c.root / over).exists():
|
|
1231
|
+
r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
|
|
1232
|
+
f"the rebutted rule may already be gone")
|
|
1233
|
+
if not dec:
|
|
1234
|
+
r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
|
|
1235
|
+
"a rebuttal MUST have a `DEC-` that decided it")
|
|
1236
|
+
elif dec not in dec_ids:
|
|
1237
|
+
r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
|
|
1238
|
+
elif dec:
|
|
1239
|
+
r.fail("V27", rel, "`decision:` is set without `overrides:` — "
|
|
1240
|
+
"name which rule is rebutted, or drop `decision:`")
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def run_checks(c: Corpus, asof: dt.date) -> Result:
|
|
1244
|
+
r = Result()
|
|
1245
|
+
for fn in (v1, v2, v3, v4, v5, v6, v7, v8, v9, v11, v12, v13, v15, v16, v17, v18, v19, v20,
|
|
1246
|
+
v21, v22, v23, v24, v25, v26, v27):
|
|
1247
|
+
fn(c, r)
|
|
1248
|
+
v14(c, r, asof)
|
|
1249
|
+
return r
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
# ------------------------------------------------------------------ generator
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _story_status(c: Corpus, story: dict) -> str:
|
|
1256
|
+
folder = str(story.get("spec_folder") or "").strip()
|
|
1257
|
+
if not folder:
|
|
1258
|
+
return "unknown"
|
|
1259
|
+
matches = sorted((c.root / folder / "stories").glob(f"{story.get('id')}-*.md"))
|
|
1260
|
+
if not matches:
|
|
1261
|
+
return "unknown"
|
|
1262
|
+
return str((frontmatter(matches[0]) or {}).get("status") or "unknown")
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def gen_components(c: Corpus) -> dict:
|
|
1266
|
+
return {
|
|
1267
|
+
"product_components": [
|
|
1268
|
+
{"id": pc.get("id"), "name": pc.get("name"),
|
|
1269
|
+
"containers": listy(pc, "containers"),
|
|
1270
|
+
"logical_components": sorted(
|
|
1271
|
+
str(lc.get("id")) for lc in c.lcs
|
|
1272
|
+
if str(lc.get("component")) == str(pc.get("id")))}
|
|
1273
|
+
for pc in c.pcs
|
|
1274
|
+
],
|
|
1275
|
+
"logical_components": [
|
|
1276
|
+
{"id": lc.get("id"), "type": lc.get("type"), "component": lc.get("component"),
|
|
1277
|
+
"area": lc.get("area"), "owner": lc.get("owner")}
|
|
1278
|
+
for lc in c.lcs
|
|
1279
|
+
],
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def gen_risks(c: Corpus) -> dict:
|
|
1284
|
+
return {"risks": [
|
|
1285
|
+
{"id": x.get("id"), "impact": x.get("impact"), "likelihood": x.get("likelihood"),
|
|
1286
|
+
"owner": x.get("owner"), "status": x.get("status"),
|
|
1287
|
+
"pivot_trigger": x.get("pivot_trigger")}
|
|
1288
|
+
for x in rows(c.risks, "risks") if str(x.get("status")) != "closed"
|
|
1289
|
+
]}
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def gen_dag(c: Corpus) -> dict:
|
|
1293
|
+
out = []
|
|
1294
|
+
per_wave: dict[str, list[dict]] = {}
|
|
1295
|
+
for wave, _, story in c.stories():
|
|
1296
|
+
per_wave.setdefault(str(wave.get("id")), []).append(story)
|
|
1297
|
+
for wid in sorted(per_wave):
|
|
1298
|
+
items = per_wave[wid]
|
|
1299
|
+
done: set[str] = set()
|
|
1300
|
+
pending = {str(s.get("id")): set(listy(s, "depends_on")) for s in items}
|
|
1301
|
+
waves_out = []
|
|
1302
|
+
while pending:
|
|
1303
|
+
ready = sorted(k for k, deps in pending.items() if not (deps - done))
|
|
1304
|
+
if not ready: # cycle — V7 has already reported it
|
|
1305
|
+
waves_out.append({"blocked": sorted(pending)})
|
|
1306
|
+
break
|
|
1307
|
+
waves_out.append({"parallel": ready})
|
|
1308
|
+
done |= set(ready)
|
|
1309
|
+
for k in ready:
|
|
1310
|
+
pending.pop(k)
|
|
1311
|
+
out.append({"wave": wid, "order": waves_out})
|
|
1312
|
+
return {"dag": out}
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def gen_rtm(c: Corpus) -> dict:
|
|
1316
|
+
cap_goal = {str(x.get("id")): str(x.get("goal", "")) for x in c.caps}
|
|
1317
|
+
ucs_for_fr: dict[str, list[str]] = {}
|
|
1318
|
+
for uc in c.ucs:
|
|
1319
|
+
for fr in listy(uc, "satisfies"):
|
|
1320
|
+
ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
|
|
1321
|
+
stories_for_uc: dict[str, list[tuple[dict, dict]]] = {}
|
|
1322
|
+
for wave, _, story in c.stories():
|
|
1323
|
+
for uc in listy(story, "satisfies"):
|
|
1324
|
+
stories_for_uc.setdefault(uc, []).append((wave, story))
|
|
1325
|
+
decs_for: dict[str, list[str]] = {}
|
|
1326
|
+
for dec in c.decs:
|
|
1327
|
+
for target in listy(dec, "serves"):
|
|
1328
|
+
decs_for.setdefault(target, []).append(str(dec.get("id")))
|
|
1329
|
+
|
|
1330
|
+
lines = []
|
|
1331
|
+
for fr in c.frs:
|
|
1332
|
+
fid = str(fr.get("id"))
|
|
1333
|
+
cap = str(fr.get("capability", ""))
|
|
1334
|
+
base = {"BG": cap_goal.get(cap, ""), "CAP": cap, "FR": fid,
|
|
1335
|
+
"DEC": sorted(decs_for.get(fid, []))}
|
|
1336
|
+
ucs = sorted(ucs_for_fr.get(fid, []))
|
|
1337
|
+
if not ucs:
|
|
1338
|
+
exempt = bool(str(fr.get("no_uc") or "").strip())
|
|
1339
|
+
lines.append({**base, "UC": "", "story": "", "wave": "", "release": "",
|
|
1340
|
+
"test": [], "status": "", "green": False,
|
|
1341
|
+
"exempt": exempt,
|
|
1342
|
+
"broken_at": "no_uc" if exempt else "UC"})
|
|
1343
|
+
continue
|
|
1344
|
+
for uid in ucs:
|
|
1345
|
+
pairs = sorted(stories_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
|
|
1346
|
+
if not pairs:
|
|
1347
|
+
lines.append({**base, "UC": uid, "story": "", "wave": "", "release": "",
|
|
1348
|
+
"test": [], "status": "", "green": False, "exempt": False,
|
|
1349
|
+
"broken_at": "story"})
|
|
1350
|
+
continue
|
|
1351
|
+
for wave, story in pairs:
|
|
1352
|
+
status = _story_status(c, story)
|
|
1353
|
+
tests = listy(story, "tests")
|
|
1354
|
+
broken = ""
|
|
1355
|
+
if not tests:
|
|
1356
|
+
broken = "test"
|
|
1357
|
+
elif status != "done":
|
|
1358
|
+
broken = "status"
|
|
1359
|
+
lines.append({**base, "UC": uid, "story": str(story.get("id")),
|
|
1360
|
+
"wave": str(wave.get("id")), "release": str(wave.get("release", "")),
|
|
1361
|
+
"test": tests, "status": status, "exempt": False,
|
|
1362
|
+
"green": broken == "", "broken_at": broken})
|
|
1363
|
+
return {"rtm": lines}
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
|
|
1367
|
+
lines = rtm.get("rtm") or []
|
|
1368
|
+
counted = [line for line in lines if not line.get("exempt")]
|
|
1369
|
+
exempt = len(lines) - len(counted)
|
|
1370
|
+
green = sum(1 for line in counted if line.get("green"))
|
|
1371
|
+
per_wave = []
|
|
1372
|
+
for wave in c.wave_list:
|
|
1373
|
+
wid = str(wave.get("id"))
|
|
1374
|
+
items = [s for w, _, s in c.stories() if str(w.get("id")) == wid]
|
|
1375
|
+
done = sum(1 for s in items if _story_status(c, s) == "done")
|
|
1376
|
+
per_wave.append({"wave": wid, "status": wave.get("status"),
|
|
1377
|
+
"stories_done": done, "stories_total": len(items),
|
|
1378
|
+
"work_progress": _pct(done, len(items))})
|
|
1379
|
+
applicable = 26 # V1..V27 minus V10, which was retired
|
|
1380
|
+
return {
|
|
1381
|
+
"promise_progress": _pct(green, len(counted)),
|
|
1382
|
+
"rtm_rows": {"green": green, "counted": len(counted),
|
|
1383
|
+
"excluded_no_uc": exempt},
|
|
1384
|
+
"work_progress": per_wave,
|
|
1385
|
+
"gate_readiness": _pct(applicable - len(result.red), applicable),
|
|
1386
|
+
"validators_red": result.red,
|
|
1387
|
+
"validators_skipped": dict(sorted(result.skipped.items())),
|
|
1388
|
+
"open_questions": _question_budget(c),
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _question_budget(c: Corpus) -> dict:
|
|
1393
|
+
"""Counts of all four question lists, compared against the budget in index.yaml.
|
|
1394
|
+
|
|
1395
|
+
The budget is NOT a hard gate. It is reported when a batch exceeds it, because a larger
|
|
1396
|
+
batch is a signal about the pass, not about the corpus.
|
|
1397
|
+
"""
|
|
1398
|
+
budget = c.index.get("question_budget") or {}
|
|
1399
|
+
out: dict[str, object] = {}
|
|
1400
|
+
for name in ("blocking", "assumptions", "external", "answered"):
|
|
1401
|
+
path = c.root / ".control/questions" / f"{name}.md"
|
|
1402
|
+
rows_n = 0
|
|
1403
|
+
if path.exists():
|
|
1404
|
+
rows_n = sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1405
|
+
if line.startswith("| OQ-"))
|
|
1406
|
+
out[name] = rows_n
|
|
1407
|
+
cap_block = budget.get("blocking_per_component")
|
|
1408
|
+
if cap_block and c.pcs:
|
|
1409
|
+
allowed = int(cap_block) * len(c.pcs)
|
|
1410
|
+
out["blocking_budget"] = allowed
|
|
1411
|
+
out["blocking_over_budget"] = out["blocking"] > allowed
|
|
1412
|
+
cap_assume = budget.get("assumptions_per_gate")
|
|
1413
|
+
if cap_assume:
|
|
1414
|
+
out["assumptions_budget_per_gate"] = int(cap_assume)
|
|
1415
|
+
return out
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _pct(part: int, total: int) -> str:
|
|
1419
|
+
return "n/a" if total == 0 else f"{round(100 * part / total)}%"
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def as_markdown(name: str, payload: dict) -> str:
|
|
1423
|
+
body = dump(payload)
|
|
1424
|
+
return (f"# {name}\n\n"
|
|
1425
|
+
f"> Generated by `.constitution/method/scripts/validate.py --generate`. "
|
|
1426
|
+
f"MUST NOT be hand-edited.\n\n"
|
|
1427
|
+
f"```yaml\n{body}```\n")
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
# ------------------------------------------------------- pages for humans
|
|
1431
|
+
|
|
1432
|
+
PAGE_HEADER = ("> Generated by `.constitution/method/scripts/validate.py --generate`. "
|
|
1433
|
+
"MUST NOT be hand-edited.\n")
|
|
1434
|
+
|
|
1435
|
+
|
|
1436
|
+
def _section(path: Path, heading: str) -> str:
|
|
1437
|
+
"""Extract one `## <heading>` section from a markdown file, as-is."""
|
|
1438
|
+
if not path.exists():
|
|
1439
|
+
return ""
|
|
1440
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1441
|
+
out: list[str] = []
|
|
1442
|
+
inside = False
|
|
1443
|
+
for line in lines:
|
|
1444
|
+
if line.startswith("## "):
|
|
1445
|
+
if inside:
|
|
1446
|
+
break
|
|
1447
|
+
inside = line[3:].strip().lower().startswith(heading.lower())
|
|
1448
|
+
continue
|
|
1449
|
+
if inside:
|
|
1450
|
+
out.append(line)
|
|
1451
|
+
return "\n".join(out).strip("\n")
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _body(path: Path) -> str:
|
|
1455
|
+
"""File content without frontmatter and without template comments."""
|
|
1456
|
+
if not path.exists():
|
|
1457
|
+
return ""
|
|
1458
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1459
|
+
match = FM.match(text)
|
|
1460
|
+
if match:
|
|
1461
|
+
text = text[match.end():]
|
|
1462
|
+
while "<!--" in text and "-->" in text:
|
|
1463
|
+
head, _, rest = text.partition("<!--")
|
|
1464
|
+
_, _, tail = rest.partition("-->")
|
|
1465
|
+
text = head + tail
|
|
1466
|
+
return text.strip("\n")
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def page_decisions(c: Corpus) -> str:
|
|
1470
|
+
"""Flat table of every `DEC-`. This is what replaces looking up decisions through the memlog."""
|
|
1471
|
+
rows_out = ["| id | Title | Status | Type | Touches | File |",
|
|
1472
|
+
"| --- | --- | --- | --- | --- | --- |"]
|
|
1473
|
+
for dec in c.decs:
|
|
1474
|
+
touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
|
|
1475
|
+
rows_out.append(
|
|
1476
|
+
f"| `{dec.get('id')}` | {_cell(dec.get('title'))} | `{dec.get('status', '')}` "
|
|
1477
|
+
f"| {dec.get('type') or '—'} | {touches} | `{dec.get('file', '')}` |")
|
|
1478
|
+
counts: dict[str, int] = {}
|
|
1479
|
+
for dec in c.decs:
|
|
1480
|
+
key = str(dec.get("status"))
|
|
1481
|
+
counts[key] = counts.get(key, 0) + 1
|
|
1482
|
+
tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "no decisions yet"
|
|
1483
|
+
return ("# decisions\n\n" + PAGE_HEADER +
|
|
1484
|
+
"\nDecisions are no longer looked up through the memlog — the memlog goes back to being just a pass log.\n"
|
|
1485
|
+
f"\n**{len(c.decs)} decisions** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def page_blueprint(c: Corpus) -> str:
|
|
1489
|
+
"""One-page roll-up reviewed at G3. Seven files become one read.
|
|
1490
|
+
|
|
1491
|
+
The UC catalogue, actor list, and domain model stay put in their own component's kernel as
|
|
1492
|
+
their permanent home. This is their view. One fact, one home, one view.
|
|
1493
|
+
"""
|
|
1494
|
+
parts = ["# blueprint\n", PAGE_HEADER,
|
|
1495
|
+
"\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
|
|
1496
|
+
"content is affected by neither `mode` nor `risk_accepted`.\n"]
|
|
1497
|
+
|
|
1498
|
+
crit = sum(1 for uc in c.ucs if uc.get("critical"))
|
|
1499
|
+
parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
|
|
1500
|
+
f"`critical`.\n")
|
|
1501
|
+
parts.append("| id | Use case | Component | Satisfies | critical |")
|
|
1502
|
+
parts.append("| --- | --- | --- | --- | --- |")
|
|
1503
|
+
for uc in c.ucs:
|
|
1504
|
+
sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
|
|
1505
|
+
flag = "yes" if uc.get("critical") else "no"
|
|
1506
|
+
parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
|
|
1507
|
+
f"`{uc.get('component', '')}` | {sat} | {flag} |")
|
|
1508
|
+
|
|
1509
|
+
parts.append("\n## Actor list\n")
|
|
1510
|
+
for pc in c.pcs:
|
|
1511
|
+
pid = str(pc.get("id"))
|
|
1512
|
+
block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
|
|
1513
|
+
parts.append(f"\n### {pid} — {pc.get('name', '')}\n")
|
|
1514
|
+
parts.append(_demote(block) if block
|
|
1515
|
+
else "_no § Actor Register in this component's SRS yet._")
|
|
1516
|
+
|
|
1517
|
+
parts.append("\n## Domain model\n")
|
|
1518
|
+
for pc in c.pcs:
|
|
1519
|
+
pid = str(pc.get("id"))
|
|
1520
|
+
block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
|
|
1521
|
+
parts.append(f"\n### {pid}\n")
|
|
1522
|
+
parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
|
|
1523
|
+
|
|
1524
|
+
parts.append("\n## Three inventories\n")
|
|
1525
|
+
for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
|
|
1526
|
+
block = _body(c.root / f".how/_platform/inventory-{kind}.md")
|
|
1527
|
+
parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
|
|
1528
|
+
parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
|
|
1529
|
+
|
|
1530
|
+
return "\n".join(parts) + "\n"
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
def _cell(value: object, limit: int = 110) -> str:
|
|
1534
|
+
"""One table row, shortened. The full-length source stays in the registry — this is just a view."""
|
|
1535
|
+
text = " ".join(str(value or "").split()).replace("|", "\\|")
|
|
1536
|
+
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def _demote(block: str, by: int = 2) -> str:
|
|
1540
|
+
"""Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
|
|
1541
|
+
out = []
|
|
1542
|
+
for line in block.splitlines():
|
|
1543
|
+
stripped = line.lstrip()
|
|
1544
|
+
if stripped.startswith("#"):
|
|
1545
|
+
hashes = len(stripped) - len(stripped.lstrip("#"))
|
|
1546
|
+
out.append("#" * min(6, hashes + by) + stripped[hashes:])
|
|
1547
|
+
else:
|
|
1548
|
+
out.append(line)
|
|
1549
|
+
return "\n".join(out)
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def page_estimate(c: Corpus) -> str:
|
|
1553
|
+
"""Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
|
|
1554
|
+
mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
|
|
1555
|
+
risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
|
|
1556
|
+
str(pc.get("risk_note") or "—")) for pc in c.pcs}
|
|
1557
|
+
cap_by_id = {str(x.get("id")): x for x in c.caps}
|
|
1558
|
+
fr_per_cap: dict[str, int] = {}
|
|
1559
|
+
for fr in c.frs:
|
|
1560
|
+
key = str(fr.get("capability", ""))
|
|
1561
|
+
fr_per_cap[key] = fr_per_cap.get(key, 0) + 1
|
|
1562
|
+
|
|
1563
|
+
have_mandays = any(x.get("estimate_mandays") for x in c.caps)
|
|
1564
|
+
parts = ["# estimate\n", PAGE_HEADER,
|
|
1565
|
+
"\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
|
|
1566
|
+
"task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
|
|
1567
|
+
"neighboring rows MAY be merged into one — that merge is a human decision made when the "
|
|
1568
|
+
"wave is opened.\n"]
|
|
1569
|
+
if not have_mandays:
|
|
1570
|
+
parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
|
|
1571
|
+
"this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
|
|
1572
|
+
|
|
1573
|
+
parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
|
|
1574
|
+
parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
1575
|
+
for fr in c.frs:
|
|
1576
|
+
cap_id = str(fr.get("capability", ""))
|
|
1577
|
+
cap = cap_by_id.get(cap_id, {})
|
|
1578
|
+
pid = str(fr.get("component") or cap.get("component") or "")
|
|
1579
|
+
risk, note = risk_of.get(pid, ("—", "—"))
|
|
1580
|
+
exposure = "not set yet" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
|
|
1581
|
+
mandays = cap.get("estimate_mandays")
|
|
1582
|
+
share = "—"
|
|
1583
|
+
if mandays:
|
|
1584
|
+
try:
|
|
1585
|
+
share = f"{float(mandays) / max(1, fr_per_cap.get(cap_id, 1)):.1f}"
|
|
1586
|
+
except (TypeError, ValueError):
|
|
1587
|
+
share = "—"
|
|
1588
|
+
deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
|
|
1589
|
+
parts.append(
|
|
1590
|
+
f"| {_cell(fr.get('text') or fr.get('title'))} | `{fr.get('id')}` | `{pid or '—'}` "
|
|
1591
|
+
f"| `{mode_of.get(pid, 'catalog')}` | {exposure} | {share} "
|
|
1592
|
+
f"| {cap.get('priority', '—')} | {deps} | {cap.get('target_release', '—')} |")
|
|
1593
|
+
return "\n".join(parts) + "\n"
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def generate(c: Corpus, result: Result) -> list[Path]:
|
|
1597
|
+
out_dir = c.root / ".control" / "generated"
|
|
1598
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1599
|
+
rtm = gen_rtm(c)
|
|
1600
|
+
payloads = {
|
|
1601
|
+
"components": gen_components(c),
|
|
1602
|
+
"risks": gen_risks(c),
|
|
1603
|
+
"dag": gen_dag(c),
|
|
1604
|
+
"rtm": rtm,
|
|
1605
|
+
"status": gen_status(c, rtm, result),
|
|
1606
|
+
}
|
|
1607
|
+
written = []
|
|
1608
|
+
for name in GENERATED_ORDER:
|
|
1609
|
+
payload = payloads[name]
|
|
1610
|
+
yaml_path = out_dir / f"{name}.yaml"
|
|
1611
|
+
yaml_path.write_text(dump(payload), encoding="utf-8")
|
|
1612
|
+
md_path = out_dir / f"{name}.md"
|
|
1613
|
+
md_path.write_text(as_markdown(name, payload), encoding="utf-8")
|
|
1614
|
+
written += [yaml_path, md_path]
|
|
1615
|
+
|
|
1616
|
+
# Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
|
|
1617
|
+
# not wrapped in a yaml fence, and no machine reader demands a second version of it.
|
|
1618
|
+
for name, render in (("decisions", page_decisions),
|
|
1619
|
+
("blueprint", page_blueprint),
|
|
1620
|
+
("estimate", page_estimate)):
|
|
1621
|
+
page = out_dir / f"{name}.md"
|
|
1622
|
+
page.write_text(render(c), encoding="utf-8")
|
|
1623
|
+
written.append(page)
|
|
1624
|
+
return written
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
# ------------------------------------------------------------------------ CLI
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1631
|
+
parser = argparse.ArgumentParser(
|
|
1632
|
+
prog="validate", description="V1..V27 and the .control/generated/ generator")
|
|
1633
|
+
parser.add_argument("--check", action="store_true",
|
|
1634
|
+
help="check only; exit non-zero if anything is red")
|
|
1635
|
+
parser.add_argument("--generate", action="store_true",
|
|
1636
|
+
help="rewrite .control/generated/ (still runs the check first)")
|
|
1637
|
+
parser.add_argument("--root", default=".", help="repo root (default: current directory)")
|
|
1638
|
+
parser.add_argument("--asof", default=None,
|
|
1639
|
+
help="reference date for V14, format YYYY-MM-DD (default: today). "
|
|
1640
|
+
"Stated explicitly so a run can be repeated exactly")
|
|
1641
|
+
args = parser.parse_args(argv)
|
|
1642
|
+
|
|
1643
|
+
if not args.check and not args.generate:
|
|
1644
|
+
args.check = True
|
|
1645
|
+
|
|
1646
|
+
root = Path(args.root).resolve()
|
|
1647
|
+
if not (root / ".control" / "registry").is_dir():
|
|
1648
|
+
print(f"validate: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
|
|
1649
|
+
return 2
|
|
1650
|
+
|
|
1651
|
+
asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
|
|
1652
|
+
corpus = Corpus.load(root)
|
|
1653
|
+
result = run_checks(corpus, asof)
|
|
1654
|
+
|
|
1655
|
+
if args.generate:
|
|
1656
|
+
for path in generate(corpus, result):
|
|
1657
|
+
print(f" wrote {path.relative_to(root).as_posix()}")
|
|
1658
|
+
|
|
1659
|
+
if result.findings:
|
|
1660
|
+
print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
|
|
1661
|
+
for finding in sorted(result.findings, key=lambda f: f.sort_key):
|
|
1662
|
+
print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
|
|
1663
|
+
else:
|
|
1664
|
+
print("\nGREEN — no findings")
|
|
1665
|
+
|
|
1666
|
+
if result.skipped:
|
|
1667
|
+
print("\nSkipped:")
|
|
1668
|
+
for vid, why in sorted(result.skipped.items()):
|
|
1669
|
+
print(f" {vid:<4} {why}")
|
|
1670
|
+
|
|
1671
|
+
print(f"\nV14 reference date: {asof.isoformat()}")
|
|
1672
|
+
return 1 if result.findings else 0
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
if __name__ == "__main__":
|
|
1676
|
+
raise SystemExit(main())
|