wdi-method 0.4.3 → 0.4.6
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/README.md +252 -222
- package/bin/wdi-method.js +1030 -1029
- package/kit/.constitution/document/delivery-flow-guide.md +1 -1
- package/kit/.constitution/document/templates/cross-cutting.md +4 -4
- package/kit/.constitution/document/templates/model.md +2 -2
- package/kit/.constitution/document/templates/questions.md +10 -9
- package/kit/.constitution/document/templates/srs.md +2 -2
- package/kit/.constitution/scripts/inventory.py +102 -100
- package/kit/.constitution/scripts/timeline.py +665 -665
- package/kit/.constitution/scripts/validate.py +314 -312
- package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -15
- package/kit/assets/bmad-custom/bmad-architecture.toml +17 -15
- package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
- package/kit/assets/bmad-custom/bmad-build.toml +52 -52
- package/kit/assets/bmad-custom/bmad-code-review.toml +6 -5
- package/kit/assets/bmad-custom/bmad-correct-course.toml +28 -27
- package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
- package/kit/assets/bmad-custom/bmad-prd.toml +22 -22
- package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -34
- package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -9
- package/kit/assets/bmad-custom/bmad-spec.toml +9 -8
- package/kit/assets/bmad-custom/bmad-ux.toml +7 -7
- package/kit/assets/bmad-custom/config.toml +3 -3
- package/kit/skills/wdi-report/SKILL.md +5 -5
- package/package.json +2 -2
- package/scaffold/.control/product-glossary.md +21 -21
- package/scaffold/.control/project-non-technical-log.md +23 -23
- package/scaffold/.control/questions/answered.md +11 -11
- package/scaffold/.control/questions/assumptions.md +15 -15
- package/scaffold/.control/questions/blocking.md +21 -21
- package/scaffold/.control/questions/external.md +11 -11
- package/scaffold/.control/registry/components.yaml +21 -21
- package/scaffold/.control/registry/defects.yaml +3 -3
- package/scaffold/.control/registry/index.yaml +46 -46
- package/scaffold/.control/registry/requirements.yaml +15 -15
- package/scaffold/.control/registry/risks.yaml +5 -5
- package/scaffold/.control/registry/usecases.yaml +6 -6
|
@@ -3,18 +3,18 @@
|
|
|
3
3
|
# requires-python = ">=3.11"
|
|
4
4
|
# dependencies = ["pyyaml>=6"]
|
|
5
5
|
# ///
|
|
6
|
-
"""validate — V1..V27 plus
|
|
6
|
+
"""validate — V1..V27 plus the .control/generated/ generator.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
validate --check
|
|
10
|
-
validate --generate
|
|
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
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
(--asof,
|
|
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
15
|
|
|
16
|
-
|
|
17
|
-
`generated/report`
|
|
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
18
|
"""
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
@@ -29,11 +29,11 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
import yaml
|
|
31
31
|
|
|
32
|
-
REGISTRY = "control/registry" #
|
|
32
|
+
REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
|
|
33
33
|
GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
|
|
34
34
|
|
|
35
|
-
#
|
|
36
|
-
#
|
|
35
|
+
# Pages read by HUMANS, not machines: written as real markdown tables, not yaml
|
|
36
|
+
# in a fence. All three are named in §22 and each has one clear reader.
|
|
37
37
|
GENERATED_PAGES = ["decisions", "blueprint", "estimate"]
|
|
38
38
|
|
|
39
39
|
MODES = ("catalog", "outline", "guarded", "deep")
|
|
@@ -53,7 +53,7 @@ SENSITIVE_MARKERS = (
|
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
# ----------------------------------------------------------------
|
|
56
|
+
# ---------------------------------------------------------------- infrastructure
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
@dataclass(frozen=True)
|
|
@@ -92,7 +92,7 @@ def load_yaml(path: Path) -> dict:
|
|
|
92
92
|
|
|
93
93
|
|
|
94
94
|
def rows(data: dict, key: str) -> list[dict]:
|
|
95
|
-
"""Registry list,
|
|
95
|
+
"""Registry list, always sorted by id so the output is deterministic."""
|
|
96
96
|
value = data.get(key) or []
|
|
97
97
|
if not isinstance(value, list):
|
|
98
98
|
return []
|
|
@@ -104,7 +104,7 @@ FM = re.compile(r"\A---\s*\n(.*?)\n---\s*(\n|\Z)", re.S)
|
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
class Dumper(yaml.SafeDumper):
|
|
107
|
-
"""
|
|
107
|
+
"""No anchors/aliases: output MUST be readable and diffable line by line."""
|
|
108
108
|
|
|
109
109
|
def ignore_aliases(self, data) -> bool: # noqa: ARG002
|
|
110
110
|
return True
|
|
@@ -116,7 +116,7 @@ def dump(payload: dict) -> str:
|
|
|
116
116
|
|
|
117
117
|
|
|
118
118
|
def frontmatter(path: Path) -> dict | None:
|
|
119
|
-
"""None
|
|
119
|
+
"""None if the file does not exist; {} if it exists but has no frontmatter."""
|
|
120
120
|
if not path.exists():
|
|
121
121
|
return None
|
|
122
122
|
match = FM.match(path.read_text(encoding="utf-8", errors="replace"))
|
|
@@ -137,7 +137,7 @@ def git(root: Path, *args: str) -> str | None:
|
|
|
137
137
|
return out.stdout.strip() if out.returncode == 0 else None
|
|
138
138
|
|
|
139
139
|
|
|
140
|
-
# -------------------------------------------------------------------
|
|
140
|
+
# ------------------------------------------------------------------- loading
|
|
141
141
|
|
|
142
142
|
|
|
143
143
|
@dataclass
|
|
@@ -167,7 +167,7 @@ class Corpus:
|
|
|
167
167
|
index=load_yaml(reg / "index.yaml"),
|
|
168
168
|
)
|
|
169
169
|
|
|
170
|
-
# ---
|
|
170
|
+
# --- shortcuts used repeatedly
|
|
171
171
|
@property
|
|
172
172
|
def goals(self) -> list[dict]:
|
|
173
173
|
return rows(self.requirements, "goals")
|
|
@@ -193,7 +193,7 @@ class Corpus:
|
|
|
193
193
|
return rows(self.decisions, "decisions")
|
|
194
194
|
|
|
195
195
|
def mode_of(self, pc: dict) -> str:
|
|
196
|
-
"""`mode`
|
|
196
|
+
"""Per-component `mode` wins over the global one; with neither, default `catalog`."""
|
|
197
197
|
own = str(pc.get("mode") or "").strip()
|
|
198
198
|
if own:
|
|
199
199
|
return own
|
|
@@ -216,7 +216,7 @@ class Corpus:
|
|
|
216
216
|
return rows(self.defects, "defects")
|
|
217
217
|
|
|
218
218
|
def stories(self) -> list[tuple[dict, dict, dict]]:
|
|
219
|
-
"""(wave, epic, story) —
|
|
219
|
+
"""(wave, epic, story) — sorted by id at each level."""
|
|
220
220
|
out = []
|
|
221
221
|
for wave in self.wave_list:
|
|
222
222
|
for epic in sorted(wave.get("epics") or [], key=lambda e: str(e.get("id", ""))):
|
|
@@ -235,18 +235,18 @@ def listy(row: dict, key: str) -> list[str]:
|
|
|
235
235
|
return [str(v) for v in value if v is not None]
|
|
236
236
|
|
|
237
237
|
|
|
238
|
-
# ------------------------------------------------------------------
|
|
238
|
+
# ------------------------------------------------------------------ validators
|
|
239
239
|
|
|
240
240
|
|
|
241
241
|
def v1(c: Corpus, r: Result) -> None:
|
|
242
|
-
"""
|
|
242
|
+
"""Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
deployment
|
|
246
|
-
|
|
247
|
-
|
|
244
|
+
A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
|
|
245
|
+
deployment foundation can be extended without being torn down — is measured by two architectural
|
|
246
|
+
properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
|
|
247
|
+
one `FR` there produces a false promise, and a false promise is more expensive than a finding.
|
|
248
248
|
|
|
249
|
-
|
|
249
|
+
The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (V2).
|
|
250
250
|
"""
|
|
251
251
|
cap_by_goal: dict[str, list[str]] = {}
|
|
252
252
|
for cap in c.caps:
|
|
@@ -259,7 +259,7 @@ def v1(c: Corpus, r: Result) -> None:
|
|
|
259
259
|
continue
|
|
260
260
|
if str(goal.get("no_fr") or "").strip():
|
|
261
261
|
continue
|
|
262
|
-
r.fail("V1", gid, "
|
|
262
|
+
r.fail("V1", gid, "has no FR through its CAP and states no reason in `no_fr`")
|
|
263
263
|
|
|
264
264
|
|
|
265
265
|
def v2(c: Corpus, r: Result) -> None:
|
|
@@ -270,48 +270,48 @@ def v2(c: Corpus, r: Result) -> None:
|
|
|
270
270
|
continue
|
|
271
271
|
if str(fr.get("no_uc") or "").strip():
|
|
272
272
|
continue
|
|
273
|
-
r.fail("V2", fid, "
|
|
273
|
+
r.fail("V2", fid, "has no UC and states no reason in `no_uc`")
|
|
274
274
|
|
|
275
275
|
|
|
276
276
|
def v3(c: Corpus, r: Result) -> None:
|
|
277
|
-
"""
|
|
277
|
+
"""A UC on a component that a wave has ALREADY touched MUST be scheduled to a story.
|
|
278
278
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
story
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
The old shape demanded this of EVERY UC, at any time. Before the first wave that meant the
|
|
280
|
+
entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
|
|
281
|
+
not drift: a story is born in a wave, and there was no wave yet. A validator that drowns six real
|
|
282
|
+
findings under fifty-six expected ones stops being read, and a validator that is not read
|
|
283
|
+
guards nothing.
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
285
|
+
What is guarded now is the actual omission: a wave touches a component, and a UC of that
|
|
286
|
+
component is left behind without a story. Full coverage of the whole catalogue is a G5 question,
|
|
287
|
+
and `wdi-build` owns it — the same way V12 was shifted to wave closing.
|
|
288
288
|
"""
|
|
289
289
|
scheduled = {uc for _, _, s in c.stories() for uc in listy(s, "satisfies")}
|
|
290
290
|
touched = {str(s.get("component")) for _, _, s in c.stories() if s.get("component")}
|
|
291
291
|
if not c.wave_list:
|
|
292
|
-
r.skip("V3", "
|
|
293
|
-
"
|
|
292
|
+
r.skip("V3", "no wave yet, so no story yet — every unscheduled UC is the correct "
|
|
293
|
+
"state. Full catalogue coverage is checked at G5")
|
|
294
294
|
return
|
|
295
295
|
for uc in c.ucs:
|
|
296
296
|
uid = str(uc.get("id"))
|
|
297
297
|
if uid in scheduled or str(uc.get("component")) not in touched:
|
|
298
298
|
continue
|
|
299
|
-
r.fail("V3", uid, f"
|
|
300
|
-
f"
|
|
299
|
+
r.fail("V3", uid, f"component `{uc.get('component')}` has already been touched by a wave, "
|
|
300
|
+
f"but this UC is not scheduled to any story")
|
|
301
301
|
|
|
302
302
|
|
|
303
303
|
def v4(c: Corpus, r: Result) -> None:
|
|
304
304
|
for _, _, story in c.stories():
|
|
305
305
|
if not [t for t in listy(story, "tests") if t.strip()]:
|
|
306
|
-
r.fail("V4", str(story.get("id")), "
|
|
306
|
+
r.fail("V4", str(story.get("id")), "has not one named test")
|
|
307
307
|
|
|
308
308
|
|
|
309
309
|
def v5(c: Corpus, r: Result) -> None:
|
|
310
|
-
"""
|
|
310
|
+
"""Every NFR has an enforcer, OR states its reason in `no_enforcer`.
|
|
311
311
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
|
|
313
|
+
**retired**, and the other states of itself that it is a **design measure, not a gate**. Demanding
|
|
314
|
+
a test for both produces a test that cannot fail, and a test that cannot fail is theater.
|
|
315
315
|
"""
|
|
316
316
|
for nfr in c.nfrs:
|
|
317
317
|
if [e for e in listy(nfr, "enforced_by") if e.strip()]:
|
|
@@ -319,7 +319,7 @@ def v5(c: Corpus, r: Result) -> None:
|
|
|
319
319
|
if str(nfr.get("no_enforcer") or "").strip():
|
|
320
320
|
continue
|
|
321
321
|
r.fail("V5", str(nfr.get("id")),
|
|
322
|
-
"
|
|
322
|
+
"has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
|
|
323
323
|
|
|
324
324
|
|
|
325
325
|
def v6(c: Corpus, r: Result) -> None:
|
|
@@ -353,7 +353,7 @@ def v6(c: Corpus, r: Result) -> None:
|
|
|
353
353
|
|
|
354
354
|
for owner, target in sorted(set(refs)):
|
|
355
355
|
if target and target not in defined:
|
|
356
|
-
r.fail("V6", owner, f"
|
|
356
|
+
r.fail("V6", owner, f"points to `{target}` which does not exist in any registry")
|
|
357
357
|
|
|
358
358
|
|
|
359
359
|
def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
@@ -378,25 +378,25 @@ def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
|
378
378
|
def v7(c: Corpus, r: Result) -> None:
|
|
379
379
|
caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
|
|
380
380
|
for node in _cycles(caps):
|
|
381
|
-
r.fail("V7", node, "
|
|
381
|
+
r.fail("V7", node, "is part of a `depends_on` cycle among CAPs")
|
|
382
382
|
stories = {str(s.get("id")): listy(s, "depends_on") for _, _, s in c.stories()}
|
|
383
383
|
for node in _cycles(stories):
|
|
384
|
-
r.fail("V7", node, "
|
|
384
|
+
r.fail("V7", node, "is part of a `depends_on` cycle among stories")
|
|
385
385
|
|
|
386
386
|
|
|
387
387
|
def v8(c: Corpus, r: Result) -> None:
|
|
388
|
-
"""
|
|
388
|
+
"""Every `applied` decision names a non-empty `touches`.
|
|
389
389
|
|
|
390
|
-
|
|
391
|
-
"filter
|
|
392
|
-
|
|
390
|
+
Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
|
|
391
|
+
"the filter MUST work like this" serves no FR at all, and that is VALID — it is exactly
|
|
392
|
+
decisions like that which most need remembering, and the old rule discarded them.
|
|
393
393
|
"""
|
|
394
394
|
for dec in c.decs:
|
|
395
395
|
if str(dec.get("status")) != "applied":
|
|
396
396
|
continue
|
|
397
397
|
if not [x for x in listy(dec, "touches") if str(x).strip()]:
|
|
398
398
|
r.fail("V8", str(dec.get("id")),
|
|
399
|
-
"
|
|
399
|
+
"is applied but `touches` is empty — an application with no file trace")
|
|
400
400
|
|
|
401
401
|
|
|
402
402
|
def v9(c: Corpus, r: Result) -> None:
|
|
@@ -408,7 +408,7 @@ def v9(c: Corpus, r: Result) -> None:
|
|
|
408
408
|
gate = str(fm.get("locked_at_gate") or "")
|
|
409
409
|
if gate not in passed:
|
|
410
410
|
rel = path.relative_to(c.root).as_posix()
|
|
411
|
-
r.fail("V9", rel, f"
|
|
411
|
+
r.fail("V9", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
|
|
412
412
|
|
|
413
413
|
|
|
414
414
|
def v11(c: Corpus, r: Result) -> None:
|
|
@@ -438,14 +438,14 @@ def v11(c: Corpus, r: Result) -> None:
|
|
|
438
438
|
if reaches(lid, rid) or reaches(rid, lid):
|
|
439
439
|
continue
|
|
440
440
|
r.fail("V11", f"{lid} + {rid}",
|
|
441
|
-
f"
|
|
441
|
+
f"share touches {shared} with no depends_on relation — MUST NOT run in parallel")
|
|
442
442
|
|
|
443
443
|
|
|
444
444
|
def v12(c: Corpus, r: Result) -> None:
|
|
445
|
-
"""
|
|
445
|
+
"""LC registration is checked when a wave CLOSES, not before a story goes `ready-for-dev`.
|
|
446
446
|
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
The old shape demanded the answer when the information was thinnest. At wave closing,
|
|
448
|
+
every `touches` already has an area and every boundary already has a name.
|
|
449
449
|
"""
|
|
450
450
|
areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
|
|
451
451
|
lcs_per_pc: dict[str, int] = {}
|
|
@@ -460,8 +460,8 @@ def v12(c: Corpus, r: Result) -> None:
|
|
|
460
460
|
for area in listy(story, "touches"):
|
|
461
461
|
if area not in areas:
|
|
462
462
|
r.fail("V12", str(story.get("id")),
|
|
463
|
-
f"wave
|
|
464
|
-
f"
|
|
463
|
+
f"its wave is already closed, but `{area}` is not registered as an `area` "
|
|
464
|
+
f"in components.yaml")
|
|
465
465
|
pid = str(story.get("component") or "")
|
|
466
466
|
row = pc_by_id.get(pid)
|
|
467
467
|
if row is None or (str(wave.get("id")), pid) in seen:
|
|
@@ -469,8 +469,8 @@ def v12(c: Corpus, r: Result) -> None:
|
|
|
469
469
|
seen.add((str(wave.get("id")), pid))
|
|
470
470
|
if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
|
|
471
471
|
r.fail("V12", f"{wave.get('id')} / {pid}",
|
|
472
|
-
f"wave
|
|
473
|
-
f"`LC`
|
|
472
|
+
f"wave closed and component with mode `{c.mode_of(row)}` has not one "
|
|
473
|
+
f"`LC` registered")
|
|
474
474
|
|
|
475
475
|
|
|
476
476
|
LENS_BY_RISK = {
|
|
@@ -483,24 +483,24 @@ FRONTMATTER_KEYS = ("reviewed:", "date:", "sha:", "lenses:", "updated:")
|
|
|
483
483
|
|
|
484
484
|
def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
|
|
485
485
|
if not isinstance(block, dict) or not block.get("sha") or not block.get("date"):
|
|
486
|
-
r.fail("V13", rel, "
|
|
486
|
+
r.fail("V13", rel, "carries no `reviewed` trace with a date and sha")
|
|
487
487
|
return
|
|
488
488
|
lenses = {str(x) for x in (block.get("lenses") or [])}
|
|
489
489
|
if not lenses:
|
|
490
|
-
r.fail("V13", rel, "
|
|
490
|
+
r.fail("V13", rel, "the `reviewed` trace names not one lens")
|
|
491
491
|
missing = sorted(need - lenses)
|
|
492
492
|
if missing:
|
|
493
493
|
r.fail("V13", rel,
|
|
494
|
-
f"
|
|
494
|
+
f"lenses {missing} MUST be included — that is what the component's `risk_accepted` demands")
|
|
495
495
|
|
|
496
496
|
|
|
497
497
|
def _only_reviewed_block(diff: str) -> bool:
|
|
498
|
-
"""True
|
|
498
|
+
"""True if a commit's diff on one file ONLY touches the `reviewed:` block.
|
|
499
499
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
500
|
+
This is the OQ-146 fix. The old V13 compared `sha` against the last commit that changed
|
|
501
|
+
the file — but the commit that WRITES the `reviewed:` block always changes the file, and
|
|
502
|
+
writing its own hash into a git commit is cryptographically impossible. As a result every
|
|
503
|
+
artifact that had just been stamped immediately read as "stale review", forever.
|
|
504
504
|
"""
|
|
505
505
|
touched = [ln for ln in diff.splitlines()
|
|
506
506
|
if ln[:1] in "+-" and not ln.startswith("+++") and not ln.startswith("---")]
|
|
@@ -516,7 +516,7 @@ def _only_reviewed_block(diff: str) -> bool:
|
|
|
516
516
|
|
|
517
517
|
|
|
518
518
|
def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
|
|
519
|
-
"""
|
|
519
|
+
"""First commit after `sha` that changes this file for a reason other than a review stamp."""
|
|
520
520
|
log = git(c.root, "log", "--format=%H", f"{sha}..HEAD", "--", rel)
|
|
521
521
|
if not log:
|
|
522
522
|
return None
|
|
@@ -534,31 +534,31 @@ def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
|
|
|
534
534
|
|
|
535
535
|
|
|
536
536
|
def v13(c: Corpus, r: Result) -> None:
|
|
537
|
-
"""
|
|
537
|
+
"""Review trace follows review INTENSITY, not document depth.
|
|
538
538
|
|
|
539
|
-
|
|
540
|
-
|
|
539
|
+
Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
|
|
540
|
+
stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
|
|
541
541
|
"""
|
|
542
542
|
watched = [pc for pc in c.pcs
|
|
543
543
|
if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
|
|
544
544
|
if not watched:
|
|
545
|
-
r.skip("V13", "
|
|
545
|
+
r.skip("V13", "no component with risk_accepted low or medium — nothing to guard")
|
|
546
546
|
targets: list[tuple[Path, set[str]]] = []
|
|
547
547
|
if watched:
|
|
548
548
|
targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
|
|
549
549
|
for pc in watched:
|
|
550
550
|
pid = str(pc.get("id"))
|
|
551
551
|
need = LENS_BY_RISK.get(str(pc.get("risk_accepted")).strip(), set())
|
|
552
|
-
# SRS
|
|
553
|
-
#
|
|
552
|
+
# The SRS exists and is meaningful at EVERY mode: it carries the Actor Register and UC
|
|
553
|
+
# Catalogue, and both are born at G3, which the depth knob does not touch.
|
|
554
554
|
targets.append((c.root / f".what/{pid}/SRS-{pid}.md", need))
|
|
555
|
-
# SDD
|
|
556
|
-
#
|
|
557
|
-
# mode: catalog skeleton
|
|
558
|
-
# g4_passed
|
|
559
|
-
#
|
|
560
|
-
#
|
|
561
|
-
#
|
|
555
|
+
# The SDD is guarded only when it HAS content worth guarding. Two states exempt it, and
|
|
556
|
+
# both are FINISHED states, not neglected ones:
|
|
557
|
+
# mode: catalog the skeleton is its final form; G4 is skipped there
|
|
558
|
+
# g4_passed not set G4 has not run yet, so not one section is written
|
|
559
|
+
# Demanding a review trace on a file whose content is 13 lines of template comments is
|
|
560
|
+
# theater — exactly the ceremony this redesign cut, and a review that cannot fail proves
|
|
561
|
+
# nothing. Once G4 passes, the demand comes back and it is meaningful.
|
|
562
562
|
passed = str(pc.get("g4_passed") or "").strip().lower()
|
|
563
563
|
if c.mode_of(pc) != "catalog" and passed not in ("", "false", "no", "belum"):
|
|
564
564
|
targets.append((c.root / f".how/{pid}/SDD-{pid}.md", need))
|
|
@@ -566,7 +566,7 @@ def v13(c: Corpus, r: Result) -> None:
|
|
|
566
566
|
for path, need in targets:
|
|
567
567
|
fm = frontmatter(path)
|
|
568
568
|
if fm is None:
|
|
569
|
-
continue #
|
|
569
|
+
continue # not born yet — not V13's business
|
|
570
570
|
rel = path.relative_to(c.root).as_posix()
|
|
571
571
|
_reviewed_ok(r, rel, fm.get("reviewed"), need)
|
|
572
572
|
block = fm.get("reviewed")
|
|
@@ -574,8 +574,8 @@ def v13(c: Corpus, r: Result) -> None:
|
|
|
574
574
|
stale = _stale_since(c, rel, str(block["sha"]))
|
|
575
575
|
if stale:
|
|
576
576
|
r.fail("V13", rel,
|
|
577
|
-
f"
|
|
578
|
-
f"review
|
|
577
|
+
f"changed at {stale[:7]} after being reviewed at {str(block['sha'])[:7]} — "
|
|
578
|
+
f"stale review")
|
|
579
579
|
|
|
580
580
|
for wave in c.wave_list:
|
|
581
581
|
if not wave.get("epics"):
|
|
@@ -585,7 +585,7 @@ def v13(c: Corpus, r: Result) -> None:
|
|
|
585
585
|
|
|
586
586
|
|
|
587
587
|
def cap_stories(c: Corpus) -> dict[str, list[dict]]:
|
|
588
|
-
"""CAP -> story,
|
|
588
|
+
"""CAP -> story, traced through CAP -> FR -> UC -> story. No git, no timeline."""
|
|
589
589
|
frs_of: dict[str, list[str]] = {}
|
|
590
590
|
for fr in c.frs:
|
|
591
591
|
frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
|
|
@@ -603,14 +603,14 @@ def cap_stories(c: Corpus) -> dict[str, list[dict]]:
|
|
|
603
603
|
|
|
604
604
|
|
|
605
605
|
def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
606
|
-
"""
|
|
606
|
+
"""Overdue-ness is computed from the registry itself — the timeline only reinforces, never gates."""
|
|
607
607
|
by_cap = cap_stories(c)
|
|
608
608
|
timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
|
|
609
609
|
listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
|
|
610
610
|
if str(row.get("state")) == "overdue"} if timeline else None
|
|
611
611
|
if listed is None:
|
|
612
|
-
r.skip("V14", "generated/timeline.yaml
|
|
613
|
-
"
|
|
612
|
+
r.skip("V14", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
|
|
613
|
+
"from the registry, but its presence in generated/report is not checked")
|
|
614
614
|
|
|
615
615
|
for cap in c.caps:
|
|
616
616
|
cid = str(cap.get("id"))
|
|
@@ -620,7 +620,7 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
|
620
620
|
try:
|
|
621
621
|
due = dt.date.fromisoformat(end)
|
|
622
622
|
except ValueError:
|
|
623
|
-
r.fail("V14", cid, f"`planned_end` `{end}`
|
|
623
|
+
r.fail("V14", cid, f"`planned_end` `{end}` is not an ISO date")
|
|
624
624
|
continue
|
|
625
625
|
items = by_cap.get(cid, [])
|
|
626
626
|
closed = bool(items) and all(_story_status(c, s) == "done" for s in items)
|
|
@@ -628,19 +628,19 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
|
628
628
|
continue
|
|
629
629
|
late = (asof - due).days
|
|
630
630
|
if listed is not None and cid not in listed:
|
|
631
|
-
r.fail("V14", cid, f"
|
|
632
|
-
f"`overdue`
|
|
631
|
+
r.fail("V14", cid, f"{late} days overdue with nothing delivered, and not flagged "
|
|
632
|
+
f"`overdue` in generated/timeline")
|
|
633
633
|
else:
|
|
634
|
-
r.fail("V14", cid, f"
|
|
634
|
+
r.fail("V14", cid, f"{late} days overdue with nothing closed")
|
|
635
635
|
|
|
636
636
|
|
|
637
637
|
def v15(c: Corpus, r: Result) -> None:
|
|
638
638
|
for cap in c.caps:
|
|
639
639
|
if not str(cap.get("goal") or "").strip():
|
|
640
|
-
r.fail("V15", str(cap.get("id")), "
|
|
640
|
+
r.fail("V15", str(cap.get("id")), "does not point to a `goal`")
|
|
641
641
|
for fr in c.frs:
|
|
642
642
|
if not str(fr.get("capability") or "").strip():
|
|
643
|
-
r.fail("V15", str(fr.get("id")), "
|
|
643
|
+
r.fail("V15", str(fr.get("id")), "does not point to a `capability`")
|
|
644
644
|
|
|
645
645
|
|
|
646
646
|
def v16(c: Corpus, r: Result) -> None:
|
|
@@ -649,26 +649,26 @@ def v16(c: Corpus, r: Result) -> None:
|
|
|
649
649
|
rel = path.relative_to(c.root).as_posix()
|
|
650
650
|
artifact = str(fm.get("artifact") or "")
|
|
651
651
|
if not artifact:
|
|
652
|
-
r.fail("V16", rel, "
|
|
652
|
+
r.fail("V16", rel, "has no `artifact:` in frontmatter")
|
|
653
653
|
elif not (c.root / artifact).exists():
|
|
654
|
-
r.fail("V16", rel, f"`artifact:`
|
|
654
|
+
r.fail("V16", rel, f"`artifact:` points to `{artifact}` which does not exist")
|
|
655
655
|
for layer in (".what", ".how"):
|
|
656
656
|
for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
|
|
657
657
|
r.fail("V16", stray.relative_to(c.root).as_posix(),
|
|
658
|
-
"memlog MUST NOT
|
|
658
|
+
"a memlog MUST NOT live inside the corpus")
|
|
659
659
|
|
|
660
660
|
|
|
661
661
|
def v17(c: Corpus, r: Result) -> None:
|
|
662
662
|
for wave in c.wave_list:
|
|
663
663
|
wid = str(wave.get("id"))
|
|
664
664
|
if not str(wave.get("release") or "").strip():
|
|
665
|
-
r.fail("V17", wid, "
|
|
665
|
+
r.fail("V17", wid, "does not name a `release`")
|
|
666
666
|
slugs = listy(wave, "prd")
|
|
667
667
|
if not slugs:
|
|
668
|
-
r.fail("V17", wid, "
|
|
668
|
+
r.fail("V17", wid, "does not name a `prd`")
|
|
669
669
|
for slug in slugs:
|
|
670
670
|
if not (c.root / ".what/_prd" / slug).is_dir():
|
|
671
|
-
r.fail("V17", wid, f"`prd: {slug}`
|
|
671
|
+
r.fail("V17", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
|
|
672
672
|
|
|
673
673
|
|
|
674
674
|
def v18(c: Corpus, r: Result) -> None:
|
|
@@ -676,22 +676,22 @@ def v18(c: Corpus, r: Result) -> None:
|
|
|
676
676
|
sid = str(story.get("id"))
|
|
677
677
|
folder = str(story.get("spec_folder") or "").strip()
|
|
678
678
|
if not folder:
|
|
679
|
-
r.fail("V18", sid, "
|
|
679
|
+
r.fail("V18", sid, "does not name a `spec_folder`")
|
|
680
680
|
continue
|
|
681
681
|
matches = sorted((c.root / folder / "stories").glob(f"{sid}-*.md"))
|
|
682
682
|
if not matches:
|
|
683
|
-
r.fail("V18", sid, f"
|
|
683
|
+
r.fail("V18", sid, f"has no story file in {folder}stories/")
|
|
684
684
|
continue
|
|
685
685
|
fm = frontmatter(matches[0]) or {}
|
|
686
686
|
if not str(fm.get("status") or "").strip():
|
|
687
|
-
r.fail("V18", sid, "story file
|
|
687
|
+
r.fail("V18", sid, "story file has no `status` in frontmatter")
|
|
688
688
|
|
|
689
689
|
|
|
690
690
|
def v19(c: Corpus, r: Result) -> None:
|
|
691
|
-
"""
|
|
691
|
+
"""The retrospective archive is tied to WAVE SIZE, not to `mode`.
|
|
692
692
|
|
|
693
|
-
|
|
694
|
-
|
|
693
|
+
Mandatory on wave `L`; advisory on `S` and `M`. Document depth and volume of work are two
|
|
694
|
+
different things, and demanding a retrospective for a three-story wave is ceremony.
|
|
695
695
|
"""
|
|
696
696
|
names = [x.name for x in sorted((c.root / ".control/reports").glob("RTR-*"))]
|
|
697
697
|
advisory: list[str] = []
|
|
@@ -702,13 +702,13 @@ def v19(c: Corpus, r: Result) -> None:
|
|
|
702
702
|
if any(wid in name for name in names):
|
|
703
703
|
continue
|
|
704
704
|
if str(wave.get("size")).upper() == "L":
|
|
705
|
-
r.fail("V19", wid, "wave `L`
|
|
705
|
+
r.fail("V19", wid, "wave `L` closed without an `RTR-` in .control/reports/")
|
|
706
706
|
else:
|
|
707
707
|
advisory.append(wid)
|
|
708
708
|
if advisory:
|
|
709
|
-
r.skip("V19", "advisory — wave S/M
|
|
709
|
+
r.skip("V19", "advisory — wave S/M closed without an RTR-: " + ", ".join(sorted(advisory)))
|
|
710
710
|
else:
|
|
711
|
-
r.skip("V19", "
|
|
711
|
+
r.skip("V19", "only the RTR- line item is checked mechanically; the rest of the distillation is guarded by wdi-build")
|
|
712
712
|
|
|
713
713
|
|
|
714
714
|
PLATFORM = "_platform"
|
|
@@ -717,18 +717,18 @@ PLATFORM_DATA_HEADING = "Milik platform"
|
|
|
717
717
|
|
|
718
718
|
|
|
719
719
|
def v21(c: Corpus, r: Result) -> None:
|
|
720
|
-
"""
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
`_platform`
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
720
|
+
"""One domain entity has EXACTLY ONE owner authorized to write it.
|
|
721
|
+
|
|
722
|
+
The owner is a Product Component, OR `_platform` for an entity with no single component
|
|
723
|
+
promise behind it. Semantic collisions across PRDs have already happened for real: one
|
|
724
|
+
component took a business-rule numbering range from a shared global sequence. Two `FR`s
|
|
725
|
+
that both claim write authority over the same entity, with neither pointing at the other,
|
|
726
|
+
are a defect the moment they are written.
|
|
727
|
+
|
|
728
|
+
`_platform` is NOT a Product Component and therefore has no `mode`, `risk_accepted`, SRS,
|
|
729
|
+
or G4. It is a home for ownership, not a domain slice — and so it does not become a dumping
|
|
730
|
+
ground, every entity it claims MUST be explained in `cross-cutting.md`: if the platform
|
|
731
|
+
owns the data, the platform documents it.
|
|
732
732
|
"""
|
|
733
733
|
owner: dict[str, str] = {}
|
|
734
734
|
for pc in c.pcs:
|
|
@@ -736,8 +736,8 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
736
736
|
for entity in listy(pc, "owns"):
|
|
737
737
|
if entity in owner and owner[entity] != pid:
|
|
738
738
|
r.fail("V21", entity,
|
|
739
|
-
f"
|
|
740
|
-
f"
|
|
739
|
+
f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
|
|
740
|
+
f"have exactly one owner")
|
|
741
741
|
else:
|
|
742
742
|
owner.setdefault(entity, pid)
|
|
743
743
|
|
|
@@ -745,8 +745,8 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
745
745
|
for entity in platform:
|
|
746
746
|
if entity in owner:
|
|
747
747
|
r.fail("V21", entity,
|
|
748
|
-
f"
|
|
749
|
-
f"`{PLATFORM}`
|
|
748
|
+
f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
|
|
749
|
+
f"`{PLATFORM}` is not a second path for an entity that already has an owner")
|
|
750
750
|
else:
|
|
751
751
|
owner[entity] = PLATFORM
|
|
752
752
|
|
|
@@ -761,21 +761,21 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
761
761
|
if not own or not home or own == home:
|
|
762
762
|
continue
|
|
763
763
|
if own == PLATFORM:
|
|
764
|
-
#
|
|
765
|
-
#
|
|
766
|
-
#
|
|
764
|
+
# The platform has no `FR`, so there is nothing a `defers_to` could point to. What
|
|
765
|
+
# stands in for "one writer" here is ONE DOCUMENTED FORM, and that is what
|
|
766
|
+
# _platform_documented checks above.
|
|
767
767
|
continue
|
|
768
768
|
if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
|
|
769
769
|
r.fail("V21", fid,
|
|
770
|
-
f"
|
|
771
|
-
f"
|
|
770
|
+
f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
|
|
771
|
+
f"pointing to an `FR` owned by that owner")
|
|
772
772
|
|
|
773
773
|
|
|
774
774
|
def _platform_inventory_rows(c: Corpus) -> list[str]:
|
|
775
|
-
"""
|
|
775
|
+
"""Inventory rows owned by `_platform`, read from `platform_rows:` in each inventory.
|
|
776
776
|
|
|
777
|
-
`_platform`
|
|
778
|
-
|
|
777
|
+
`_platform` is a valid value at EVERY ownership position, so the guard applies at every
|
|
778
|
+
position too: whatever it owns MUST be documented in `cross-cutting.md`.
|
|
779
779
|
"""
|
|
780
780
|
out: list[str] = []
|
|
781
781
|
for kind in ("db", "api", "screen"):
|
|
@@ -788,31 +788,31 @@ def _platform_inventory_rows(c: Corpus) -> list[str]:
|
|
|
788
788
|
|
|
789
789
|
|
|
790
790
|
def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
|
|
791
|
-
"""
|
|
791
|
+
"""Every entity with `platform_owns` MUST be named in `cross-cutting.md`.
|
|
792
792
|
|
|
793
|
-
|
|
794
|
-
|
|
793
|
+
Skipped while the file does not yet carry that section: `cross-cutting.md` is a G3 output, and
|
|
794
|
+
an artifact the next gate will produce MUST NOT be reported missing.
|
|
795
795
|
"""
|
|
796
796
|
if not entities:
|
|
797
797
|
return
|
|
798
798
|
path = c.root / CROSS_CUTTING
|
|
799
799
|
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
|
800
800
|
if PLATFORM_DATA_HEADING.lower() not in text.lower():
|
|
801
|
-
r.skip("V21", f"`{CROSS_CUTTING}`
|
|
802
|
-
f"{len(entities)}
|
|
801
|
+
r.skip("V21", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
|
|
802
|
+
f"{len(entities)} entities with platform_owns are not documented yet: "
|
|
803
803
|
+ ", ".join(sorted(entities)))
|
|
804
804
|
return
|
|
805
805
|
for entity in sorted(entities):
|
|
806
806
|
if entity not in text:
|
|
807
807
|
r.fail("V21", entity,
|
|
808
|
-
f"
|
|
809
|
-
f"platform
|
|
808
|
+
f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
|
|
809
|
+
f"a platform that owns data MUST document it")
|
|
810
810
|
|
|
811
811
|
|
|
812
812
|
def v22(c: Corpus, r: Result) -> None:
|
|
813
|
-
"""
|
|
813
|
+
"""A wave MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
|
|
814
814
|
|
|
815
|
-
`catalog`
|
|
815
|
+
`catalog` skips G4 on purpose, so it is not an exception — it is part of the rule.
|
|
816
816
|
"""
|
|
817
817
|
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
818
818
|
seen: set[tuple[str, str]] = set()
|
|
@@ -829,20 +829,20 @@ def v22(c: Corpus, r: Result) -> None:
|
|
|
829
829
|
if mode == "catalog":
|
|
830
830
|
continue
|
|
831
831
|
if mode not in MODES:
|
|
832
|
-
r.fail("V22", pid, f"`mode: {mode}`
|
|
832
|
+
r.fail("V22", pid, f"`mode: {mode}` is not one of {list(MODES)}")
|
|
833
833
|
continue
|
|
834
834
|
passed = row.get("g4_passed")
|
|
835
835
|
if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
|
|
836
836
|
r.fail("V22", f"{wave.get('id')} / {pid}",
|
|
837
|
-
f"wave
|
|
837
|
+
f"wave touches a component with mode `{mode}` whose `g4_passed` has not been set")
|
|
838
838
|
|
|
839
839
|
|
|
840
840
|
def v23(c: Corpus, r: Result) -> None:
|
|
841
|
-
"""`risk_accepted: high`
|
|
841
|
+
"""`risk_accepted: high` on a sensitive component demands a `DEC-` in `risk_accepted_by`.
|
|
842
842
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
843
|
+
On a component that touches nothing on that list, `high` is FREE. The control is
|
|
844
|
+
disclosure, not veto — the owner may still choose quickly, just not without knowing what
|
|
845
|
+
they are wagering.
|
|
846
846
|
"""
|
|
847
847
|
known = {str(x.get("id")) for x in c.decs}
|
|
848
848
|
for pc in c.pcs:
|
|
@@ -856,10 +856,10 @@ def v23(c: Corpus, r: Result) -> None:
|
|
|
856
856
|
ref = str(pc.get("risk_accepted_by") or "").strip()
|
|
857
857
|
if not ref:
|
|
858
858
|
r.fail("V23", pid,
|
|
859
|
-
f"`risk_accepted: high`
|
|
860
|
-
f"`risk_accepted_by`
|
|
859
|
+
f"`risk_accepted: high` while `risk_note` mentions {hits}, without "
|
|
860
|
+
f"`risk_accepted_by` pointing to a risk-acceptance `DEC-`")
|
|
861
861
|
elif ref not in known:
|
|
862
|
-
r.fail("V23", pid, f"`risk_accepted_by: {ref}`
|
|
862
|
+
r.fail("V23", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
|
|
863
863
|
|
|
864
864
|
|
|
865
865
|
def v20(c: Corpus, r: Result) -> None:
|
|
@@ -870,24 +870,25 @@ def v20(c: Corpus, r: Result) -> None:
|
|
|
870
870
|
if cause not in needs_link:
|
|
871
871
|
continue
|
|
872
872
|
if not listy(defect, "violates"):
|
|
873
|
-
r.fail("V20", did, f"
|
|
873
|
+
r.fail("V20", did, f"has `root_cause` `{cause}` but `violates` is empty")
|
|
874
874
|
if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
|
|
875
875
|
r.fail("V20", did,
|
|
876
|
-
f"
|
|
876
|
+
f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
|
|
877
877
|
|
|
878
878
|
|
|
879
|
-
#
|
|
880
|
-
#
|
|
879
|
+
# Files that DESCRIBE the past, not STATE what currently holds. A dangling citation here is
|
|
880
|
+
# not a finding — corpus-guide.md owns that rule, and rewriting it would falsify history.
|
|
881
881
|
PAST_RECORD = (
|
|
882
882
|
".control/memlog/",
|
|
883
883
|
".control/decisions/",
|
|
884
884
|
".control/questions/answered.md",
|
|
885
885
|
".control/reports/",
|
|
886
886
|
)
|
|
887
|
-
#
|
|
887
|
+
# Corpus that §25 freezes as-is. Its citation of a now-retired prototype is authorized by DEC-016.
|
|
888
888
|
FROZEN = (".what/",)
|
|
889
|
-
#
|
|
890
|
-
#
|
|
889
|
+
# A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
|
|
890
|
+
# memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
|
|
891
|
+
# happened.
|
|
891
892
|
DESTINATION = (
|
|
892
893
|
".control/memlog/",
|
|
893
894
|
".control/meetings/",
|
|
@@ -901,16 +902,16 @@ CITE_RE = re.compile(
|
|
|
901
902
|
|
|
902
903
|
|
|
903
904
|
def v24(c: Corpus, r: Result) -> None:
|
|
904
|
-
"""
|
|
905
|
+
"""A path citation inside a document that STATES what currently holds MUST resolve.
|
|
905
906
|
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
907
|
+
This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
|
|
908
|
+
that a migration stayed complete. Its failure class is distinctive: a file gets deleted or moved,
|
|
909
|
+
while the routing line that points at it stays behind — no other validator sees it, because no
|
|
910
|
+
id moved.
|
|
910
911
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
912
|
+
Deliberately SKIPPED: files that describe the past, and corpus that has been frozen. A `DEC-`
|
|
913
|
+
Trace that names material that has since been retired describes what was read on that date;
|
|
914
|
+
reporting it would demand history be rewritten to match the present.
|
|
914
915
|
"""
|
|
915
916
|
scanned = 0
|
|
916
917
|
for path in sorted(c.root.rglob("*.md")) + sorted(c.root.rglob("*.yaml")):
|
|
@@ -923,20 +924,20 @@ def v24(c: Corpus, r: Result) -> None:
|
|
|
923
924
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
924
925
|
for cited in sorted(set(CITE_RE.findall(text))):
|
|
925
926
|
if "<" in cited or "{" in cited:
|
|
926
|
-
continue # placeholder,
|
|
927
|
+
continue # placeholder, not a path
|
|
927
928
|
if cited.startswith(DESTINATION):
|
|
928
929
|
continue
|
|
929
930
|
if not (c.root / cited).exists():
|
|
930
|
-
r.fail("V24", rel, f"
|
|
931
|
+
r.fail("V24", rel, f"cites `{cited}` which does not exist")
|
|
931
932
|
if not scanned:
|
|
932
|
-
r.skip("V24", "
|
|
933
|
+
r.skip("V24", "no file was scanned")
|
|
933
934
|
|
|
934
935
|
|
|
935
936
|
CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
|
|
936
937
|
|
|
937
938
|
|
|
938
939
|
def map_container_headings(root: Path) -> list[str] | None:
|
|
939
|
-
"""Heading `### x`
|
|
940
|
+
"""Heading `### x` under `## Containers` in the code map. None if the map does not exist."""
|
|
940
941
|
path = root / ".control" / "structure-codebase.md"
|
|
941
942
|
if not path.exists():
|
|
942
943
|
return None
|
|
@@ -952,70 +953,70 @@ def map_container_headings(root: Path) -> list[str] | None:
|
|
|
952
953
|
|
|
953
954
|
|
|
954
955
|
def v25(c: Corpus, r: Result) -> None:
|
|
955
|
-
"""`built`
|
|
956
|
+
"""A container's `built` and its four consequences, plus the PC x container matrix.
|
|
956
957
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
`built`
|
|
961
|
-
|
|
958
|
+
A container EXISTS inside the boundary whether or not we write its content, and that is what
|
|
959
|
+
used to make the rule unsatisfiable: `structure-guide.md` demands every code-map heading match
|
|
960
|
+
the registry, while a database or web server MUST be registered and MUST NOT have a heading.
|
|
961
|
+
`built` separates the two, and this check is what makes that separation hold instead of the
|
|
962
|
+
argument being repeated on every project. `DEC-017` records its definition.
|
|
962
963
|
|
|
963
|
-
|
|
964
|
-
|
|
964
|
+
Anything whose runtime we do not deploy is an external system: it lives in C4 L1 and MUST NOT
|
|
965
|
+
be registered here at all — its absence from the registry is the check.
|
|
965
966
|
"""
|
|
966
967
|
containers = rows(c.components, "containers")
|
|
967
968
|
if not containers:
|
|
968
|
-
r.skip("V25", "`containers:`
|
|
969
|
+
r.skip("V25", "`containers:` is not registered yet")
|
|
969
970
|
return
|
|
970
971
|
|
|
971
972
|
built: dict[str, bool] = {}
|
|
972
973
|
for ctr in containers:
|
|
973
974
|
cid = str(ctr.get("id") or "").strip()
|
|
974
975
|
if not cid:
|
|
975
|
-
r.fail("V25", "containers", "
|
|
976
|
+
r.fail("V25", "containers", "a container has no `id`")
|
|
976
977
|
continue
|
|
977
978
|
flag = ctr.get("built")
|
|
978
979
|
if not isinstance(flag, bool):
|
|
979
|
-
r.fail("V25", cid, "`built` MUST bool — true
|
|
980
|
+
r.fail("V25", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
|
|
980
981
|
continue
|
|
981
982
|
built[cid] = flag
|
|
982
983
|
|
|
983
|
-
# (1) heading
|
|
984
|
+
# (1) code-map heading = EXACTLY a container with `built: true`
|
|
984
985
|
headings = map_container_headings(c.root)
|
|
985
986
|
if headings is None:
|
|
986
|
-
r.fail("V25", ".control/structure-codebase.md", "
|
|
987
|
+
r.fail("V25", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
|
|
987
988
|
else:
|
|
988
989
|
for h in headings:
|
|
989
990
|
if h not in built:
|
|
990
|
-
r.fail("V25", f"
|
|
991
|
+
r.fail("V25", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
|
|
991
992
|
elif not built[h]:
|
|
992
|
-
r.fail("V25", f"
|
|
993
|
+
r.fail("V25", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
|
|
993
994
|
for cid, flag in sorted(built.items()):
|
|
994
995
|
if flag and cid not in headings:
|
|
995
|
-
r.fail("V25", cid, "`built: true` MUST
|
|
996
|
+
r.fail("V25", cid, "`built: true` MUST have a heading in the code map")
|
|
996
997
|
|
|
997
|
-
# (2) `built: false` MUST NOT
|
|
998
|
+
# (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
|
|
998
999
|
for lc in c.lcs:
|
|
999
1000
|
ctr = str(lc.get("container") or "").strip()
|
|
1000
1001
|
if ctr and built.get(ctr) is False:
|
|
1001
|
-
r.fail("V25", str(lc.get("id") or "LC-?"), f"
|
|
1002
|
+
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is `built: false`")
|
|
1002
1003
|
elif ctr and ctr not in built:
|
|
1003
|
-
r.fail("V25", str(lc.get("id") or "LC-?"), f"
|
|
1004
|
+
r.fail("V25", str(lc.get("id") or "LC-?"), f"names container `{ctr}` which is not registered")
|
|
1004
1005
|
|
|
1005
|
-
# (4)
|
|
1006
|
+
# (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
|
|
1006
1007
|
for pc in c.pcs:
|
|
1007
1008
|
pid = str(pc.get("id") or "?")
|
|
1008
1009
|
listed = listy(pc, "containers")
|
|
1009
1010
|
if not listed:
|
|
1010
|
-
r.fail("V25", pid, "`containers:`
|
|
1011
|
+
r.fail("V25", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
|
|
1011
1012
|
continue
|
|
1012
1013
|
for ctr in listed:
|
|
1013
1014
|
if ctr not in built:
|
|
1014
|
-
r.fail("V25", pid, f"`containers:`
|
|
1015
|
+
r.fail("V25", pid, f"`containers:` names `{ctr}` which is not registered")
|
|
1015
1016
|
elif not built[ctr]:
|
|
1016
|
-
r.fail("V25", pid, f"`containers:`
|
|
1017
|
+
r.fail("V25", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
|
|
1017
1018
|
|
|
1018
|
-
# (5) L3 —
|
|
1019
|
+
# (5) L3 — only for `built: true`, and only ones that hold more than one PC
|
|
1019
1020
|
pcs_per: dict[str, list[str]] = {}
|
|
1020
1021
|
for pc in c.pcs:
|
|
1021
1022
|
for ctr in listy(pc, "containers"):
|
|
@@ -1024,40 +1025,41 @@ def v25(c: Corpus, r: Result) -> None:
|
|
|
1024
1025
|
cid = path.name[len("c4-l3-"):-len(".md")]
|
|
1025
1026
|
if cid not in built:
|
|
1026
1027
|
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1027
|
-
f"L3
|
|
1028
|
+
f"L3 for `{cid}` which is not a registered container")
|
|
1028
1029
|
elif not built[cid]:
|
|
1029
1030
|
r.fail("V25", path.relative_to(c.root).as_posix(),
|
|
1030
|
-
f"`{cid}` `built: false` MUST NOT
|
|
1031
|
+
f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
|
|
1031
1032
|
for cid, pids in sorted(pcs_per.items()):
|
|
1032
1033
|
if built.get(cid) and len(pids) > 1:
|
|
1033
1034
|
l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
|
|
1034
1035
|
if not l3.exists():
|
|
1035
|
-
r.fail("V25", cid, f"
|
|
1036
|
+
r.fail("V25", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
|
|
1036
1037
|
|
|
1037
1038
|
|
|
1038
1039
|
UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
|
|
1039
1040
|
|
|
1040
|
-
#
|
|
1041
|
-
# `ya`
|
|
1042
|
-
#
|
|
1041
|
+
# The `critical` column value is machine-matched, so it is machine-facing and its canonical form
|
|
1042
|
+
# is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
|
|
1043
|
+
# MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
|
|
1044
|
+
# matching inside other words.
|
|
1043
1045
|
CRITICAL_YES = re.compile(r"\b(yes|ya)\b", re.I)
|
|
1044
1046
|
|
|
1045
1047
|
|
|
1046
1048
|
def v26(c: Corpus, r: Result) -> None:
|
|
1047
|
-
"""
|
|
1049
|
+
"""The UC catalogue in every SRS MUST agree with `usecases.yaml` — both its id AND its `critical`.
|
|
1048
1050
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1051
|
+
This is the most expensive gap this pass closes, because it is the only one that **had already
|
|
1052
|
+
happened and no validator saw it.** Step 16 re-derived `critical` in the registry with a
|
|
1053
|
+
narrowed definition — money, personal data, irreversible action — and the seven catalogue tables
|
|
1054
|
+
in the SRS did not follow along. Twenty-six rows disagreed, and the disagreement was only
|
|
1055
|
+
discovered when a human read the sentence "nine of these are critical" in SRS-admin while the
|
|
1056
|
+
registry held three.
|
|
1055
1057
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
+
The registry is the SSOT. The table in the SRS is the catalogue's permanent home for a reader,
|
|
1059
|
+
and two homes for one fact are only safe if something compares them. This is what compares them.
|
|
1058
1060
|
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
+
What is NOT checked here: title and actor. Both are prose, and prose with different words is
|
|
1062
|
+
not prose with a different meaning — comparing them would report style as a defect.
|
|
1061
1063
|
"""
|
|
1062
1064
|
reg = {str(uc.get("id")): bool(uc.get("critical")) for uc in c.ucs}
|
|
1063
1065
|
reg_pc = {str(uc.get("id")): str(uc.get("component") or "") for uc in c.ucs}
|
|
@@ -1077,11 +1079,11 @@ def v26(c: Corpus, r: Result) -> None:
|
|
|
1077
1079
|
continue
|
|
1078
1080
|
seen.add(uid)
|
|
1079
1081
|
if uid not in reg:
|
|
1080
|
-
r.fail("V26", f"{pid}/{uid}", "
|
|
1082
|
+
r.fail("V26", f"{pid}/{uid}", "is in the SRS catalogue but not in `usecases.yaml`")
|
|
1081
1083
|
continue
|
|
1082
1084
|
if reg_pc[uid] != pid:
|
|
1083
1085
|
r.fail("V26", f"{pid}/{uid}",
|
|
1084
|
-
f"registry
|
|
1086
|
+
f"the registry places it in `{reg_pc[uid]}`, not in this component")
|
|
1085
1087
|
marked = CRITICAL_YES.search(cells[3]) is not None
|
|
1086
1088
|
if marked != reg[uid]:
|
|
1087
1089
|
r.fail("V26", f"{pid}/{uid}",
|
|
@@ -1089,58 +1091,58 @@ def v26(c: Corpus, r: Result) -> None:
|
|
|
1089
1091
|
f"in the registry {'yes' if reg[uid] else 'no'}")
|
|
1090
1092
|
for uid, owner in sorted(reg_pc.items()):
|
|
1091
1093
|
if owner == pid and uid not in seen:
|
|
1092
|
-
r.fail("V26", f"{pid}/{uid}", "
|
|
1094
|
+
r.fail("V26", f"{pid}/{uid}", "is in `usecases.yaml` but not in the SRS catalogue")
|
|
1093
1095
|
if not checked:
|
|
1094
|
-
r.skip("V26", "
|
|
1096
|
+
r.skip("V26", "no SRS could be read")
|
|
1095
1097
|
|
|
1096
1098
|
|
|
1097
1099
|
def v27(c: Corpus, r: Result) -> None:
|
|
1098
|
-
"""
|
|
1100
|
+
"""Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
|
|
1099
1101
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1102
|
+
The `.constitution/project/` room exists so product-specific rules have a home that `update`
|
|
1103
|
+
does not overwrite and `promote` does not publish. The cost that comes with it: it is also the
|
|
1104
|
+
easiest place to break a generic rule without a trace. Its frontmatter is what holds that back.
|
|
1103
1105
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1106
|
+
A file here MAY narrow or add without naming anything. To REBUT a generic rule it MUST name it
|
|
1107
|
+
in `overrides:` and carry a `decision:` — because a method that can be rebutted without a
|
|
1108
|
+
decision stops being trustworthy in the next repo.
|
|
1107
1109
|
|
|
1108
|
-
`README.md`
|
|
1110
|
+
The room's `README.md` is skipped: it is authored in the package, not in the product.
|
|
1109
1111
|
"""
|
|
1110
1112
|
room = c.root / ".constitution" / "project"
|
|
1111
1113
|
if not room.is_dir():
|
|
1112
|
-
r.skip("V27", "
|
|
1114
|
+
r.skip("V27", "the `.constitution/project/` room does not exist yet — it is seeded at install")
|
|
1113
1115
|
return
|
|
1114
1116
|
files = [p for p in sorted(room.rglob("*.md")) if p.name != "README.md"]
|
|
1115
1117
|
if not files:
|
|
1116
|
-
r.skip("V27", "
|
|
1117
|
-
"
|
|
1118
|
+
r.skip("V27", "the `.constitution/project/` room is empty, and that is a valid state — "
|
|
1119
|
+
"a generic rule MUST NOT be moved here just to give the room content")
|
|
1118
1120
|
return
|
|
1119
1121
|
dec_ids = {str(d.get("id")) for d in c.decs}
|
|
1120
1122
|
for path in files:
|
|
1121
1123
|
rel = path.relative_to(c.root).as_posix()
|
|
1122
1124
|
fm = frontmatter(path)
|
|
1123
1125
|
if fm is None:
|
|
1124
|
-
r.fail("V27", rel, "
|
|
1126
|
+
r.fail("V27", rel, "has no frontmatter")
|
|
1125
1127
|
continue
|
|
1126
1128
|
if str(fm.get("scope") or "").strip() != "project":
|
|
1127
|
-
r.fail("V27", rel, "`scope:` MUST
|
|
1129
|
+
r.fail("V27", rel, "`scope:` MUST contain exactly `project`")
|
|
1128
1130
|
if not str(fm.get("purpose") or "").strip():
|
|
1129
|
-
r.fail("V27", rel, "`purpose:`
|
|
1131
|
+
r.fail("V27", rel, "`purpose:` is empty — one line: what this rule guards")
|
|
1130
1132
|
over = str(fm.get("overrides") or "").strip()
|
|
1131
1133
|
dec = str(fm.get("decision") or "").strip()
|
|
1132
1134
|
if over:
|
|
1133
1135
|
if not (c.root / over).exists():
|
|
1134
|
-
r.fail("V27", rel, f"`overrides:`
|
|
1135
|
-
f"
|
|
1136
|
+
r.fail("V27", rel, f"`overrides:` points to `{over}` which does not exist — "
|
|
1137
|
+
f"the rebutted rule may already be gone")
|
|
1136
1138
|
if not dec:
|
|
1137
|
-
r.fail("V27", rel, "
|
|
1138
|
-
"
|
|
1139
|
+
r.fail("V27", rel, "rebuts a generic rule without `decision:` — "
|
|
1140
|
+
"a rebuttal MUST have a `DEC-` that decided it")
|
|
1139
1141
|
elif dec not in dec_ids:
|
|
1140
|
-
r.fail("V27", rel, f"`decision: {dec}`
|
|
1142
|
+
r.fail("V27", rel, f"`decision: {dec}` is not registered in decisions.yaml")
|
|
1141
1143
|
elif dec:
|
|
1142
|
-
r.fail("V27", rel, "`decision:`
|
|
1143
|
-
"
|
|
1144
|
+
r.fail("V27", rel, "`decision:` is set without `overrides:` — "
|
|
1145
|
+
"name which rule is rebutted, or drop `decision:`")
|
|
1144
1146
|
|
|
1145
1147
|
|
|
1146
1148
|
def run_checks(c: Corpus, asof: dt.date) -> Result:
|
|
@@ -1204,7 +1206,7 @@ def gen_dag(c: Corpus) -> dict:
|
|
|
1204
1206
|
waves_out = []
|
|
1205
1207
|
while pending:
|
|
1206
1208
|
ready = sorted(k for k, deps in pending.items() if not (deps - done))
|
|
1207
|
-
if not ready: #
|
|
1209
|
+
if not ready: # cycle — V7 has already reported it
|
|
1208
1210
|
waves_out.append({"blocked": sorted(pending)})
|
|
1209
1211
|
break
|
|
1210
1212
|
waves_out.append({"parallel": ready})
|
|
@@ -1278,25 +1280,25 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
|
|
|
1278
1280
|
done = sum(1 for s in items if _story_status(c, s) == "done")
|
|
1279
1281
|
per_wave.append({"wave": wid, "status": wave.get("status"),
|
|
1280
1282
|
"stories_done": done, "stories_total": len(items),
|
|
1281
|
-
"
|
|
1282
|
-
applicable = 26 # V1..V27
|
|
1283
|
+
"work_progress": _pct(done, len(items))})
|
|
1284
|
+
applicable = 26 # V1..V27 minus V10, which was retired
|
|
1283
1285
|
return {
|
|
1284
|
-
"
|
|
1285
|
-
"
|
|
1286
|
-
"
|
|
1287
|
-
"
|
|
1288
|
-
"
|
|
1289
|
-
"
|
|
1290
|
-
"
|
|
1291
|
-
"
|
|
1286
|
+
"promise_progress": _pct(green, len(counted)),
|
|
1287
|
+
"rtm_rows": {"green": green, "counted": len(counted),
|
|
1288
|
+
"excluded_no_uc": exempt},
|
|
1289
|
+
"work_progress": per_wave,
|
|
1290
|
+
"gate_readiness": _pct(applicable - len(result.red), applicable),
|
|
1291
|
+
"validators_red": result.red,
|
|
1292
|
+
"validators_skipped": dict(sorted(result.skipped.items())),
|
|
1293
|
+
"open_questions": _question_budget(c),
|
|
1292
1294
|
}
|
|
1293
1295
|
|
|
1294
1296
|
|
|
1295
1297
|
def _question_budget(c: Corpus) -> dict:
|
|
1296
|
-
"""
|
|
1298
|
+
"""Counts of all four question lists, compared against the budget in index.yaml.
|
|
1297
1299
|
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
+
The budget is NOT a hard gate. It is reported when a batch exceeds it, because a larger
|
|
1301
|
+
batch is a signal about the pass, not about the corpus.
|
|
1300
1302
|
"""
|
|
1301
1303
|
budget = c.index.get("question_budget") or {}
|
|
1302
1304
|
out: dict[str, object] = {}
|
|
@@ -1310,11 +1312,11 @@ def _question_budget(c: Corpus) -> dict:
|
|
|
1310
1312
|
cap_block = budget.get("blocking_per_component")
|
|
1311
1313
|
if cap_block and c.pcs:
|
|
1312
1314
|
allowed = int(cap_block) * len(c.pcs)
|
|
1313
|
-
out["
|
|
1314
|
-
out["
|
|
1315
|
+
out["blocking_budget"] = allowed
|
|
1316
|
+
out["blocking_over_budget"] = out["blocking"] > allowed
|
|
1315
1317
|
cap_assume = budget.get("assumptions_per_gate")
|
|
1316
1318
|
if cap_assume:
|
|
1317
|
-
out["
|
|
1319
|
+
out["assumptions_budget_per_gate"] = int(cap_assume)
|
|
1318
1320
|
return out
|
|
1319
1321
|
|
|
1320
1322
|
|
|
@@ -1325,19 +1327,19 @@ def _pct(part: int, total: int) -> str:
|
|
|
1325
1327
|
def as_markdown(name: str, payload: dict) -> str:
|
|
1326
1328
|
body = dump(payload)
|
|
1327
1329
|
return (f"# {name}\n\n"
|
|
1328
|
-
f">
|
|
1329
|
-
f"MUST NOT
|
|
1330
|
+
f"> Generated by `.constitution/scripts/validate --generate`. "
|
|
1331
|
+
f"MUST NOT be hand-edited.\n\n"
|
|
1330
1332
|
f"```yaml\n{body}```\n")
|
|
1331
1333
|
|
|
1332
1334
|
|
|
1333
|
-
# -------------------------------------------------------
|
|
1335
|
+
# ------------------------------------------------------- pages for humans
|
|
1334
1336
|
|
|
1335
|
-
PAGE_HEADER = (">
|
|
1336
|
-
"MUST NOT
|
|
1337
|
+
PAGE_HEADER = ("> Generated by `.constitution/scripts/validate --generate`. "
|
|
1338
|
+
"MUST NOT be hand-edited.\n")
|
|
1337
1339
|
|
|
1338
1340
|
|
|
1339
1341
|
def _section(path: Path, heading: str) -> str:
|
|
1340
|
-
"""
|
|
1342
|
+
"""Extract one `## <heading>` section from a markdown file, as-is."""
|
|
1341
1343
|
if not path.exists():
|
|
1342
1344
|
return ""
|
|
1343
1345
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
@@ -1355,7 +1357,7 @@ def _section(path: Path, heading: str) -> str:
|
|
|
1355
1357
|
|
|
1356
1358
|
|
|
1357
1359
|
def _body(path: Path) -> str:
|
|
1358
|
-
"""
|
|
1360
|
+
"""File content without frontmatter and without template comments."""
|
|
1359
1361
|
if not path.exists():
|
|
1360
1362
|
return ""
|
|
1361
1363
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -1370,8 +1372,8 @@ def _body(path: Path) -> str:
|
|
|
1370
1372
|
|
|
1371
1373
|
|
|
1372
1374
|
def page_decisions(c: Corpus) -> str:
|
|
1373
|
-
"""
|
|
1374
|
-
rows_out = ["| id |
|
|
1375
|
+
"""Flat table of every `DEC-`. This is what replaces looking up decisions through the memlog."""
|
|
1376
|
+
rows_out = ["| id | Title | Status | Type | Touches | File |",
|
|
1375
1377
|
"| --- | --- | --- | --- | --- | --- |"]
|
|
1376
1378
|
for dec in c.decs:
|
|
1377
1379
|
touches = ", ".join(f"`{x}`" for x in listy(dec, "touches")) or "—"
|
|
@@ -1382,65 +1384,65 @@ def page_decisions(c: Corpus) -> str:
|
|
|
1382
1384
|
for dec in c.decs:
|
|
1383
1385
|
key = str(dec.get("status"))
|
|
1384
1386
|
counts[key] = counts.get(key, 0) + 1
|
|
1385
|
-
tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "
|
|
1387
|
+
tally = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) or "no decisions yet"
|
|
1386
1388
|
return ("# decisions\n\n" + PAGE_HEADER +
|
|
1387
|
-
"\
|
|
1388
|
-
f"\n**{len(c.decs)}
|
|
1389
|
+
"\nDecisions are no longer looked up through the memlog — the memlog goes back to being just a pass log.\n"
|
|
1390
|
+
f"\n**{len(c.decs)} decisions** — {tally}.\n\n" + "\n".join(rows_out) + "\n")
|
|
1389
1391
|
|
|
1390
1392
|
|
|
1391
1393
|
def page_blueprint(c: Corpus) -> str:
|
|
1392
|
-
"""
|
|
1394
|
+
"""One-page roll-up reviewed at G3. Seven files become one read.
|
|
1393
1395
|
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
+
The UC catalogue, actor list, and domain model stay put in their own component's kernel as
|
|
1397
|
+
their permanent home. This is their view. One fact, one home, one view.
|
|
1396
1398
|
"""
|
|
1397
1399
|
parts = ["# blueprint\n", PAGE_HEADER,
|
|
1398
|
-
"\
|
|
1399
|
-
"
|
|
1400
|
+
"\nThis is what the owner reads at **G3 Blueprint**, instead of seven files. Its "
|
|
1401
|
+
"content is affected by neither `mode` nor `risk_accepted`.\n"]
|
|
1400
1402
|
|
|
1401
1403
|
crit = sum(1 for uc in c.ucs if uc.get("critical"))
|
|
1402
|
-
parts.append(f"\n##
|
|
1404
|
+
parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked "
|
|
1403
1405
|
f"`critical`.\n")
|
|
1404
|
-
parts.append("| id | Use case |
|
|
1406
|
+
parts.append("| id | Use case | Component | Satisfies | critical |")
|
|
1405
1407
|
parts.append("| --- | --- | --- | --- | --- |")
|
|
1406
1408
|
for uc in c.ucs:
|
|
1407
1409
|
sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
|
|
1408
|
-
flag = "
|
|
1410
|
+
flag = "yes" if uc.get("critical") else "no"
|
|
1409
1411
|
parts.append(f"| `{uc.get('id')}` | {_cell(uc.get('title'))} | "
|
|
1410
1412
|
f"`{uc.get('component', '')}` | {sat} | {flag} |")
|
|
1411
1413
|
|
|
1412
|
-
parts.append("\n##
|
|
1414
|
+
parts.append("\n## Actor list\n")
|
|
1413
1415
|
for pc in c.pcs:
|
|
1414
1416
|
pid = str(pc.get("id"))
|
|
1415
1417
|
block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
|
|
1416
1418
|
parts.append(f"\n### {pid} — {pc.get('name', '')}\n")
|
|
1417
1419
|
parts.append(_demote(block) if block
|
|
1418
|
-
else "
|
|
1420
|
+
else "_no § Actor Register in this component's SRS yet._")
|
|
1419
1421
|
|
|
1420
|
-
parts.append("\n##
|
|
1422
|
+
parts.append("\n## Domain model\n")
|
|
1421
1423
|
for pc in c.pcs:
|
|
1422
1424
|
pid = str(pc.get("id"))
|
|
1423
1425
|
block = _body(c.root / f".what/{pid}/03-domain/domain-model.md")
|
|
1424
1426
|
parts.append(f"\n### {pid}\n")
|
|
1425
|
-
parts.append(_demote(block) if block else "
|
|
1427
|
+
parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
|
|
1426
1428
|
|
|
1427
|
-
parts.append("\n##
|
|
1428
|
-
for kind, name in (("db", "
|
|
1429
|
+
parts.append("\n## Three inventories\n")
|
|
1430
|
+
for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
|
|
1429
1431
|
block = _body(c.root / f".how/_platform/inventory-{kind}.md")
|
|
1430
|
-
parts.append(f"\n###
|
|
1431
|
-
parts.append(_demote(block) if block else f"
|
|
1432
|
+
parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
|
|
1433
|
+
parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
|
|
1432
1434
|
|
|
1433
1435
|
return "\n".join(parts) + "\n"
|
|
1434
1436
|
|
|
1435
1437
|
|
|
1436
1438
|
def _cell(value: object, limit: int = 110) -> str:
|
|
1437
|
-
"""
|
|
1439
|
+
"""One table row, shortened. The full-length source stays in the registry — this is just a view."""
|
|
1438
1440
|
text = " ".join(str(value or "").split()).replace("|", "\\|")
|
|
1439
1441
|
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
1440
1442
|
|
|
1441
1443
|
|
|
1442
1444
|
def _demote(block: str, by: int = 2) -> str:
|
|
1443
|
-
"""
|
|
1445
|
+
"""Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
|
|
1444
1446
|
out = []
|
|
1445
1447
|
for line in block.splitlines():
|
|
1446
1448
|
stripped = line.lstrip()
|
|
@@ -1453,7 +1455,7 @@ def _demote(block: str, by: int = 2) -> str:
|
|
|
1453
1455
|
|
|
1454
1456
|
|
|
1455
1457
|
def page_estimate(c: Corpus) -> str:
|
|
1456
|
-
"""
|
|
1458
|
+
"""Table of CANDIDATE tasks. One row per `FR`, since that is a wave's ideal shape."""
|
|
1457
1459
|
mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
|
|
1458
1460
|
risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
|
|
1459
1461
|
str(pc.get("risk_note") or "—")) for pc in c.pcs}
|
|
@@ -1465,22 +1467,22 @@ def page_estimate(c: Corpus) -> str:
|
|
|
1465
1467
|
|
|
1466
1468
|
have_mandays = any(x.get("estimate_mandays") for x in c.caps)
|
|
1467
1469
|
parts = ["# estimate\n", PAGE_HEADER,
|
|
1468
|
-
"\n**
|
|
1469
|
-
"
|
|
1470
|
-
"
|
|
1471
|
-
"
|
|
1470
|
+
"\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
|
|
1471
|
+
"task; the wave in `waves.yaml` is the real one. One row MAY become one wave, and three "
|
|
1472
|
+
"neighboring rows MAY be merged into one — that merge is a human decision made when the "
|
|
1473
|
+
"wave is opened.\n"]
|
|
1472
1474
|
if not have_mandays:
|
|
1473
|
-
parts.append("\n**
|
|
1474
|
-
"
|
|
1475
|
+
parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
|
|
1476
|
+
"this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
|
|
1475
1477
|
|
|
1476
|
-
parts.append("\n| Task | FR | Epic | mode |
|
|
1478
|
+
parts.append("\n| Task | FR | Epic | mode | Exposure | Load | Priority | Depends on | Release |")
|
|
1477
1479
|
parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
1478
1480
|
for fr in c.frs:
|
|
1479
1481
|
cap_id = str(fr.get("capability", ""))
|
|
1480
1482
|
cap = cap_by_id.get(cap_id, {})
|
|
1481
1483
|
pid = str(fr.get("component") or cap.get("component") or "")
|
|
1482
1484
|
risk, note = risk_of.get(pid, ("—", "—"))
|
|
1483
|
-
exposure = "
|
|
1485
|
+
exposure = "not set yet" if risk == "—" else f"`{risk}` — {_cell(note, 60)}"
|
|
1484
1486
|
mandays = cap.get("estimate_mandays")
|
|
1485
1487
|
share = "—"
|
|
1486
1488
|
if mandays:
|
|
@@ -1516,8 +1518,8 @@ def generate(c: Corpus, result: Result) -> list[Path]:
|
|
|
1516
1518
|
md_path.write_text(as_markdown(name, payload), encoding="utf-8")
|
|
1517
1519
|
written += [yaml_path, md_path]
|
|
1518
1520
|
|
|
1519
|
-
#
|
|
1520
|
-
#
|
|
1521
|
+
# Three pages for HUMANS: real markdown tables, with no .yaml twin. What people read is
|
|
1522
|
+
# not wrapped in a yaml fence, and no machine reader demands a second version of it.
|
|
1521
1523
|
for name, render in (("decisions", page_decisions),
|
|
1522
1524
|
("blueprint", page_blueprint),
|
|
1523
1525
|
("estimate", page_estimate)):
|
|
@@ -1532,15 +1534,15 @@ def generate(c: Corpus, result: Result) -> list[Path]:
|
|
|
1532
1534
|
|
|
1533
1535
|
def main(argv: list[str] | None = None) -> int:
|
|
1534
1536
|
parser = argparse.ArgumentParser(
|
|
1535
|
-
prog="validate", description="V1..V27
|
|
1537
|
+
prog="validate", description="V1..V27 and the .control/generated/ generator")
|
|
1536
1538
|
parser.add_argument("--check", action="store_true",
|
|
1537
|
-
help="
|
|
1539
|
+
help="check only; exit non-zero if anything is red")
|
|
1538
1540
|
parser.add_argument("--generate", action="store_true",
|
|
1539
|
-
help="
|
|
1540
|
-
parser.add_argument("--root", default=".", help="
|
|
1541
|
+
help="rewrite .control/generated/ (still runs the check first)")
|
|
1542
|
+
parser.add_argument("--root", default=".", help="repo root (default: current directory)")
|
|
1541
1543
|
parser.add_argument("--asof", default=None,
|
|
1542
|
-
help="
|
|
1543
|
-
"
|
|
1544
|
+
help="reference date for V14, format YYYY-MM-DD (default: today). "
|
|
1545
|
+
"Stated explicitly so a run can be repeated exactly")
|
|
1544
1546
|
args = parser.parse_args(argv)
|
|
1545
1547
|
|
|
1546
1548
|
if not args.check and not args.generate:
|
|
@@ -1548,7 +1550,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1548
1550
|
|
|
1549
1551
|
root = Path(args.root).resolve()
|
|
1550
1552
|
if not (root / ".control" / "registry").is_dir():
|
|
1551
|
-
print(f"validate: {root}
|
|
1553
|
+
print(f"validate: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
|
|
1552
1554
|
return 2
|
|
1553
1555
|
|
|
1554
1556
|
asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
|
|
@@ -1557,21 +1559,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1557
1559
|
|
|
1558
1560
|
if args.generate:
|
|
1559
1561
|
for path in generate(corpus, result):
|
|
1560
|
-
print(f"
|
|
1562
|
+
print(f" wrote {path.relative_to(root).as_posix()}")
|
|
1561
1563
|
|
|
1562
1564
|
if result.findings:
|
|
1563
|
-
print(f"\
|
|
1565
|
+
print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
|
|
1564
1566
|
for finding in sorted(result.findings, key=lambda f: f.sort_key):
|
|
1565
1567
|
print(f" {finding.vid:<4} {finding.subject}: {finding.message}")
|
|
1566
1568
|
else:
|
|
1567
|
-
print("\
|
|
1569
|
+
print("\nGREEN — no findings")
|
|
1568
1570
|
|
|
1569
1571
|
if result.skipped:
|
|
1570
|
-
print("\
|
|
1572
|
+
print("\nSkipped:")
|
|
1571
1573
|
for vid, why in sorted(result.skipped.items()):
|
|
1572
1574
|
print(f" {vid:<4} {why}")
|
|
1573
1575
|
|
|
1574
|
-
print(f"\
|
|
1576
|
+
print(f"\nV14 reference date: {asof.isoformat()}")
|
|
1575
1577
|
return 1 if result.findings else 0
|
|
1576
1578
|
|
|
1577
1579
|
|