wdi-method 0.5.13 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +436 -271
- package/bin/wdi-method.js +198 -5
- package/kit/.constitution/method/README.md +76 -75
- package/kit/.constitution/method/constitution.md +2 -1
- package/kit/.constitution/method/document/architecture-guide.md +10 -10
- package/kit/.constitution/method/document/bmad-guide.md +50 -16
- package/kit/.constitution/method/document/bmad-skill-register.md +74 -71
- package/kit/.constitution/method/document/brief-guide.md +42 -11
- package/kit/.constitution/method/document/corpus-guide.md +146 -11
- package/kit/.constitution/method/document/decision-guide.md +199 -134
- package/kit/.constitution/method/document/delivery-flow-guide.md +154 -83
- package/kit/.constitution/method/document/prd-guide.md +63 -20
- package/kit/.constitution/method/document/sdd-guide.md +5 -4
- package/kit/.constitution/method/document/srs-guide.md +150 -147
- package/kit/.constitution/method/document/templates/addendum.md +70 -71
- package/kit/.constitution/method/document/templates/architecture-spine.md +6 -4
- package/kit/.constitution/method/document/templates/brief.md +132 -110
- package/kit/.constitution/method/document/templates/c4.md +11 -11
- package/kit/.constitution/method/document/templates/contract.md +51 -52
- package/kit/.constitution/method/document/templates/cross-cutting.md +1 -2
- package/kit/.constitution/method/document/templates/design-system.md +0 -1
- package/kit/.constitution/method/document/templates/integration.md +68 -69
- package/kit/.constitution/method/document/templates/inventory.md +0 -1
- package/kit/.constitution/method/document/templates/lc.md +49 -47
- package/kit/.constitution/method/document/templates/lifecycle.md +51 -52
- package/kit/.constitution/method/document/templates/model.md +68 -55
- package/kit/.constitution/method/document/templates/oq.md +45 -45
- package/kit/.constitution/method/document/templates/prd.md +244 -226
- package/kit/.constitution/method/document/templates/questions.md +100 -76
- package/kit/.constitution/method/document/templates/rules.md +46 -47
- package/kit/.constitution/method/document/templates/sdd.md +26 -11
- package/kit/.constitution/method/document/templates/srs.md +46 -25
- package/kit/.constitution/method/document/templates/uc.md +1 -1
- package/kit/.constitution/method/document/templates/ux.md +76 -77
- package/kit/.constitution/method/document/ux-guide.md +115 -99
- package/kit/.constitution/method/language-guide.md +1 -1
- package/kit/.constitution/method/method-glossary.md +29 -16
- package/kit/.constitution/method/scripts/timeline.py +663 -665
- package/kit/.constitution/method/scripts/validate.py +1143 -362
- package/kit/.constitution/method/structure-guide.md +3 -3
- package/kit/.constitution/method/why/README.md +184 -169
- package/kit/.constitution/method/why/artifact-map.md +15 -13
- package/kit/.constitution/method/why/mode-risk-map.md +140 -0
- package/kit/.constitution/method/why/portability.md +1 -1
- package/kit/.constitution/method/why/rationale.md +12 -5
- package/kit/.constitution/project/README.md +2 -2
- package/kit/.constitution/project/codebase-brownfield-guide.md +18 -18
- package/kit/.constitution/project/codebase-conventions-guide.md +18 -18
- package/kit/.constitution/project/codebase-stack-guide.md +18 -18
- package/kit/assets/bmad-custom/bmad-architecture.toml +1 -1
- package/kit/assets/bmad-custom/bmad-correct-course.toml +5 -5
- package/kit/assets/bmad-custom/bmad-prd.toml +9 -3
- package/kit/assets/bmad-custom/bmad-product-brief.toml +6 -2
- package/kit/skills/wdi-blueprint/SKILL.md +71 -10
- package/kit/skills/wdi-build/SKILL.md +233 -122
- package/kit/skills/wdi-component/SKILL.md +8 -6
- package/kit/skills/wdi-decision/SKILL.md +187 -183
- package/kit/skills/wdi-help/SKILL.md +8 -7
- package/kit/skills/wdi-init/SKILL.md +20 -6
- package/kit/skills/wdi-problem/SKILL.md +31 -13
- package/kit/skills/wdi-product/SKILL.md +30 -17
- package/kit/skills/wdi-question/SKILL.md +170 -88
- package/kit/skills/wdi-reconcile/SKILL.md +33 -10
- package/kit/skills/wdi-report/SKILL.md +77 -25
- package/kit/skills/wdi-review/SKILL.md +201 -118
- package/kit/skills/wdi-systematic-debugging/SKILL.md +7 -7
- package/kit/skills/wdi-upgrade/SKILL.md +179 -0
- package/kit/skills/wdi-ux/SKILL.md +35 -11
- package/kit-overlay/AGENTS.md +2 -2
- package/kit-overlay/README.md +76 -75
- package/kit-overlay/constitution.md +2 -1
- package/kit-overlay/portability.md +1 -1
- package/package.json +43 -43
- package/scaffold/.control/product-glossary.md +1 -1
- package/scaffold/.control/registry/goals.yaml +25 -0
- package/scaffold/.control/registry/index.yaml +5 -5
- package/scaffold/.control/registry/specs.yaml +5 -0
- package/scaffold/.control/structure-codebase.md +19 -19
- package/scaffold/.control/structure-document.md +25 -25
- package/kit/.constitution/method/document/templates/rtr.md +0 -61
- package/kit/assets/bmad-custom/bmad-build-auto.toml +0 -10
- package/kit/assets/bmad-custom/bmad-build.toml +0 -52
- package/kit/assets/bmad-custom/bmad-code-review.toml +0 -13
- package/kit/assets/bmad-custom/bmad-retrospective.toml +0 -9
- package/kit/assets/bmad-custom/bmad-spec.toml +0 -16
- package/scaffold/.control/registry/requirements.yaml +0 -15
- package/scaffold/.control/registry/waves.yaml +0 -5
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# requires-python = ">=3.11"
|
|
4
4
|
# dependencies = ["pyyaml>=6"]
|
|
5
5
|
# ///
|
|
6
|
-
"""validate —
|
|
6
|
+
"""validate — goal-has-fr..id-allocated-once plus the .control/generated/ generator.
|
|
7
7
|
|
|
8
8
|
Two modes:
|
|
9
9
|
validate --check exit non-zero if anything is red; writes nothing
|
|
@@ -11,7 +11,7 @@ Two modes:
|
|
|
11
11
|
|
|
12
12
|
Determinism is the contract: two runs over the same data MUST produce the same result.
|
|
13
13
|
That is why there is no unordered iteration, and the one time-dependent input
|
|
14
|
-
(--asof, used by
|
|
14
|
+
(--asof, used by plan-dates) is stated explicitly instead of being taken silently from the wall clock.
|
|
15
15
|
|
|
16
16
|
What is NOT done here: the time dimension from git. `generated/timeline` and
|
|
17
17
|
`generated/report` belong to wdi-report. See 08-project-management.md.
|
|
@@ -30,16 +30,55 @@ from pathlib import Path
|
|
|
30
30
|
|
|
31
31
|
import yaml
|
|
32
32
|
|
|
33
|
+
# Every check, in the order findings are printed. A check is NAMED for what must be true, so a
|
|
34
|
+
# failure reads as "this is what is not true". The numbers these replaced are kept as a `# was V<n>`
|
|
35
|
+
# marker on each def, because decisions and reports frozen in real repos still cite them.
|
|
36
|
+
CHECK_ORDER = (
|
|
37
|
+
"goal-has-fr",
|
|
38
|
+
"fr-has-uc",
|
|
39
|
+
"uc-scheduled",
|
|
40
|
+
"ticket-has-test",
|
|
41
|
+
"nfr-has-enforcer",
|
|
42
|
+
"refs-resolve",
|
|
43
|
+
"no-cycles",
|
|
44
|
+
"applied-dec-touches",
|
|
45
|
+
"locked-gate-passed",
|
|
46
|
+
"parallel-tickets-blocked",
|
|
47
|
+
"lc-registered",
|
|
48
|
+
"review-trace",
|
|
49
|
+
"plan-dates",
|
|
50
|
+
"chain-links",
|
|
51
|
+
"memlog-home",
|
|
52
|
+
"spec-names-release-prd",
|
|
53
|
+
"ticket-status-one-home",
|
|
54
|
+
"defect-root-cause",
|
|
55
|
+
"entity-one-writer",
|
|
56
|
+
"spec-after-g4",
|
|
57
|
+
"high-risk-named",
|
|
58
|
+
"cites-resolve",
|
|
59
|
+
"container-built",
|
|
60
|
+
"custom-room-declared",
|
|
61
|
+
"id-allocated-once",
|
|
62
|
+
)
|
|
63
|
+
|
|
33
64
|
REGISTRY = "control/registry" # tidied up in resolve(); '.control' is what is actually used
|
|
34
65
|
GENERATED_ORDER = ["components", "risks", "dag", "rtm", "status"]
|
|
35
66
|
|
|
36
67
|
# Pages read by HUMANS, not machines: written as real markdown tables, not yaml
|
|
37
|
-
# in a fence.
|
|
38
|
-
|
|
68
|
+
# in a fence. Each has one clear reader. `brief` and `prd-<slug>` are deliverables — see
|
|
69
|
+
# page_brief/page_prd — and are not listed here because a PRD page is one per initiative found
|
|
70
|
+
# on disk, not one fixed name.
|
|
71
|
+
GENERATED_PAGES = ["decisions", "estimate"]
|
|
72
|
+
|
|
73
|
+
# The two trees a HUMAN reads. Every file in them is a projection: prose lifted from the working
|
|
74
|
+
# document at the mirror path, rows rendered from the registry, nothing authored, nothing edited.
|
|
75
|
+
# A skill MUST NOT read them as input — they are output, and the working document is the source.
|
|
76
|
+
RENDERED_WHAT = ".what-rendered"
|
|
77
|
+
RENDERED_HOW = ".how-rendered"
|
|
39
78
|
|
|
40
79
|
MODES = ("catalog", "outline", "guarded", "deep")
|
|
41
80
|
|
|
42
|
-
# Keywords that make a component "sensitive" for
|
|
81
|
+
# Keywords that make a component "sensitive" for high-risk-named. Matched against `risk_note`, which is PROSE in
|
|
43
82
|
# whatever `policy.doc_language` the product chose — so the set is the UNION of both languages rather
|
|
44
83
|
# than a translation. It leans toward disclosing more, which is what this check is for: it discloses,
|
|
45
84
|
# it does not judge. Deliberately short.
|
|
@@ -65,8 +104,8 @@ class Finding:
|
|
|
65
104
|
|
|
66
105
|
@property
|
|
67
106
|
def sort_key(self) -> tuple[int, str, str]:
|
|
68
|
-
|
|
69
|
-
return (
|
|
107
|
+
order = CHECK_ORDER.index(self.vid) if self.vid in CHECK_ORDER else len(CHECK_ORDER)
|
|
108
|
+
return (order, self.subject, self.message)
|
|
70
109
|
|
|
71
110
|
|
|
72
111
|
@dataclass
|
|
@@ -141,33 +180,74 @@ def git(root: Path, *args: str) -> str | None:
|
|
|
141
180
|
# ------------------------------------------------------------------- loading
|
|
142
181
|
|
|
143
182
|
|
|
183
|
+
REQUIREMENT_KEYS = ("goals", "capabilities", "functional", "nonfunctional", "journeys")
|
|
184
|
+
|
|
185
|
+
|
|
144
186
|
@dataclass
|
|
145
187
|
class Corpus:
|
|
146
188
|
root: Path
|
|
147
189
|
requirements: dict
|
|
190
|
+
"""The MERGED view of every requirement file. Read by everything that asks a product-wide
|
|
191
|
+
question — the RTM, the estimate, goal-has-fr/fr-has-uc/nfr-has-enforcer/chain-links. What is merged is decided in `load`."""
|
|
192
|
+
requirement_files: dict[str, dict]
|
|
193
|
+
"""Each requirement file kept separately, keyed by its name: `goals` for the product's `BG`,
|
|
194
|
+
and one `requirements-<slug>` per PRD — the slug being that PRD's folder name.
|
|
195
|
+
|
|
196
|
+
ONE FILE, ONE WRITER, ONE GATE. `wdi-problem` owns `goals.yaml` at G1; `wdi-product` owns its
|
|
197
|
+
own initiative's file at G2, and that is where `CAP` lives too — a capability is declared by a
|
|
198
|
+
feature, one feature is one capability, and a feature lives in exactly one PRD. Co-locating
|
|
199
|
+
`CAP` with `BG` was tried and reverted: the argument for it was that `depends_on` between
|
|
200
|
+
capabilities crosses initiatives, and that turned out to buy nothing, because no-cycles reads the
|
|
201
|
+
MERGED view and never opens a file.
|
|
202
|
+
|
|
203
|
+
The split also makes an `FR`'s initiative STRUCTURAL — before it, nothing in the registry
|
|
204
|
+
recorded which PRD a promise belonged to, and the only way to find out was to grep the PRD's
|
|
205
|
+
prose for its id."""
|
|
148
206
|
usecases: dict
|
|
149
207
|
decisions: dict
|
|
150
208
|
risks: dict
|
|
151
209
|
components: dict
|
|
152
|
-
|
|
210
|
+
specs: dict
|
|
153
211
|
defects: dict
|
|
154
212
|
index: dict
|
|
155
213
|
|
|
156
214
|
@classmethod
|
|
157
215
|
def load(cls, root: Path) -> "Corpus":
|
|
158
216
|
reg = root / ".control" / "registry"
|
|
217
|
+
files: dict[str, dict] = {}
|
|
218
|
+
product = reg / "goals.yaml"
|
|
219
|
+
if product.exists():
|
|
220
|
+
files[product.stem] = load_yaml(product)
|
|
221
|
+
# A corpus from before the split keeps ONE `requirements.yaml`, and it is still read: an
|
|
222
|
+
# install that has not been cut per PRD yet stays green. `update` seeds `goals.yaml`;
|
|
223
|
+
# moving the rows is the owner's act, done by the skill that owns each row.
|
|
224
|
+
legacy = reg / "requirements.yaml"
|
|
225
|
+
if legacy.exists():
|
|
226
|
+
files[legacy.stem] = load_yaml(legacy)
|
|
227
|
+
for path in sorted(reg.glob("requirements-*.yaml")):
|
|
228
|
+
files[path.stem] = load_yaml(path)
|
|
229
|
+
|
|
230
|
+
# Union, not precedence. A row present in two files is a REAL defect — id-allocated-once names it — and
|
|
231
|
+
# preferring one file would hide it instead.
|
|
232
|
+
merged = {key: [row for doc in files.values() for row in rows(doc, key)]
|
|
233
|
+
for key in REQUIREMENT_KEYS}
|
|
159
234
|
return cls(
|
|
160
235
|
root=root,
|
|
161
|
-
requirements=
|
|
236
|
+
requirements=merged,
|
|
237
|
+
requirement_files=files,
|
|
162
238
|
usecases=load_yaml(reg / "usecases.yaml"),
|
|
163
239
|
decisions=load_yaml(reg / "decisions.yaml"),
|
|
164
240
|
risks=load_yaml(reg / "risks.yaml"),
|
|
165
241
|
components=load_yaml(reg / "components.yaml"),
|
|
166
|
-
|
|
242
|
+
specs=load_yaml(reg / "specs.yaml"),
|
|
167
243
|
defects=load_yaml(reg / "defects.yaml"),
|
|
168
244
|
index=load_yaml(reg / "index.yaml"),
|
|
169
245
|
)
|
|
170
246
|
|
|
247
|
+
def requirements_of(self, slug: str) -> dict:
|
|
248
|
+
"""One initiative's own requirement file, empty when it has none yet."""
|
|
249
|
+
return self.requirement_files.get(f"requirements-{slug}", {})
|
|
250
|
+
|
|
171
251
|
# --- shortcuts used repeatedly
|
|
172
252
|
@property
|
|
173
253
|
def goals(self) -> list[dict]:
|
|
@@ -209,23 +289,25 @@ class Corpus:
|
|
|
209
289
|
return rows(self.components, "product_components")
|
|
210
290
|
|
|
211
291
|
@property
|
|
212
|
-
def
|
|
213
|
-
return rows(self.
|
|
292
|
+
def spec_list(self) -> list[dict]:
|
|
293
|
+
return rows(self.specs, "specs")
|
|
214
294
|
|
|
215
295
|
@property
|
|
216
296
|
def defect_list(self) -> list[dict]:
|
|
217
297
|
return rows(self.defects, "defects")
|
|
218
298
|
|
|
219
|
-
def
|
|
220
|
-
"""(
|
|
299
|
+
def tickets(self) -> list[tuple[dict, dict]]:
|
|
300
|
+
"""(spec, ticket) — sorted by ticket id.
|
|
301
|
+
|
|
302
|
+
FLAT. The `epics` level between a spec and its tickets is repealed: it grouped rows and
|
|
303
|
+
bought nothing, and every reader here had to walk through it to reach the row it wanted.
|
|
304
|
+
A ticket names its `component` directly.
|
|
305
|
+
"""
|
|
221
306
|
out = []
|
|
222
|
-
for
|
|
223
|
-
for
|
|
224
|
-
if
|
|
225
|
-
|
|
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))
|
|
307
|
+
for spec in self.spec_list:
|
|
308
|
+
for ticket in sorted(spec.get("tickets") or [], key=lambda t: str(t.get("id", ""))):
|
|
309
|
+
if isinstance(ticket, dict):
|
|
310
|
+
out.append((spec, ticket))
|
|
229
311
|
return out
|
|
230
312
|
|
|
231
313
|
|
|
@@ -239,7 +321,7 @@ def listy(row: dict, key: str) -> list[str]:
|
|
|
239
321
|
# ------------------------------------------------------------------ validators
|
|
240
322
|
|
|
241
323
|
|
|
242
|
-
def
|
|
324
|
+
def goal_has_fr(c: Corpus, r: Result) -> None: # was V1
|
|
243
325
|
"""Every BG has >=1 FR through its CAP, OR states its reason in `no_fr`.
|
|
244
326
|
|
|
245
327
|
A goal MAY be satisfied by an **invariant** rather than a feature. `BG-6` — the data and
|
|
@@ -247,7 +329,7 @@ def v1(c: Corpus, r: Result) -> None:
|
|
|
247
329
|
properties that its own `measure` names, and no `FR` can carry it without being invented. Demanding
|
|
248
330
|
one `FR` there produces a false promise, and a false promise is more expensive than a finding.
|
|
249
331
|
|
|
250
|
-
The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (
|
|
332
|
+
The escape MUST carry a reason, not a boolean — the same shape as `no_uc` on `FR` (fr-has-uc).
|
|
251
333
|
"""
|
|
252
334
|
cap_by_goal: dict[str, list[str]] = {}
|
|
253
335
|
for cap in c.caps:
|
|
@@ -260,10 +342,10 @@ def v1(c: Corpus, r: Result) -> None:
|
|
|
260
342
|
continue
|
|
261
343
|
if str(goal.get("no_fr") or "").strip():
|
|
262
344
|
continue
|
|
263
|
-
r.fail("
|
|
345
|
+
r.fail("goal-has-fr", gid, "has no FR through its CAP and states no reason in `no_fr`")
|
|
264
346
|
|
|
265
347
|
|
|
266
|
-
def
|
|
348
|
+
def fr_has_uc(c: Corpus, r: Result) -> None: # was V2
|
|
267
349
|
covered = {fr for uc in c.ucs for fr in listy(uc, "satisfies")}
|
|
268
350
|
for fr in c.frs:
|
|
269
351
|
fid = str(fr.get("id"))
|
|
@@ -271,43 +353,43 @@ def v2(c: Corpus, r: Result) -> None:
|
|
|
271
353
|
continue
|
|
272
354
|
if str(fr.get("no_uc") or "").strip():
|
|
273
355
|
continue
|
|
274
|
-
r.fail("
|
|
356
|
+
r.fail("fr-has-uc", fid, "has no UC and states no reason in `no_uc`")
|
|
275
357
|
|
|
276
358
|
|
|
277
|
-
def
|
|
278
|
-
"""A UC on a component that a
|
|
359
|
+
def uc_scheduled(c: Corpus, r: Result) -> None: # was V3
|
|
360
|
+
"""A UC on a component that a spec has ALREADY touched MUST be scheduled to a ticket.
|
|
279
361
|
|
|
280
|
-
The old shape demanded this of EVERY UC, at any time. Before the first
|
|
362
|
+
The old shape demanded this of EVERY UC, at any time. Before the first spec that meant the
|
|
281
363
|
entire catalogue was reported red — 56 findings out of 62, and those 56 were the correct state,
|
|
282
|
-
not drift: a
|
|
283
|
-
findings under fifty-six expected ones stops being read, and a validator that is not read
|
|
364
|
+
not drift: a ticket is born in a spec, and there was no spec yet. A validator that drowns six
|
|
365
|
+
real findings under fifty-six expected ones stops being read, and a validator that is not read
|
|
284
366
|
guards nothing.
|
|
285
367
|
|
|
286
|
-
What is guarded now is the actual omission: a
|
|
287
|
-
component is left behind without a
|
|
288
|
-
and `wdi-build` owns it — the same way
|
|
368
|
+
What is guarded now is the actual omission: a spec touches a component, and a UC of that
|
|
369
|
+
component is left behind without a ticket. Full coverage of the whole catalogue is a G5
|
|
370
|
+
question, and `wdi-build` owns it — the same way lc-registered was shifted to spec closing.
|
|
289
371
|
"""
|
|
290
|
-
scheduled = {uc for _,
|
|
291
|
-
touched = {str(
|
|
292
|
-
if not c.
|
|
293
|
-
r.skip("
|
|
372
|
+
scheduled = {uc for _, t in c.tickets() for uc in listy(t, "satisfies")}
|
|
373
|
+
touched = {str(t.get("component")) for _, t in c.tickets() if t.get("component")}
|
|
374
|
+
if not c.spec_list:
|
|
375
|
+
r.skip("uc-scheduled", "no spec yet, so no ticket yet — every unscheduled UC is the correct "
|
|
294
376
|
"state. Full catalogue coverage is checked at G5")
|
|
295
377
|
return
|
|
296
378
|
for uc in c.ucs:
|
|
297
379
|
uid = str(uc.get("id"))
|
|
298
380
|
if uid in scheduled or str(uc.get("component")) not in touched:
|
|
299
381
|
continue
|
|
300
|
-
r.fail("
|
|
301
|
-
f"but this UC is not scheduled to any
|
|
382
|
+
r.fail("uc-scheduled", uid, f"component `{uc.get('component')}` has already been touched by a spec, "
|
|
383
|
+
f"but this UC is not scheduled to any ticket")
|
|
302
384
|
|
|
303
385
|
|
|
304
|
-
def
|
|
305
|
-
for _,
|
|
306
|
-
if not [t for t in listy(
|
|
307
|
-
r.fail("
|
|
386
|
+
def ticket_has_test(c: Corpus, r: Result) -> None: # was V4
|
|
387
|
+
for _, ticket in c.tickets():
|
|
388
|
+
if not [t for t in listy(ticket, "tests") if t.strip()]:
|
|
389
|
+
r.fail("ticket-has-test", str(ticket.get("id")), "has not one named test")
|
|
308
390
|
|
|
309
391
|
|
|
310
|
-
def
|
|
392
|
+
def nfr_has_enforcer(c: Corpus, r: Result) -> None: # was V5
|
|
311
393
|
"""Every NFR has an enforcer, OR states its reason in `no_enforcer`.
|
|
312
394
|
|
|
313
395
|
Two NFRs in this repo cannot have an enforcer, and both are valid: one has already been
|
|
@@ -319,20 +401,19 @@ def v5(c: Corpus, r: Result) -> None:
|
|
|
319
401
|
continue
|
|
320
402
|
if str(nfr.get("no_enforcer") or "").strip():
|
|
321
403
|
continue
|
|
322
|
-
r.fail("
|
|
404
|
+
r.fail("nfr-has-enforcer", str(nfr.get("id")),
|
|
323
405
|
"has no enforcer in `enforced_by` and states no reason in `no_enforcer`")
|
|
324
406
|
|
|
325
407
|
|
|
326
|
-
def
|
|
408
|
+
def refs_resolve(c: Corpus, r: Result) -> None: # was V6
|
|
327
409
|
defined: set[str] = set()
|
|
328
410
|
for group in (c.goals, c.caps, c.frs, c.nfrs, c.ucs, c.decs, c.lcs, c.pcs,
|
|
329
411
|
rows(c.requirements, "journeys"), rows(c.risks, "risks"), c.defect_list):
|
|
330
412
|
defined |= {str(row.get("id")) for row in group if row.get("id") is not None}
|
|
331
|
-
for
|
|
332
|
-
defined.add(str(
|
|
333
|
-
for _,
|
|
334
|
-
defined.add(str(
|
|
335
|
-
defined.add(str(story.get("id")))
|
|
413
|
+
for spec in c.spec_list:
|
|
414
|
+
defined.add(str(spec.get("id")))
|
|
415
|
+
for _, ticket in c.tickets():
|
|
416
|
+
defined.add(str(ticket.get("id")))
|
|
336
417
|
|
|
337
418
|
refs: list[tuple[str, str]] = []
|
|
338
419
|
for cap in c.caps:
|
|
@@ -348,13 +429,15 @@ def v6(c: Corpus, r: Result) -> None:
|
|
|
348
429
|
refs += [(str(dec.get("id")), s) for s in listy(dec, "serves")]
|
|
349
430
|
for defect in c.defect_list:
|
|
350
431
|
refs += [(str(defect.get("id")), v) for v in listy(defect, "violates")]
|
|
351
|
-
for
|
|
352
|
-
refs += [(str(
|
|
353
|
-
|
|
432
|
+
for spec in c.spec_list:
|
|
433
|
+
refs += [(str(spec.get("id")), d) for d in listy(spec, "depends_on")]
|
|
434
|
+
for _, ticket in c.tickets():
|
|
435
|
+
refs += [(str(ticket.get("id")), u) for u in listy(ticket, "satisfies")]
|
|
436
|
+
refs += [(str(ticket.get("id")), b) for b in listy(ticket, "blocked_by")]
|
|
354
437
|
|
|
355
438
|
for owner, target in sorted(set(refs)):
|
|
356
439
|
if target and target not in defined:
|
|
357
|
-
r.fail("
|
|
440
|
+
r.fail("refs-resolve", owner, f"points to `{target}` which does not exist in any registry")
|
|
358
441
|
|
|
359
442
|
|
|
360
443
|
def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
@@ -376,16 +459,23 @@ def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
|
376
459
|
return sorted(set(bad))
|
|
377
460
|
|
|
378
461
|
|
|
379
|
-
def
|
|
462
|
+
def no_cycles(c: Corpus, r: Result) -> None: # was V7
|
|
380
463
|
caps = {str(x.get("id")): listy(x, "depends_on") for x in c.caps}
|
|
381
464
|
for node in _cycles(caps):
|
|
382
|
-
r.fail("
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
465
|
+
r.fail("no-cycles", node, "is part of a `depends_on` cycle among CAPs")
|
|
466
|
+
# Two graphs, two field names, and the difference is not cosmetic. A spec `depends_on` another
|
|
467
|
+
# spec — an ordering between units of delivery. A ticket is `blocked_by` other tickets, which is
|
|
468
|
+
# what the frontier is read from and what the tracker calls the same edge.
|
|
469
|
+
specs = {str(x.get("id")): listy(x, "depends_on") for x in c.spec_list}
|
|
470
|
+
for node in _cycles(specs):
|
|
471
|
+
r.fail("no-cycles", node, "is part of a `depends_on` cycle among specs")
|
|
472
|
+
tickets = {str(t.get("id")): listy(t, "blocked_by") for _, t in c.tickets()}
|
|
473
|
+
for node in _cycles(tickets):
|
|
474
|
+
r.fail("no-cycles", node, "is part of a `blocked_by` cycle among tickets — the frontier is empty "
|
|
475
|
+
"and no ticket can ever start")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def applied_dec_touches(c: Corpus, r: Result) -> None: # was V8
|
|
389
479
|
"""Every `applied` decision names a non-empty `touches`.
|
|
390
480
|
|
|
391
481
|
Replaces the old shape "every accepted decision serves >=1 FR/NFR". A decision like
|
|
@@ -396,11 +486,11 @@ def v8(c: Corpus, r: Result) -> None:
|
|
|
396
486
|
if str(dec.get("status")) != "applied":
|
|
397
487
|
continue
|
|
398
488
|
if not [x for x in listy(dec, "touches") if str(x).strip()]:
|
|
399
|
-
r.fail("
|
|
489
|
+
r.fail("applied-dec-touches", str(dec.get("id")),
|
|
400
490
|
"is applied but `touches` is empty — an application with no file trace")
|
|
401
491
|
|
|
402
492
|
|
|
403
|
-
def
|
|
493
|
+
def locked_gate_passed(c: Corpus, r: Result) -> None: # was V9
|
|
404
494
|
passed = {str(g) for g in (c.index.get("gates_passed") or [])}
|
|
405
495
|
for path in sorted(c.root.glob(".what/**/*.md")) + sorted(c.root.glob(".how/**/*.md")):
|
|
406
496
|
fm = frontmatter(path) or {}
|
|
@@ -409,17 +499,17 @@ def v9(c: Corpus, r: Result) -> None:
|
|
|
409
499
|
gate = str(fm.get("locked_at_gate") or "")
|
|
410
500
|
if gate not in passed:
|
|
411
501
|
rel = path.relative_to(c.root).as_posix()
|
|
412
|
-
r.fail("
|
|
502
|
+
r.fail("locked-gate-passed", rel, f"is locked but gate `{gate or '?'}` is not recorded as passed")
|
|
413
503
|
|
|
414
504
|
|
|
415
|
-
def
|
|
416
|
-
|
|
417
|
-
for
|
|
418
|
-
|
|
505
|
+
def parallel_tickets_blocked(c: Corpus, r: Result) -> None: # was V11
|
|
506
|
+
per_spec: dict[str, list[dict]] = {}
|
|
507
|
+
for spec, ticket in c.tickets():
|
|
508
|
+
per_spec.setdefault(str(spec.get("id")), []).append(ticket)
|
|
419
509
|
|
|
420
|
-
for wid in sorted(
|
|
421
|
-
items =
|
|
422
|
-
edges = {str(
|
|
510
|
+
for wid in sorted(per_spec):
|
|
511
|
+
items = per_spec[wid]
|
|
512
|
+
edges = {str(t.get("id")): set(listy(t, "blocked_by")) for t in items}
|
|
423
513
|
|
|
424
514
|
def reaches(a: str, b: str, seen: set[str] | None = None) -> bool:
|
|
425
515
|
seen = seen or set()
|
|
@@ -438,14 +528,15 @@ def v11(c: Corpus, r: Result) -> None:
|
|
|
438
528
|
continue
|
|
439
529
|
if reaches(lid, rid) or reaches(rid, lid):
|
|
440
530
|
continue
|
|
441
|
-
r.fail("
|
|
442
|
-
f"share touches {shared} with no
|
|
531
|
+
r.fail("parallel-tickets-blocked", f"{lid} + {rid}",
|
|
532
|
+
f"share touches {shared} with no blocking edge between them — MUST NOT run "
|
|
533
|
+
f"in parallel")
|
|
443
534
|
|
|
444
535
|
|
|
445
|
-
def
|
|
446
|
-
"""LC registration is checked when a
|
|
536
|
+
def lc_registered(c: Corpus, r: Result) -> None: # was V12
|
|
537
|
+
"""LC registration is checked when a spec CLOSES, not before a ticket is picked up.
|
|
447
538
|
|
|
448
|
-
The old shape demanded the answer when the information was thinnest. At
|
|
539
|
+
The old shape demanded the answer when the information was thinnest. At spec closing,
|
|
449
540
|
every `touches` already has an area and every boundary already has a name.
|
|
450
541
|
"""
|
|
451
542
|
areas = {str(lc.get("area")) for lc in c.lcs if lc.get("area")}
|
|
@@ -455,22 +546,22 @@ def v12(c: Corpus, r: Result) -> None:
|
|
|
455
546
|
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
456
547
|
|
|
457
548
|
seen: set[tuple[str, str]] = set()
|
|
458
|
-
for
|
|
459
|
-
if str(
|
|
549
|
+
for spec, ticket in c.tickets():
|
|
550
|
+
if str(spec.get("status")) != "closed":
|
|
460
551
|
continue
|
|
461
|
-
for area in listy(
|
|
552
|
+
for area in listy(ticket, "touches"):
|
|
462
553
|
if area not in areas:
|
|
463
|
-
r.fail("
|
|
464
|
-
f"its
|
|
554
|
+
r.fail("lc-registered", str(ticket.get("id")),
|
|
555
|
+
f"its spec is already closed, but `{area}` is not registered as an `area` "
|
|
465
556
|
f"in components.yaml")
|
|
466
|
-
pid = str(
|
|
557
|
+
pid = str(ticket.get("component") or "")
|
|
467
558
|
row = pc_by_id.get(pid)
|
|
468
|
-
if row is None or (str(
|
|
559
|
+
if row is None or (str(spec.get("id")), pid) in seen:
|
|
469
560
|
continue
|
|
470
|
-
seen.add((str(
|
|
561
|
+
seen.add((str(spec.get("id")), pid))
|
|
471
562
|
if c.mode_of(row) in ("guarded", "deep") and not lcs_per_pc.get(pid):
|
|
472
|
-
r.fail("
|
|
473
|
-
f"
|
|
563
|
+
r.fail("lc-registered", f"{spec.get('id')} / {pid}",
|
|
564
|
+
f"spec closed and component with mode `{c.mode_of(row)}` has not one "
|
|
474
565
|
f"`LC` registered")
|
|
475
566
|
|
|
476
567
|
|
|
@@ -488,26 +579,26 @@ def _reviewed_ok(r: Result, rel: str, block: object, need: set[str]) -> None:
|
|
|
488
579
|
# The old test then reported "carries no reviewed trace" about a file that plainly carries one,
|
|
489
580
|
# which is the worst kind of finding: correct-looking, and wrong.
|
|
490
581
|
if not isinstance(block, dict):
|
|
491
|
-
r.fail("
|
|
582
|
+
r.fail("review-trace", rel, "carries no `reviewed` trace with a date and sha")
|
|
492
583
|
return
|
|
493
584
|
# NOT `block.get("sha") or ""` — for the integer 0 that yields "" and reintroduces the very
|
|
494
585
|
# bug this guards. `.get(key, "")` returns the 0, and str(0) is "0", which is truthy.
|
|
495
586
|
if not str(block.get("sha", "")).strip() or not str(block.get("date", "")).strip():
|
|
496
|
-
r.fail("
|
|
587
|
+
r.fail("review-trace", rel, "carries no `reviewed` trace with a date and sha")
|
|
497
588
|
return
|
|
498
589
|
lenses = {str(x) for x in (block.get("lenses") or [])}
|
|
499
590
|
if not lenses:
|
|
500
|
-
r.fail("
|
|
591
|
+
r.fail("review-trace", rel, "the `reviewed` trace names not one lens")
|
|
501
592
|
missing = sorted(need - lenses)
|
|
502
593
|
if missing:
|
|
503
|
-
r.fail("
|
|
594
|
+
r.fail("review-trace", rel,
|
|
504
595
|
f"lenses {missing} MUST be included — that is what the component's `risk_accepted` demands")
|
|
505
596
|
|
|
506
597
|
|
|
507
598
|
def _only_reviewed_block(diff: str) -> bool:
|
|
508
599
|
"""True if a commit's diff on one file ONLY touches the `reviewed:` block.
|
|
509
600
|
|
|
510
|
-
This is the OQ-146 fix. The old
|
|
601
|
+
This is the OQ-146 fix. The old review-trace compared `sha` against the last commit that changed
|
|
511
602
|
the file — but the commit that WRITES the `reviewed:` block always changes the file, and
|
|
512
603
|
writing its own hash into a git commit is cryptographically impossible. As a result every
|
|
513
604
|
artifact that had just been stamped immediately read as "stale review", forever.
|
|
@@ -543,16 +634,28 @@ def _stale_since(c: Corpus, rel: str, sha: str) -> str | None:
|
|
|
543
634
|
return None
|
|
544
635
|
|
|
545
636
|
|
|
546
|
-
def
|
|
637
|
+
def review_trace(c: Corpus, r: Result) -> None: # was V13
|
|
547
638
|
"""Review trace follows review INTENSITY, not document depth.
|
|
548
639
|
|
|
549
640
|
Narrowed to components with `risk_accepted` `low` or `medium`. At `high` the owner has already
|
|
550
641
|
stated they accept the risk, and demanding a trace there is bookkeeping with no buyer.
|
|
642
|
+
|
|
643
|
+
Two narrowings answer the same complaint — that review had become a treadmill:
|
|
644
|
+
|
|
645
|
+
ABSENCE still fails. A binding artifact with no trace at all has never been reviewed.
|
|
646
|
+
STALENESS is ADVISORY. A trace has to be fresh at a gate and at spec close, and review-trace cannot see
|
|
647
|
+
a gate; firing on every commit turned every edit into a re-review. G4's fourth star question
|
|
648
|
+
is what holds the gate on a stale review, and it is asked by a human who can see one.
|
|
649
|
+
LENS SET is demanded BEFORE the component's G4 has passed. That is the first review and the
|
|
650
|
+
review that opens the gate — the two the heavy lens is bought for. A re-review after G4 may
|
|
651
|
+
legitimately run structure + prose, so demanding edge-case-hunter there would force either a
|
|
652
|
+
pointless run or a false trace, and a false trace is worse.
|
|
551
653
|
"""
|
|
654
|
+
stale_advisory: list[str] = []
|
|
552
655
|
watched = [pc for pc in c.pcs
|
|
553
656
|
if str(pc.get("risk_accepted") or "").strip() in ("low", "medium")]
|
|
554
657
|
if not watched:
|
|
555
|
-
r.skip("
|
|
658
|
+
r.skip("review-trace", "no component with risk_accepted low or medium — nothing to guard")
|
|
556
659
|
targets: list[tuple[Path, set[str]]] = []
|
|
557
660
|
if watched:
|
|
558
661
|
targets.append((c.root / ".how/_platform/ARCHITECTURE-SPINE.md", set()))
|
|
@@ -570,32 +673,43 @@ def v13(c: Corpus, r: Result) -> None:
|
|
|
570
673
|
# theater — exactly the ceremony this redesign cut, and a review that cannot fail proves
|
|
571
674
|
# nothing. Once G4 passes, the demand comes back and it is meaningful.
|
|
572
675
|
passed = str(pc.get("g4_passed") or "").strip().lower()
|
|
573
|
-
|
|
574
|
-
|
|
676
|
+
gate_open = passed in ("", "false", "no", "belum")
|
|
677
|
+
# Before G4 passes, the risk-mandated lens set is demanded: that covers the first review and the
|
|
678
|
+
# review that opens the gate. After it passes, a re-review naming any lens satisfies review-trace.
|
|
679
|
+
if not gate_open:
|
|
680
|
+
targets[-1] = (targets[-1][0], set())
|
|
681
|
+
if c.mode_of(pc) != "catalog" and not gate_open:
|
|
682
|
+
targets.append((c.root / f".how/{pid}/SDD-{pid}.md", set()))
|
|
575
683
|
|
|
576
684
|
for path, need in targets:
|
|
577
685
|
fm = frontmatter(path)
|
|
578
686
|
if fm is None:
|
|
579
|
-
continue # not born yet — not
|
|
687
|
+
continue # not born yet — not review-trace's business
|
|
580
688
|
rel = path.relative_to(c.root).as_posix()
|
|
581
689
|
_reviewed_ok(r, rel, fm.get("reviewed"), need)
|
|
582
690
|
block = fm.get("reviewed")
|
|
583
691
|
if isinstance(block, dict) and block.get("sha"):
|
|
584
692
|
stale = _stale_since(c, rel, str(block["sha"]))
|
|
585
693
|
if stale:
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
694
|
+
stale_advisory.append(
|
|
695
|
+
f"{rel} (changed at {stale[:7]}, reviewed at {str(block['sha'])[:7]})")
|
|
696
|
+
|
|
697
|
+
# One trace per SPEC, never one per ticket. Where there is a `SPEC.md` the trace covers it;
|
|
698
|
+
# at size `S` there is none and the trace covers the ticket set as one artifact. Either way the
|
|
699
|
+
# unit reviewed is the spec, which is why the trace lives on the spec row.
|
|
700
|
+
for spec in c.spec_list:
|
|
701
|
+
if not spec.get("tickets"):
|
|
592
702
|
continue
|
|
593
|
-
_reviewed_ok(r, f"
|
|
703
|
+
_reviewed_ok(r, f"specs.yaml:{spec.get('id')}", spec.get("spec_reviewed"),
|
|
594
704
|
{"edge-case-hunter"})
|
|
595
705
|
|
|
706
|
+
if stale_advisory:
|
|
707
|
+
r.skip("review-trace", "advisory — trace stale, re-run before the next gate or spec close: "
|
|
708
|
+
+ ", ".join(sorted(stale_advisory)))
|
|
709
|
+
|
|
596
710
|
|
|
597
|
-
def
|
|
598
|
-
"""CAP ->
|
|
711
|
+
def cap_tickets(c: Corpus) -> dict[str, list[dict]]:
|
|
712
|
+
"""CAP -> ticket, traced through CAP -> FR -> UC -> ticket. No git, no timeline."""
|
|
599
713
|
frs_of: dict[str, list[str]] = {}
|
|
600
714
|
for fr in c.frs:
|
|
601
715
|
frs_of.setdefault(str(fr.get("capability", "")), []).append(str(fr.get("id")))
|
|
@@ -607,19 +721,19 @@ def cap_stories(c: Corpus) -> dict[str, list[dict]]:
|
|
|
607
721
|
for cap in c.caps:
|
|
608
722
|
cid = str(cap.get("id"))
|
|
609
723
|
wanted = {u for fid in frs_of.get(cid, []) for u in ucs_of.get(fid, [])}
|
|
610
|
-
out[cid] = [
|
|
611
|
-
if wanted & set(listy(
|
|
724
|
+
out[cid] = [t for _, t in c.tickets()
|
|
725
|
+
if wanted & set(listy(t, "satisfies"))]
|
|
612
726
|
return out
|
|
613
727
|
|
|
614
728
|
|
|
615
|
-
def
|
|
729
|
+
def plan_dates(c: Corpus, r: Result, asof: dt.date) -> None: # was V14
|
|
616
730
|
"""Overdue-ness is computed from the registry itself — the timeline only reinforces, never gates."""
|
|
617
|
-
by_cap =
|
|
731
|
+
by_cap = cap_tickets(c)
|
|
618
732
|
timeline = load_yaml(c.root / ".control/generated/timeline.yaml")
|
|
619
733
|
listed = {str(row.get("id")) for row in rows(timeline, "capabilities")
|
|
620
734
|
if str(row.get("state")) == "overdue"} if timeline else None
|
|
621
735
|
if listed is None:
|
|
622
|
-
r.skip("
|
|
736
|
+
r.skip("plan-dates", "generated/timeline.yaml does not exist yet — overdue-ness is still computed "
|
|
623
737
|
"from the registry, but its presence in generated/report is not checked")
|
|
624
738
|
|
|
625
739
|
for cap in c.caps:
|
|
@@ -630,100 +744,90 @@ def v14(c: Corpus, r: Result, asof: dt.date) -> None:
|
|
|
630
744
|
try:
|
|
631
745
|
due = dt.date.fromisoformat(end)
|
|
632
746
|
except ValueError:
|
|
633
|
-
r.fail("
|
|
747
|
+
r.fail("plan-dates", cid, f"`planned_end` `{end}` is not an ISO date")
|
|
634
748
|
continue
|
|
635
749
|
items = by_cap.get(cid, [])
|
|
636
|
-
closed = bool(items) and all(
|
|
750
|
+
closed = bool(items) and all(_ticket_status(c, t) == "done" for t in items)
|
|
637
751
|
if closed or due >= asof:
|
|
638
752
|
continue
|
|
639
753
|
late = (asof - due).days
|
|
640
754
|
if listed is not None and cid not in listed:
|
|
641
|
-
r.fail("
|
|
755
|
+
r.fail("plan-dates", cid, f"{late} days overdue with nothing delivered, and not flagged "
|
|
642
756
|
f"`overdue` in generated/timeline")
|
|
643
757
|
else:
|
|
644
|
-
r.fail("
|
|
758
|
+
r.fail("plan-dates", cid, f"{late} days overdue with nothing closed")
|
|
645
759
|
|
|
646
760
|
|
|
647
|
-
def
|
|
761
|
+
def chain_links(c: Corpus, r: Result) -> None: # was V15
|
|
648
762
|
for cap in c.caps:
|
|
649
763
|
if not str(cap.get("goal") or "").strip():
|
|
650
|
-
r.fail("
|
|
764
|
+
r.fail("chain-links", str(cap.get("id")), "does not point to a `goal`")
|
|
651
765
|
for fr in c.frs:
|
|
652
766
|
if not str(fr.get("capability") or "").strip():
|
|
653
|
-
r.fail("
|
|
767
|
+
r.fail("chain-links", str(fr.get("id")), "does not point to a `capability`")
|
|
654
768
|
|
|
655
769
|
|
|
656
|
-
def
|
|
770
|
+
def memlog_home(c: Corpus, r: Result) -> None: # was V16
|
|
657
771
|
for path in sorted((c.root / ".control/memlog").glob("*.md")):
|
|
658
772
|
fm = frontmatter(path) or {}
|
|
659
773
|
rel = path.relative_to(c.root).as_posix()
|
|
660
774
|
artifact = str(fm.get("artifact") or "")
|
|
661
775
|
if not artifact:
|
|
662
|
-
r.fail("
|
|
776
|
+
r.fail("memlog-home", rel, "has no `artifact:` in frontmatter")
|
|
663
777
|
elif not (c.root / artifact).exists():
|
|
664
|
-
r.fail("
|
|
778
|
+
r.fail("memlog-home", rel, f"`artifact:` points to `{artifact}` which does not exist")
|
|
665
779
|
for layer in (".what", ".how"):
|
|
666
780
|
for stray in sorted(c.root.glob(f"{layer}/**/.memlog.md")):
|
|
667
|
-
r.fail("
|
|
781
|
+
r.fail("memlog-home", stray.relative_to(c.root).as_posix(),
|
|
668
782
|
"a memlog MUST NOT live inside the corpus")
|
|
669
783
|
|
|
670
784
|
|
|
671
|
-
def
|
|
672
|
-
for
|
|
673
|
-
wid = str(
|
|
674
|
-
if not str(
|
|
675
|
-
r.fail("
|
|
676
|
-
slugs = listy(
|
|
785
|
+
def spec_names_release_prd(c: Corpus, r: Result) -> None: # was V17
|
|
786
|
+
for spec in c.spec_list:
|
|
787
|
+
wid = str(spec.get("id"))
|
|
788
|
+
if not str(spec.get("release") or "").strip():
|
|
789
|
+
r.fail("spec-names-release-prd", wid, "does not name a `release`")
|
|
790
|
+
slugs = listy(spec, "prd")
|
|
677
791
|
if not slugs:
|
|
678
|
-
r.fail("
|
|
792
|
+
r.fail("spec-names-release-prd", wid, "does not name a `prd`")
|
|
679
793
|
for slug in slugs:
|
|
680
794
|
if not (c.root / ".what/_prd" / slug).is_dir():
|
|
681
|
-
r.fail("
|
|
795
|
+
r.fail("spec-names-release-prd", wid, f"`prd: {slug}` has no folder .what/_prd/{slug}/")
|
|
682
796
|
|
|
683
797
|
|
|
684
|
-
def
|
|
685
|
-
|
|
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
|
-
|
|
798
|
+
def ticket_status_one_home(c: Corpus, r: Result) -> None: # was V18
|
|
799
|
+
"""A ticket's status has exactly ONE home, and the corpus can point to it.
|
|
699
800
|
|
|
700
|
-
|
|
701
|
-
|
|
801
|
+
That home is the ticket file. `specs.yaml` carries the traceability index — what a ticket
|
|
802
|
+
satisfies, what blocks it, what it touches, what tests it — and a `status` key copied in beside
|
|
803
|
+
them is the second home this validator exists to prevent.
|
|
702
804
|
|
|
703
|
-
|
|
704
|
-
|
|
805
|
+
The file's LOCATION is ours only as far as the root: `{spec_folder}/issues/`. Below that the
|
|
806
|
+
shape belongs to the engine that writes them — one file per ticket, numbered from `01` in
|
|
807
|
+
dependency order — and that number is the tail of the ticket id, which is why `SPEC-3-01`
|
|
808
|
+
finds `issues/01-*.md`.
|
|
705
809
|
"""
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
810
|
+
for spec, ticket in c.tickets():
|
|
811
|
+
sid = str(ticket.get("id"))
|
|
812
|
+
if str(ticket.get("status") or "").strip():
|
|
813
|
+
r.fail("ticket-status-one-home", sid, "carries a `status` in specs.yaml — status lives in the ticket "
|
|
814
|
+
"file, and two homes for one fact is how a registry starts lying")
|
|
815
|
+
folder = _spec_folder(spec, ticket)
|
|
816
|
+
if not folder:
|
|
817
|
+
r.fail("ticket-status-one-home", sid, "its spec does not name a `spec_folder`")
|
|
710
818
|
continue
|
|
711
|
-
|
|
712
|
-
if
|
|
819
|
+
matches = _ticket_files(c, spec, ticket)
|
|
820
|
+
if not matches:
|
|
821
|
+
r.fail("ticket-status-one-home", sid, f"has no ticket file under {folder}issues/")
|
|
713
822
|
continue
|
|
714
|
-
if
|
|
715
|
-
r.fail("
|
|
716
|
-
|
|
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")
|
|
823
|
+
if _read_status(matches[0]) == "unknown":
|
|
824
|
+
r.fail("ticket-status-one-home", sid, "ticket file states no status — neither a `**Status:**` line nor "
|
|
825
|
+
"`status:` in frontmatter")
|
|
722
826
|
|
|
723
827
|
|
|
724
828
|
PLATFORM = "_platform"
|
|
725
829
|
CROSS_CUTTING = ".how/_platform/cross-cutting.md"
|
|
726
|
-
# The section heading
|
|
830
|
+
# The section heading entity-one-writer looks for. A heading a SCRIPT matches is a machine-facing key, and
|
|
727
831
|
# `language-guide.md` says a key is always English — so the template writes the English one and
|
|
728
832
|
# this is what a new corpus carries. The Indonesian form is kept as a READER-side alias, exactly
|
|
729
833
|
# like `yes|ya`: a corpus written before this MUST NOT be migrated for a regex.
|
|
@@ -731,7 +835,7 @@ PLATFORM_DATA_HEADINGS = ("Platform-owned", "Milik platform")
|
|
|
731
835
|
PLATFORM_DATA_HEADING = PLATFORM_DATA_HEADINGS[0]
|
|
732
836
|
|
|
733
837
|
|
|
734
|
-
def
|
|
838
|
+
def entity_one_writer(c: Corpus, r: Result) -> None: # was V21
|
|
735
839
|
"""One domain entity has EXACTLY ONE owner authorized to write it.
|
|
736
840
|
|
|
737
841
|
The owner is a Product Component, OR `_platform` for an entity with no single component
|
|
@@ -750,7 +854,7 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
750
854
|
pid = str(pc.get("id"))
|
|
751
855
|
for entity in listy(pc, "owns"):
|
|
752
856
|
if entity in owner and owner[entity] != pid:
|
|
753
|
-
r.fail("
|
|
857
|
+
r.fail("entity-one-writer", entity,
|
|
754
858
|
f"claimed as `owns` by both `{owner[entity]}` and `{pid}` — one entity MUST "
|
|
755
859
|
f"have exactly one owner")
|
|
756
860
|
else:
|
|
@@ -759,7 +863,7 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
759
863
|
platform = listy(c.components, "platform_owns")
|
|
760
864
|
for entity in platform:
|
|
761
865
|
if entity in owner:
|
|
762
|
-
r.fail("
|
|
866
|
+
r.fail("entity-one-writer", entity,
|
|
763
867
|
f"claimed as `platform_owns` and also as `owns` by `{owner[entity]}` — "
|
|
764
868
|
f"`{PLATFORM}` is not a second path for an entity that already has an owner")
|
|
765
869
|
else:
|
|
@@ -781,7 +885,7 @@ def v21(c: Corpus, r: Result) -> None:
|
|
|
781
885
|
# _platform_documented checks above.
|
|
782
886
|
continue
|
|
783
887
|
if not [d for d in listy(fr, "defers_to") if str(d).strip()]:
|
|
784
|
-
r.fail("
|
|
888
|
+
r.fail("entity-one-writer", fid,
|
|
785
889
|
f"promises to write `{entity}` which `{own}` owns, without `defers_to` "
|
|
786
890
|
f"pointing to an `FR` owned by that owner")
|
|
787
891
|
|
|
@@ -813,30 +917,30 @@ def _platform_documented(c: Corpus, r: Result, entities: list[str]) -> None:
|
|
|
813
917
|
path = c.root / CROSS_CUTTING
|
|
814
918
|
text = path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
|
815
919
|
if not any(h.lower() in text.lower() for h in PLATFORM_DATA_HEADINGS):
|
|
816
|
-
r.skip("
|
|
920
|
+
r.skip("entity-one-writer", f"`{CROSS_CUTTING}` has no `{PLATFORM_DATA_HEADING}` section yet — "
|
|
817
921
|
f"{len(entities)} entities with platform_owns are not documented yet: "
|
|
818
922
|
+ ", ".join(sorted(entities)))
|
|
819
923
|
return
|
|
820
924
|
for entity in sorted(entities):
|
|
821
925
|
if entity not in text:
|
|
822
|
-
r.fail("
|
|
926
|
+
r.fail("entity-one-writer", entity,
|
|
823
927
|
f"claimed as `platform_owns` but not named in `{CROSS_CUTTING}` — "
|
|
824
928
|
f"a platform that owns data MUST document it")
|
|
825
929
|
|
|
826
930
|
|
|
827
|
-
def
|
|
828
|
-
"""A
|
|
931
|
+
def spec_after_g4(c: Corpus, r: Result) -> None: # was V22
|
|
932
|
+
"""A spec MUST NOT touch a component whose G4 has not passed and whose mode is not catalog.
|
|
829
933
|
|
|
830
934
|
`catalog` skips G4 on purpose, so it is not an exception — it is part of the rule.
|
|
831
935
|
"""
|
|
832
936
|
pc_by_id = {str(x.get("id")): x for x in c.pcs}
|
|
833
937
|
seen: set[tuple[str, str]] = set()
|
|
834
|
-
for
|
|
835
|
-
pid = str(
|
|
938
|
+
for spec, ticket in c.tickets():
|
|
939
|
+
pid = str(ticket.get("component") or "")
|
|
836
940
|
row = pc_by_id.get(pid)
|
|
837
941
|
if row is None:
|
|
838
942
|
continue
|
|
839
|
-
key = (str(
|
|
943
|
+
key = (str(spec.get("id")), pid)
|
|
840
944
|
if key in seen:
|
|
841
945
|
continue
|
|
842
946
|
seen.add(key)
|
|
@@ -844,20 +948,26 @@ def v22(c: Corpus, r: Result) -> None:
|
|
|
844
948
|
if mode == "catalog":
|
|
845
949
|
continue
|
|
846
950
|
if mode not in MODES:
|
|
847
|
-
r.fail("
|
|
951
|
+
r.fail("spec-after-g4", pid, f"`mode: {mode}` is not one of {list(MODES)}")
|
|
848
952
|
continue
|
|
849
953
|
passed = row.get("g4_passed")
|
|
850
954
|
if not passed or str(passed).strip().lower() in ("false", "no", "belum"):
|
|
851
|
-
r.fail("
|
|
852
|
-
f"
|
|
955
|
+
r.fail("spec-after-g4", f"{spec.get('id')} / {pid}",
|
|
956
|
+
f"spec touches a component with mode `{mode}` whose `g4_passed` has not been set")
|
|
853
957
|
|
|
854
958
|
|
|
855
|
-
def
|
|
856
|
-
"""`risk_accepted: high` on a sensitive component demands a
|
|
959
|
+
def high_risk_named(c: Corpus, r: Result) -> None: # was V23
|
|
960
|
+
"""`risk_accepted: high` on a sensitive component demands a NAMED acceptance in `risk_accepted_by`.
|
|
857
961
|
|
|
858
962
|
On a component that touches nothing on that list, `high` is FREE. The control is
|
|
859
963
|
disclosure, not veto — the owner may still choose quickly, just not without knowing what
|
|
860
964
|
they are wagering.
|
|
965
|
+
|
|
966
|
+
It used to demand that the record be a `DEC-` file, which made accepting a risk cost a document and
|
|
967
|
+
put the fact in a second home while `components.yaml` already had the field for it. A person and a
|
|
968
|
+
date, written where the risk is set, IS the disclosure. A `DEC-` id is still accepted and still has
|
|
969
|
+
to resolve: a repo pointing at a decision is making a checkable claim, and a pointer to a decision
|
|
970
|
+
that does not exist is worse than no pointer.
|
|
861
971
|
"""
|
|
862
972
|
known = {str(x.get("id")) for x in c.decs}
|
|
863
973
|
for pc in c.pcs:
|
|
@@ -870,14 +980,14 @@ def v23(c: Corpus, r: Result) -> None:
|
|
|
870
980
|
continue
|
|
871
981
|
ref = str(pc.get("risk_accepted_by") or "").strip()
|
|
872
982
|
if not ref:
|
|
873
|
-
r.fail("
|
|
874
|
-
f"`risk_accepted: high` while `risk_note` mentions {hits},
|
|
875
|
-
f"`risk_accepted_by`
|
|
876
|
-
elif ref not in known:
|
|
877
|
-
r.fail("
|
|
983
|
+
r.fail("high-risk-named", pid,
|
|
984
|
+
f"`risk_accepted: high` while `risk_note` mentions {hits}, and "
|
|
985
|
+
f"`risk_accepted_by` names nobody — a person and a date is enough")
|
|
986
|
+
elif ref.startswith("DEC-") and ref not in known:
|
|
987
|
+
r.fail("high-risk-named", pid, f"`risk_accepted_by: {ref}` does not exist in decisions.yaml")
|
|
878
988
|
|
|
879
989
|
|
|
880
|
-
def
|
|
990
|
+
def defect_root_cause(c: Corpus, r: Result) -> None: # was V20
|
|
881
991
|
needs_link = {"requirement", "architecture"}
|
|
882
992
|
for defect in c.defect_list:
|
|
883
993
|
did = str(defect.get("id"))
|
|
@@ -885,9 +995,9 @@ def v20(c: Corpus, r: Result) -> None:
|
|
|
885
995
|
if cause not in needs_link:
|
|
886
996
|
continue
|
|
887
997
|
if not listy(defect, "violates"):
|
|
888
|
-
r.fail("
|
|
998
|
+
r.fail("defect-root-cause", did, f"has `root_cause` `{cause}` but `violates` is empty")
|
|
889
999
|
if str(defect.get("status")) == "fixed" and not str(defect.get("decision") or "").strip():
|
|
890
|
-
r.fail("
|
|
1000
|
+
r.fail("defect-root-cause", did,
|
|
891
1001
|
f"closed as fixed with root_cause `{cause}` without an accompanying `DEC-`")
|
|
892
1002
|
|
|
893
1003
|
|
|
@@ -905,7 +1015,7 @@ FROZEN = (".what/",)
|
|
|
905
1015
|
# by hand, so nobody may fix it where it is reported. It also renders registry values inside
|
|
906
1016
|
# backticks, which makes a frozen `DEC-` `touches:` entry look like a live citation: the 0.5.0
|
|
907
1017
|
# layout move surfaced three of those, all of them correct history. Fix the source or leave it.
|
|
908
|
-
DERIVED = (".control/generated/",)
|
|
1018
|
+
DERIVED = (".control/generated/", RENDERED_WHAT + "/", RENDERED_HOW + "/")
|
|
909
1019
|
# A path a run WILL PRODUCE, not one a document cites as existing. A rule stating "this pass's
|
|
910
1020
|
# memlog lands at X" names a DESTINATION; demanding X already exist would demand the run has already
|
|
911
1021
|
# happened.
|
|
@@ -920,14 +1030,17 @@ DESTINATION = (
|
|
|
920
1030
|
#
|
|
921
1031
|
# `.constitution/method/` is portable explanation. Its citations teach where a thing GOES — "the
|
|
922
1032
|
# glossary lives at `.control/product-glossary.md`" — and are not this product's claim that it has
|
|
923
|
-
# one yet. Scanning it made
|
|
1033
|
+
# one yet. Scanning it made cites-resolve unsatisfiable in both directions: a fresh install went RED on 69
|
|
924
1034
|
# such lines before G1 had run, and a mature one stayed quiet only by accident. A method guide that
|
|
925
1035
|
# cites a method file IS checked, but here in the package where it can be fixed — see
|
|
926
1036
|
# tests/kit-integrity.test.mjs. A product cannot fix a guide `update` overwrites.
|
|
927
1037
|
#
|
|
928
|
-
# The BMad skill trees are the same class under whichever host the installer wrote them to.
|
|
929
|
-
#
|
|
930
|
-
# template failing, which reads as a defect in that product rather than an omission
|
|
1038
|
+
# The BMad skill trees are the same class under whichever host the installer wrote them to. EVERY
|
|
1039
|
+
# host MUST be listed, and there are three: `.claude/skills/bmad-` alone left the `.agents/` copy of
|
|
1040
|
+
# one identical template failing, which reads as a defect in that product rather than an omission
|
|
1041
|
+
# here. Listing two then left the `.agent/` copy failing the same way — the singular host is a
|
|
1042
|
+
# separate directory from `.agents/`, not a prefix of it, and a product carrying all three saw the
|
|
1043
|
+
# same worked example reported once per host it was missing.
|
|
931
1044
|
#
|
|
932
1045
|
# `wdi-*` skills are OURS and are deliberately NOT here. They MUST NOT cite a product file that
|
|
933
1046
|
# does not exist unless the cite is a placeholder.
|
|
@@ -935,9 +1048,17 @@ INSTALLED = (
|
|
|
935
1048
|
".constitution/method/",
|
|
936
1049
|
".claude/skills/bmad-",
|
|
937
1050
|
".agents/skills/bmad-",
|
|
1051
|
+
".agent/skills/bmad-",
|
|
1052
|
+
# The method's own skills are installed by `update` the same way the guides are, and a consumer
|
|
1053
|
+
# can no more fix a cite in one than in a guide. Their cites are checked where the fix is made —
|
|
1054
|
+
# `kit-integrity` in the package. And one of them, `wdi-upgrade`, deliberately names paths of the
|
|
1055
|
+
# OLD shape to probe for them; read as claims, every probe would be a finding.
|
|
1056
|
+
".claude/skills/wdi-",
|
|
1057
|
+
".agents/skills/wdi-",
|
|
1058
|
+
".agent/skills/wdi-",
|
|
938
1059
|
)
|
|
939
1060
|
|
|
940
|
-
# The extension list is deliberately WIDE. A narrow one does not make
|
|
1061
|
+
# The extension list is deliberately WIDE. A narrow one does not make cites-resolve safer — it makes it
|
|
941
1062
|
# silent: a product written in a language missing from the list has its code citations
|
|
942
1063
|
# unchecked, and nothing says so. Adding one is cheap; a gap is invisible.
|
|
943
1064
|
CITE_RE = re.compile(
|
|
@@ -980,7 +1101,7 @@ def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
|
980
1101
|
return sorted(out)
|
|
981
1102
|
|
|
982
1103
|
|
|
983
|
-
def
|
|
1104
|
+
def cites_resolve(c: Corpus, r: Result) -> None: # was V24
|
|
984
1105
|
"""A path citation inside a document that STATES what currently holds MUST resolve.
|
|
985
1106
|
|
|
986
1107
|
This is the mechanical half of `wdi-reconcile`'s Evidence check, and it is the only way to know
|
|
@@ -1009,9 +1130,9 @@ def v24(c: Corpus, r: Result) -> None:
|
|
|
1009
1130
|
if cited.startswith(DESTINATION):
|
|
1010
1131
|
continue
|
|
1011
1132
|
if not (c.root / cited).exists():
|
|
1012
|
-
r.fail("
|
|
1133
|
+
r.fail("cites-resolve", rel, f"cites `{cited}` which does not exist")
|
|
1013
1134
|
if not scanned:
|
|
1014
|
-
r.skip("
|
|
1135
|
+
r.skip("cites-resolve", "no file was scanned")
|
|
1015
1136
|
|
|
1016
1137
|
|
|
1017
1138
|
CTR_HEADING = re.compile(r"^###\s+(.+?)\s*$", re.M)
|
|
@@ -1033,7 +1154,7 @@ def map_container_headings(root: Path) -> list[str] | None:
|
|
|
1033
1154
|
return [m.group(1).strip().strip("`") for m in CTR_HEADING.finditer(rest)]
|
|
1034
1155
|
|
|
1035
1156
|
|
|
1036
|
-
def
|
|
1157
|
+
def container_built(c: Corpus, r: Result) -> None: # was V25
|
|
1037
1158
|
"""A container's `built` and its four consequences, plus the PC x container matrix.
|
|
1038
1159
|
|
|
1039
1160
|
A container EXISTS inside the boundary whether or not we write its content, and that is what
|
|
@@ -1047,55 +1168,70 @@ def v25(c: Corpus, r: Result) -> None:
|
|
|
1047
1168
|
"""
|
|
1048
1169
|
containers = rows(c.components, "containers")
|
|
1049
1170
|
if not containers:
|
|
1050
|
-
r.skip("
|
|
1171
|
+
r.skip("container-built", "`containers:` is not registered yet")
|
|
1051
1172
|
return
|
|
1052
1173
|
|
|
1053
1174
|
built: dict[str, bool] = {}
|
|
1054
1175
|
for ctr in containers:
|
|
1055
1176
|
cid = str(ctr.get("id") or "").strip()
|
|
1056
1177
|
if not cid:
|
|
1057
|
-
r.fail("
|
|
1178
|
+
r.fail("container-built", "containers", "a container has no `id`")
|
|
1058
1179
|
continue
|
|
1059
1180
|
flag = ctr.get("built")
|
|
1060
1181
|
if not isinstance(flag, bool):
|
|
1061
|
-
r.fail("
|
|
1182
|
+
r.fail("container-built", cid, "`built` MUST be a bool — true if we write its content, false if someone else implements it")
|
|
1062
1183
|
continue
|
|
1063
1184
|
built[cid] = flag
|
|
1064
1185
|
|
|
1065
1186
|
# (1) code-map heading = EXACTLY a container with `built: true`
|
|
1066
1187
|
headings = map_container_headings(c.root)
|
|
1067
1188
|
if headings is None:
|
|
1068
|
-
r.fail("
|
|
1189
|
+
r.fail("container-built", ".control/structure-codebase.md", "the code map does not exist, so container headings cannot be compared")
|
|
1069
1190
|
else:
|
|
1070
1191
|
for h in headings:
|
|
1071
1192
|
if h not in built:
|
|
1072
|
-
r.fail("
|
|
1193
|
+
r.fail("container-built", f"code map §{h}", "heading is not a registered container — register it, or it is not a container")
|
|
1073
1194
|
elif not built[h]:
|
|
1074
|
-
r.fail("
|
|
1195
|
+
r.fail("container-built", f"code map §{h}", "`built: false` MUST NOT have a heading — there is no code of ours inside it")
|
|
1075
1196
|
for cid, flag in sorted(built.items()):
|
|
1076
1197
|
if flag and cid not in headings:
|
|
1077
|
-
r.fail("
|
|
1198
|
+
r.fail("container-built", cid, "`built: true` MUST have a heading in the code map")
|
|
1078
1199
|
|
|
1079
1200
|
# (2) `built: false` MUST NOT be used by an LC, and (3) MUST NOT appear in a PC's `containers:`
|
|
1201
|
+
#
|
|
1202
|
+
# An EMPTY container is legal while the answer does not exist yet. Screens are known as soon as
|
|
1203
|
+
# DESIGN.md is written at G2; containers are born at G3. Blocking the UX landing until then bought
|
|
1204
|
+
# nothing and cost a half-placed artifact somebody had to come back to.
|
|
1205
|
+
#
|
|
1206
|
+
# The deadline is derived, not scheduled: once the LC's own PC lists containers, the information
|
|
1207
|
+
# exists and the answer is owed. Silent before G3, automatic after, and no gate in between.
|
|
1208
|
+
pc_containers = {str(pc.get("id")): listy(pc, "containers") for pc in c.pcs}
|
|
1080
1209
|
for lc in c.lcs:
|
|
1081
1210
|
ctr = str(lc.get("container") or "").strip()
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1211
|
+
lid = str(lc.get("id") or "LC-?")
|
|
1212
|
+
if not ctr:
|
|
1213
|
+
if pc_containers.get(str(lc.get("component") or "")):
|
|
1214
|
+
r.fail("container-built", lid,
|
|
1215
|
+
"has no `container` while its Product Component already lists one — the answer "
|
|
1216
|
+
"exists now, so a screen with no deployable home is a gap rather than a wait")
|
|
1217
|
+
continue
|
|
1218
|
+
if built.get(ctr) is False:
|
|
1219
|
+
r.fail("container-built", lid, f"names container `{ctr}` which is `built: false`")
|
|
1220
|
+
elif ctr not in built:
|
|
1221
|
+
r.fail("container-built", lid, f"names container `{ctr}` which is not registered")
|
|
1086
1222
|
|
|
1087
1223
|
# (4) PC x container matrix — this field is its SSOT, and it MUST be complete at G3
|
|
1088
1224
|
for pc in c.pcs:
|
|
1089
1225
|
pid = str(pc.get("id") or "?")
|
|
1090
1226
|
listed = listy(pc, "containers")
|
|
1091
1227
|
if not listed:
|
|
1092
|
-
r.fail("
|
|
1228
|
+
r.fail("container-built", pid, "`containers:` is empty — every PC MUST live in at least one container (a G3 debt)")
|
|
1093
1229
|
continue
|
|
1094
1230
|
for ctr in listed:
|
|
1095
1231
|
if ctr not in built:
|
|
1096
|
-
r.fail("
|
|
1232
|
+
r.fail("container-built", pid, f"`containers:` names `{ctr}` which is not registered")
|
|
1097
1233
|
elif not built[ctr]:
|
|
1098
|
-
r.fail("
|
|
1234
|
+
r.fail("container-built", pid, f"`containers:` names `{ctr}` which is `built: false` — the data lives there by definition, so the row tells us nothing")
|
|
1099
1235
|
|
|
1100
1236
|
# (5) L3 — only for `built: true`, and only ones that hold more than one PC
|
|
1101
1237
|
pcs_per: dict[str, list[str]] = {}
|
|
@@ -1105,79 +1241,26 @@ def v25(c: Corpus, r: Result) -> None:
|
|
|
1105
1241
|
for path in sorted((c.root / ".how" / "_platform").glob("c4-l3-*.md")):
|
|
1106
1242
|
cid = path.name[len("c4-l3-"):-len(".md")]
|
|
1107
1243
|
if cid not in built:
|
|
1108
|
-
r.fail("
|
|
1244
|
+
r.fail("container-built", path.relative_to(c.root).as_posix(),
|
|
1109
1245
|
f"L3 for `{cid}` which is not a registered container")
|
|
1110
1246
|
elif not built[cid]:
|
|
1111
|
-
r.fail("
|
|
1247
|
+
r.fail("container-built", path.relative_to(c.root).as_posix(),
|
|
1112
1248
|
f"`{cid}` `built: false` MUST NOT have an L3 — not one box inside it is ours to draw")
|
|
1113
1249
|
for cid, pids in sorted(pcs_per.items()):
|
|
1114
1250
|
if built.get(cid) and len(pids) > 1:
|
|
1115
1251
|
l3 = c.root / ".how" / "_platform" / f"c4-l3-{cid}.md"
|
|
1116
1252
|
if not l3.exists():
|
|
1117
|
-
r.fail("
|
|
1253
|
+
r.fail("container-built", cid, f"holds {len(pids)} PCs, so `c4-l3-{cid}.md` MUST exist")
|
|
1118
1254
|
|
|
1119
1255
|
|
|
1120
|
-
UC_ROW_RE = re.compile(r"^\|\s*(UC-\d+)\s*\|([^\n]*)$", re.M)
|
|
1121
1256
|
|
|
1122
1257
|
# The `critical` column value is machine-matched, so it is machine-facing and its canonical form
|
|
1123
1258
|
# is English `yes`. `ya` is still accepted: a corpus that wrote it before this rule took effect
|
|
1124
1259
|
# MUST NOT be forced to migrate just so a regex can be tidier. The word boundary keeps `ya` from
|
|
1125
1260
|
# 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
1261
|
|
|
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
1262
|
|
|
1139
|
-
|
|
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:
|
|
1263
|
+
def custom_room_declared(c: Corpus, r: Result) -> None: # was V27
|
|
1181
1264
|
"""Every file in the custom room MUST declare itself, and a rebuttal MUST have a decision.
|
|
1182
1265
|
|
|
1183
1266
|
The `.constitution/project/` room exists so product-specific rules have a home that `update`
|
|
@@ -1194,23 +1277,23 @@ def v27(c: Corpus, r: Result) -> None:
|
|
|
1194
1277
|
README.md authored in the package, not in the product
|
|
1195
1278
|
constitution.md Articles 1, 2, 5 — carries `status:`, and Article 4 governs it
|
|
1196
1279
|
codebase-*-guide.md the stack, conventions, and brownfield guides — `status:` plus
|
|
1197
|
-
`ratified_by:`, and they are filled by a
|
|
1280
|
+
`ratified_by:`, and they are filled by a spec's distillation
|
|
1198
1281
|
|
|
1199
1282
|
Demanding `scope:` and `purpose:` of those would be demanding a declaration of files whose
|
|
1200
|
-
role is already fixed by the layout. What
|
|
1283
|
+
role is already fixed by the layout. What custom-room-declared exists to guard is the file somebody ADDS.
|
|
1201
1284
|
|
|
1202
1285
|
Only `.md` is looked at. A script in the room — `inventory-readers.py` is the one the package
|
|
1203
1286
|
seeds — is not an ad-hoc rule and has nowhere to put frontmatter.
|
|
1204
1287
|
"""
|
|
1205
1288
|
room = c.root / ".constitution" / "project"
|
|
1206
1289
|
if not room.is_dir():
|
|
1207
|
-
r.skip("
|
|
1290
|
+
r.skip("custom-room-declared", "the `.constitution/project/` room does not exist yet — it is seeded at install")
|
|
1208
1291
|
return
|
|
1209
1292
|
structural = {"README.md", "constitution.md"}
|
|
1210
1293
|
files = [p for p in sorted(room.rglob("*.md"))
|
|
1211
1294
|
if p.name not in structural and not p.name.startswith("codebase-")]
|
|
1212
1295
|
if not files:
|
|
1213
|
-
r.skip("
|
|
1296
|
+
r.skip("custom-room-declared", "the `.constitution/project/` room is empty, and that is a valid state — "
|
|
1214
1297
|
"a generic rule MUST NOT be moved here just to give the room content")
|
|
1215
1298
|
return
|
|
1216
1299
|
dec_ids = {str(d.get("id")) for d in c.decs}
|
|
@@ -1218,48 +1301,134 @@ def v27(c: Corpus, r: Result) -> None:
|
|
|
1218
1301
|
rel = path.relative_to(c.root).as_posix()
|
|
1219
1302
|
fm = frontmatter(path)
|
|
1220
1303
|
if fm is None:
|
|
1221
|
-
r.fail("
|
|
1304
|
+
r.fail("custom-room-declared", rel, "has no frontmatter")
|
|
1222
1305
|
continue
|
|
1223
1306
|
if str(fm.get("scope") or "").strip() != "project":
|
|
1224
|
-
r.fail("
|
|
1307
|
+
r.fail("custom-room-declared", rel, "`scope:` MUST contain exactly `project`")
|
|
1225
1308
|
if not str(fm.get("purpose") or "").strip():
|
|
1226
|
-
r.fail("
|
|
1309
|
+
r.fail("custom-room-declared", rel, "`purpose:` is empty — one line: what this rule guards")
|
|
1227
1310
|
over = str(fm.get("overrides") or "").strip()
|
|
1228
1311
|
dec = str(fm.get("decision") or "").strip()
|
|
1229
1312
|
if over:
|
|
1230
1313
|
if not (c.root / over).exists():
|
|
1231
|
-
r.fail("
|
|
1314
|
+
r.fail("custom-room-declared", rel, f"`overrides:` points to `{over}` which does not exist — "
|
|
1232
1315
|
f"the rebutted rule may already be gone")
|
|
1233
1316
|
if not dec:
|
|
1234
|
-
r.fail("
|
|
1317
|
+
r.fail("custom-room-declared", rel, "rebuts a generic rule without `decision:` — "
|
|
1235
1318
|
"a rebuttal MUST have a `DEC-` that decided it")
|
|
1236
1319
|
elif dec not in dec_ids:
|
|
1237
|
-
r.fail("
|
|
1320
|
+
r.fail("custom-room-declared", rel, f"`decision: {dec}` is not registered in decisions.yaml")
|
|
1238
1321
|
elif dec:
|
|
1239
|
-
r.fail("
|
|
1322
|
+
r.fail("custom-room-declared", rel, "`decision:` is set without `overrides:` — "
|
|
1240
1323
|
"name which rule is rebutted, or drop `decision:`")
|
|
1241
1324
|
|
|
1242
1325
|
|
|
1326
|
+
def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
|
|
1327
|
+
"""One id, one row — across every file the requirement registry is split into.
|
|
1328
|
+
|
|
1329
|
+
The split into `goals.yaml` plus one `requirements-<slug>.yaml` per PRD bought
|
|
1330
|
+
one writer per file, and cost exactly one new failure mode: two initiatives written in parallel
|
|
1331
|
+
can both allocate `FR-12`. Nothing before this saw it. refs-resolve builds its `defined` set as a SET, so
|
|
1332
|
+
a duplicate id silently collapsed into one entry and every reference to it still resolved.
|
|
1333
|
+
|
|
1334
|
+
The id sequence is global to the product — that is what lets a ticket say `satisfies: [FR-12]`
|
|
1335
|
+
without also naming which PRD it came from. This is the check that keeps it true now that the
|
|
1336
|
+
rows live in more than one file.
|
|
1337
|
+
"""
|
|
1338
|
+
where: dict[str, list[str]] = {}
|
|
1339
|
+
for name, doc in sorted(c.requirement_files.items()):
|
|
1340
|
+
for key in REQUIREMENT_KEYS:
|
|
1341
|
+
for row in rows(doc, key):
|
|
1342
|
+
rid = str(row.get("id") or "").strip()
|
|
1343
|
+
if rid:
|
|
1344
|
+
where.setdefault(rid, []).append(name)
|
|
1345
|
+
for rid, sources in sorted(where.items()):
|
|
1346
|
+
if len(sources) > 1:
|
|
1347
|
+
r.fail("id-allocated-once", rid, "is declared in " + ", ".join(f"`{s}.yaml`" for s in sources)
|
|
1348
|
+
+ " — an id is allocated ONCE, and the sequence is global to the product")
|
|
1349
|
+
if not where:
|
|
1350
|
+
r.skip("id-allocated-once", "no requirement row in any registry file yet")
|
|
1351
|
+
|
|
1352
|
+
|
|
1243
1353
|
def run_checks(c: Corpus, asof: dt.date) -> Result:
|
|
1244
1354
|
r = Result()
|
|
1245
|
-
|
|
1246
|
-
|
|
1355
|
+
# uc-catalogue-matches (was V26) is RETIRED: it compared the SRS's hand-written UC table with
|
|
1356
|
+
# `usecases.yaml`. The table is a pointer now and the rendered SRS shows the rows, so there are
|
|
1357
|
+
# no two copies left to compare.
|
|
1358
|
+
# V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
|
|
1359
|
+
# retrospective it archived was the only thing spec size `L` ever decided. Both went together.
|
|
1360
|
+
for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, cites_resolve, container_built, custom_room_declared, id_allocated_once):
|
|
1247
1361
|
fn(c, r)
|
|
1248
|
-
|
|
1362
|
+
plan_dates(c, r, asof)
|
|
1249
1363
|
return r
|
|
1250
1364
|
|
|
1251
1365
|
|
|
1252
1366
|
# ------------------------------------------------------------------ generator
|
|
1253
1367
|
|
|
1254
1368
|
|
|
1255
|
-
def
|
|
1256
|
-
|
|
1369
|
+
def _spec_folder(spec: dict, ticket: dict) -> str:
|
|
1370
|
+
"""`spec_folder` belongs to the SPEC — one per spec, not one per spec x component.
|
|
1371
|
+
|
|
1372
|
+
A ticket row is still read as a fallback, because that is where a repo written before the
|
|
1373
|
+
rename put it, and a validator that reports every one of those rows as missing a folder is
|
|
1374
|
+
reporting the migration, not a defect.
|
|
1375
|
+
"""
|
|
1376
|
+
return str(spec.get("spec_folder") or ticket.get("spec_folder") or "").strip()
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _ticket_files(c: Corpus, spec: dict, ticket: dict) -> list[Path]:
|
|
1380
|
+
"""`{spec_folder}/issues/<NN>-<slug>.md`, found by the number at the tail of the ticket id.
|
|
1381
|
+
|
|
1382
|
+
The full id is tried too, so a product that names its files after the whole id is not punished
|
|
1383
|
+
for a convention this method never demanded of it.
|
|
1384
|
+
"""
|
|
1385
|
+
folder = _spec_folder(spec, ticket)
|
|
1257
1386
|
if not folder:
|
|
1387
|
+
return []
|
|
1388
|
+
tid = str(ticket.get("id") or "")
|
|
1389
|
+
issues = c.root / folder / "issues"
|
|
1390
|
+
for stem in (tid.rsplit("-", 1)[-1], tid):
|
|
1391
|
+
if not stem:
|
|
1392
|
+
continue
|
|
1393
|
+
found = sorted(issues.glob(f"{stem}-*.md"))
|
|
1394
|
+
if found:
|
|
1395
|
+
return found
|
|
1396
|
+
return []
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def status_in(text: str) -> str:
|
|
1400
|
+
"""Two spellings, one home.
|
|
1401
|
+
|
|
1402
|
+
The engine writes `**Status:** ready-for-agent` as a body line, because a ticket file is a
|
|
1403
|
+
tracker payload and trackers do not read YAML frontmatter. A product that keeps `status:` in
|
|
1404
|
+
frontmatter instead is not wrong, so both are read. Reading two spellings of one field in one
|
|
1405
|
+
file is not two homes; it is one home written two ways.
|
|
1406
|
+
|
|
1407
|
+
Takes TEXT, not a path, because timeline.py asks the same question of historical revisions
|
|
1408
|
+
pulled out of git, where there is no file to open.
|
|
1409
|
+
"""
|
|
1410
|
+
m = FM.match(text)
|
|
1411
|
+
if m:
|
|
1412
|
+
try:
|
|
1413
|
+
fm = yaml.safe_load(m.group(1)) or {}
|
|
1414
|
+
except yaml.YAMLError:
|
|
1415
|
+
fm = {}
|
|
1416
|
+
if isinstance(fm, dict) and str(fm.get("status") or "").strip():
|
|
1417
|
+
return str(fm["status"]).strip()
|
|
1418
|
+
m = re.search(r"^\*\*Status:\*\*\s*(.+?)\s*$", text, re.M)
|
|
1419
|
+
return m.group(1).strip() if m else "unknown"
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
def _read_status(path: Path) -> str:
|
|
1423
|
+
try:
|
|
1424
|
+
return status_in(path.read_text(encoding="utf-8"))
|
|
1425
|
+
except OSError:
|
|
1258
1426
|
return "unknown"
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _ticket_status(c: Corpus, spec: dict, ticket: dict) -> str:
|
|
1430
|
+
matches = _ticket_files(c, spec, ticket)
|
|
1431
|
+
return _read_status(matches[0]) if matches else "unknown"
|
|
1263
1432
|
|
|
1264
1433
|
|
|
1265
1434
|
def gen_components(c: Corpus) -> dict:
|
|
@@ -1290,25 +1459,26 @@ def gen_risks(c: Corpus) -> dict:
|
|
|
1290
1459
|
|
|
1291
1460
|
|
|
1292
1461
|
def gen_dag(c: Corpus) -> dict:
|
|
1462
|
+
"""The frontier, spec by spec: each `parallel` group is what can be started at once."""
|
|
1293
1463
|
out = []
|
|
1294
|
-
|
|
1295
|
-
for
|
|
1296
|
-
|
|
1297
|
-
for wid in sorted(
|
|
1298
|
-
items =
|
|
1464
|
+
per_spec: dict[str, list[dict]] = {}
|
|
1465
|
+
for spec, ticket in c.tickets():
|
|
1466
|
+
per_spec.setdefault(str(spec.get("id")), []).append(ticket)
|
|
1467
|
+
for wid in sorted(per_spec):
|
|
1468
|
+
items = per_spec[wid]
|
|
1299
1469
|
done: set[str] = set()
|
|
1300
|
-
pending = {str(
|
|
1301
|
-
|
|
1470
|
+
pending = {str(t.get("id")): set(listy(t, "blocked_by")) for t in items}
|
|
1471
|
+
order = []
|
|
1302
1472
|
while pending:
|
|
1303
1473
|
ready = sorted(k for k, deps in pending.items() if not (deps - done))
|
|
1304
|
-
if not ready: # cycle —
|
|
1305
|
-
|
|
1474
|
+
if not ready: # cycle — no-cycles has already reported it
|
|
1475
|
+
order.append({"blocked": sorted(pending)})
|
|
1306
1476
|
break
|
|
1307
|
-
|
|
1477
|
+
order.append({"parallel": ready})
|
|
1308
1478
|
done |= set(ready)
|
|
1309
1479
|
for k in ready:
|
|
1310
1480
|
pending.pop(k)
|
|
1311
|
-
out.append({"
|
|
1481
|
+
out.append({"spec": wid, "order": order})
|
|
1312
1482
|
return {"dag": out}
|
|
1313
1483
|
|
|
1314
1484
|
|
|
@@ -1318,10 +1488,10 @@ def gen_rtm(c: Corpus) -> dict:
|
|
|
1318
1488
|
for uc in c.ucs:
|
|
1319
1489
|
for fr in listy(uc, "satisfies"):
|
|
1320
1490
|
ucs_for_fr.setdefault(fr, []).append(str(uc.get("id")))
|
|
1321
|
-
|
|
1322
|
-
for
|
|
1323
|
-
for uc in listy(
|
|
1324
|
-
|
|
1491
|
+
tickets_for_uc: dict[str, list[tuple[dict, dict]]] = {}
|
|
1492
|
+
for spec, ticket in c.tickets():
|
|
1493
|
+
for uc in listy(ticket, "satisfies"):
|
|
1494
|
+
tickets_for_uc.setdefault(uc, []).append((spec, ticket))
|
|
1325
1495
|
decs_for: dict[str, list[str]] = {}
|
|
1326
1496
|
for dec in c.decs:
|
|
1327
1497
|
for target in listy(dec, "serves"):
|
|
@@ -1336,28 +1506,28 @@ def gen_rtm(c: Corpus) -> dict:
|
|
|
1336
1506
|
ucs = sorted(ucs_for_fr.get(fid, []))
|
|
1337
1507
|
if not ucs:
|
|
1338
1508
|
exempt = bool(str(fr.get("no_uc") or "").strip())
|
|
1339
|
-
lines.append({**base, "UC": "", "
|
|
1509
|
+
lines.append({**base, "UC": "", "ticket": "", "spec": "", "release": "",
|
|
1340
1510
|
"test": [], "status": "", "green": False,
|
|
1341
1511
|
"exempt": exempt,
|
|
1342
1512
|
"broken_at": "no_uc" if exempt else "UC"})
|
|
1343
1513
|
continue
|
|
1344
1514
|
for uid in ucs:
|
|
1345
|
-
pairs = sorted(
|
|
1515
|
+
pairs = sorted(tickets_for_uc.get(uid, []), key=lambda p: str(p[1].get("id")))
|
|
1346
1516
|
if not pairs:
|
|
1347
|
-
lines.append({**base, "UC": uid, "
|
|
1517
|
+
lines.append({**base, "UC": uid, "ticket": "", "spec": "", "release": "",
|
|
1348
1518
|
"test": [], "status": "", "green": False, "exempt": False,
|
|
1349
|
-
"broken_at": "
|
|
1519
|
+
"broken_at": "ticket"})
|
|
1350
1520
|
continue
|
|
1351
|
-
for
|
|
1352
|
-
status =
|
|
1353
|
-
tests = listy(
|
|
1521
|
+
for spec, ticket in pairs:
|
|
1522
|
+
status = _ticket_status(c, spec, ticket)
|
|
1523
|
+
tests = listy(ticket, "tests")
|
|
1354
1524
|
broken = ""
|
|
1355
1525
|
if not tests:
|
|
1356
1526
|
broken = "test"
|
|
1357
1527
|
elif status != "done":
|
|
1358
1528
|
broken = "status"
|
|
1359
|
-
lines.append({**base, "UC": uid, "
|
|
1360
|
-
"
|
|
1529
|
+
lines.append({**base, "UC": uid, "ticket": str(ticket.get("id")),
|
|
1530
|
+
"spec": str(spec.get("id")), "release": str(spec.get("release", "")),
|
|
1361
1531
|
"test": tests, "status": status, "exempt": False,
|
|
1362
1532
|
"green": broken == "", "broken_at": broken})
|
|
1363
1533
|
return {"rtm": lines}
|
|
@@ -1368,20 +1538,20 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
|
|
|
1368
1538
|
counted = [line for line in lines if not line.get("exempt")]
|
|
1369
1539
|
exempt = len(lines) - len(counted)
|
|
1370
1540
|
green = sum(1 for line in counted if line.get("green"))
|
|
1371
|
-
|
|
1372
|
-
for
|
|
1373
|
-
wid = str(
|
|
1374
|
-
items = [
|
|
1375
|
-
done = sum(1 for
|
|
1376
|
-
|
|
1377
|
-
"
|
|
1541
|
+
per_spec = []
|
|
1542
|
+
for spec in c.spec_list:
|
|
1543
|
+
wid = str(spec.get("id"))
|
|
1544
|
+
items = [t for sp, t in c.tickets() if str(sp.get("id")) == wid]
|
|
1545
|
+
done = sum(1 for t in items if _ticket_status(c, spec, t) == "done")
|
|
1546
|
+
per_spec.append({"spec": wid, "status": spec.get("status"),
|
|
1547
|
+
"tickets_done": done, "tickets_total": len(items),
|
|
1378
1548
|
"work_progress": _pct(done, len(items))})
|
|
1379
|
-
applicable = 26 #
|
|
1549
|
+
applicable = 26 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
|
|
1380
1550
|
return {
|
|
1381
1551
|
"promise_progress": _pct(green, len(counted)),
|
|
1382
1552
|
"rtm_rows": {"green": green, "counted": len(counted),
|
|
1383
1553
|
"excluded_no_uc": exempt},
|
|
1384
|
-
"work_progress":
|
|
1554
|
+
"work_progress": per_spec,
|
|
1385
1555
|
"gate_readiness": _pct(applicable - len(result.red), applicable),
|
|
1386
1556
|
"validators_red": result.red,
|
|
1387
1557
|
"validators_skipped": dict(sorted(result.skipped.items())),
|
|
@@ -1397,13 +1567,38 @@ def _question_budget(c: Corpus) -> dict:
|
|
|
1397
1567
|
"""
|
|
1398
1568
|
budget = c.index.get("question_budget") or {}
|
|
1399
1569
|
out: dict[str, object] = {}
|
|
1570
|
+
# `Whose` splits the open rows by who can act, and whether anyone may act yet. A flat "25 open"
|
|
1571
|
+
# is what made a six-item list read as twenty-five items of homework; the owner's number is the
|
|
1572
|
+
# only one they can do anything about. templates/questions.md owns the vocabulary.
|
|
1573
|
+
whose: dict[str, int] = {"owner": 0, "run": 0, "frozen": 0, "unstated": 0}
|
|
1400
1574
|
for name in ("blocking", "assumptions", "external", "answered"):
|
|
1401
1575
|
path = c.root / ".control/questions" / f"{name}.md"
|
|
1402
1576
|
rows_n = 0
|
|
1403
1577
|
if path.exists():
|
|
1404
|
-
|
|
1405
|
-
|
|
1578
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
1579
|
+
if not line.startswith("| OQ-"):
|
|
1580
|
+
continue
|
|
1581
|
+
rows_n += 1
|
|
1582
|
+
# `answered` is closed; `external` needs no `Whose` — sitting in that file already
|
|
1583
|
+
# says who acts, and it is reported as its own line rather than folded in.
|
|
1584
|
+
if name in ("answered", "external"):
|
|
1585
|
+
continue
|
|
1586
|
+
cells = [x.strip() for x in line.strip().strip("|").split("|")]
|
|
1587
|
+
key = "unstated"
|
|
1588
|
+
for cell in cells:
|
|
1589
|
+
low = cell.lower()
|
|
1590
|
+
if low == "owner":
|
|
1591
|
+
key = "owner"
|
|
1592
|
+
elif low.startswith("run:"):
|
|
1593
|
+
key = "run"
|
|
1594
|
+
elif low.startswith("frozen:"):
|
|
1595
|
+
key = "frozen"
|
|
1596
|
+
else:
|
|
1597
|
+
continue
|
|
1598
|
+
break
|
|
1599
|
+
whose[key] += 1
|
|
1406
1600
|
out[name] = rows_n
|
|
1601
|
+
out["open_by_whose"] = whose
|
|
1407
1602
|
cap_block = budget.get("blocking_per_component")
|
|
1408
1603
|
if cap_block and c.pcs:
|
|
1409
1604
|
allowed = int(cap_block) * len(c.pcs)
|
|
@@ -1433,18 +1628,26 @@ PAGE_HEADER = ("> Generated by `.constitution/method/scripts/validate.py --gener
|
|
|
1433
1628
|
"MUST NOT be hand-edited.\n")
|
|
1434
1629
|
|
|
1435
1630
|
|
|
1631
|
+
def _hname(heading: str) -> str:
|
|
1632
|
+
"""A heading's NAME: lower-cased, with any leading `3.` / `4.2` / `§5` numbering dropped. Section
|
|
1633
|
+
numbers moved between kits (Non-Goals was §7 in one, §5 in the next) and a migrated document may
|
|
1634
|
+
keep its old ones — a page that keyed on the number went blind on exactly those corpora."""
|
|
1635
|
+
return re.sub(r"^\s*(?:§\s*)?\d+(?:\.\d+)*\.?\s*", "", heading.strip().lower())
|
|
1636
|
+
|
|
1637
|
+
|
|
1436
1638
|
def _section(path: Path, heading: str) -> str:
|
|
1437
|
-
"""Extract one `## <heading>` section from a markdown file, as-is."""
|
|
1639
|
+
"""Extract one `## <heading>` section from a markdown file, as-is. Matched by name, not number."""
|
|
1438
1640
|
if not path.exists():
|
|
1439
1641
|
return ""
|
|
1440
1642
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1441
1643
|
out: list[str] = []
|
|
1442
1644
|
inside = False
|
|
1645
|
+
want = _hname(heading)
|
|
1443
1646
|
for line in lines:
|
|
1444
1647
|
if line.startswith("## "):
|
|
1445
1648
|
if inside:
|
|
1446
1649
|
break
|
|
1447
|
-
inside = line[3:]
|
|
1650
|
+
inside = _hname(line[3:]).startswith(want)
|
|
1448
1651
|
continue
|
|
1449
1652
|
if inside:
|
|
1450
1653
|
out.append(line)
|
|
@@ -1486,37 +1689,30 @@ def page_decisions(c: Corpus) -> str:
|
|
|
1486
1689
|
|
|
1487
1690
|
|
|
1488
1691
|
def page_blueprint(c: Corpus) -> str:
|
|
1489
|
-
"""
|
|
1692
|
+
"""The ONE page read at G3 — and it MUST answer all seven of G3's questions, or the owner is
|
|
1693
|
+
back to opening files. Before this it answered four: the actor list, the UC catalogue, the
|
|
1694
|
+
domain model, and the inventories. The three it left to other files were the AD-N invariants,
|
|
1695
|
+
the cross-component business rules, and the glossary — questions 5, 6, and 7.
|
|
1490
1696
|
|
|
1491
|
-
|
|
1492
|
-
|
|
1697
|
+
Every table here is a VIEW. The UC catalogue's home is `usecases.yaml`; the invariants' home is
|
|
1698
|
+
the spine; the PC x container matrix's home is `components.yaml`. One fact, one home, one view.
|
|
1493
1699
|
"""
|
|
1494
1700
|
parts = ["# blueprint\n", PAGE_HEADER,
|
|
1495
|
-
"\nThis is what the owner reads at **G3 Blueprint
|
|
1496
|
-
"content is affected by neither `mode` nor
|
|
1701
|
+
"\nThis is what the owner reads at **G3 Blueprint** — one page, every one of the gate's "
|
|
1702
|
+
"seven questions answerable from it. Its content is affected by neither `mode` nor "
|
|
1703
|
+
"`risk_accepted`.\n"]
|
|
1497
1704
|
|
|
1498
1705
|
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"`
|
|
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} |")
|
|
1706
|
+
parts.append(f"\n## Use case catalogue\n\n**{len(c.ucs)} use cases**, {crit} marked `critical`. "
|
|
1707
|
+
f"Rendered from `usecases.yaml`.\n\n{_uc_table(c.ucs)}")
|
|
1508
1708
|
|
|
1509
|
-
parts.append("\n## Actor list\n")
|
|
1709
|
+
parts.append("\n\n## Actor list\n")
|
|
1510
1710
|
for pc in c.pcs:
|
|
1511
1711
|
pid = str(pc.get("id"))
|
|
1512
1712
|
block = _section(c.root / f".what/{pid}/SRS-{pid}.md", "Actor Register")
|
|
1513
|
-
# A Product Component carries no `name` in `components.yaml` — only a container does — so
|
|
1514
|
-
# this heading rendered as `### settings — `, with an orphaned separator, for every
|
|
1515
|
-
# component of every product. The separator belongs to the name, not to the heading.
|
|
1516
1713
|
name = str(pc.get("name") or "").strip()
|
|
1517
1714
|
parts.append(f"\n### {pid} — {name}\n" if name else f"\n### {pid}\n")
|
|
1518
|
-
parts.append(_demote(block) if block
|
|
1519
|
-
else "_no § Actor Register in this component's SRS yet._")
|
|
1715
|
+
parts.append(_demote(block) if block else "_no § Actor Register in this component's SRS yet._")
|
|
1520
1716
|
|
|
1521
1717
|
parts.append("\n## Domain model\n")
|
|
1522
1718
|
for pc in c.pcs:
|
|
@@ -1525,12 +1721,40 @@ def page_blueprint(c: Corpus) -> str:
|
|
|
1525
1721
|
parts.append(f"\n### {pid}\n")
|
|
1526
1722
|
parts.append(_demote(block) if block else "_no `03-domain/domain-model.md` yet._")
|
|
1527
1723
|
|
|
1528
|
-
parts.append("\n##
|
|
1724
|
+
parts.append("\n## Business rules binding more than one component\n")
|
|
1725
|
+
br = _section(c.root / ".what" / "business-rules.md", "Rules")
|
|
1726
|
+
parts.append(f"\n{br}\n" if br else "\n_no `.what/business-rules.md` § Rules yet._\n")
|
|
1727
|
+
|
|
1728
|
+
parts.append("\n## Invariants — the spine\n")
|
|
1729
|
+
parts.append("\nRendered from `.how/_platform/ARCHITECTURE-SPINE.md`. G3 asks of every row: does it "
|
|
1730
|
+
"name the concrete failure it prevents, and would breaking it in one component break "
|
|
1731
|
+
"another?\n\n" + _ad_table(_ad_blocks(c.root)))
|
|
1732
|
+
|
|
1733
|
+
parts.append("\n\n## Containers, and which components live in each\n")
|
|
1734
|
+
parts.append("\nRendered from `components.yaml` — the table C4 L2 used to carry by hand.\n\n"
|
|
1735
|
+
+ _pc_container_table(c))
|
|
1736
|
+
c4 = _body(c.root / ".how" / "_platform" / "c4-l2-containers.md")
|
|
1737
|
+
if c4:
|
|
1738
|
+
parts.append(f"\n\n### C4 L2 — `c4-l2-containers.md`\n\n{_demote(c4)}")
|
|
1739
|
+
|
|
1740
|
+
parts.append("\n\n## Three inventories\n")
|
|
1529
1741
|
for kind, name in (("db", "table"), ("api", "endpoint"), ("screen", "screen")):
|
|
1530
1742
|
block = _body(c.root / f".how/_platform/inventory-{kind}.md")
|
|
1531
1743
|
parts.append(f"\n### List of {name}s — `inventory-{kind}.md`\n")
|
|
1532
1744
|
parts.append(_demote(block) if block else f"_no `inventory-{kind}.md` yet._")
|
|
1533
1745
|
|
|
1746
|
+
parts.append("\n## Error envelope\n")
|
|
1747
|
+
cc = c.root / ".how" / "_platform" / "cross-cutting.md"
|
|
1748
|
+
env = _section(cc, "Error envelope")
|
|
1749
|
+
cat = _section(cc, "Error catalogue")
|
|
1750
|
+
parts.append(f"\n{env}\n" if env else "\n_no § Error envelope in `cross-cutting.md` yet._\n")
|
|
1751
|
+
if cat:
|
|
1752
|
+
parts.append(f"\n### Error catalogue\n\n{cat}\n")
|
|
1753
|
+
|
|
1754
|
+
parts.append("\n## Glossary\n")
|
|
1755
|
+
gl = _section(c.root / ".control" / "product-glossary.md", "Entries")
|
|
1756
|
+
parts.append(f"\n{gl}\n" if gl.strip() else "\n_no entry in `product-glossary.md` yet._\n")
|
|
1757
|
+
|
|
1534
1758
|
return "\n".join(parts) + "\n"
|
|
1535
1759
|
|
|
1536
1760
|
|
|
@@ -1540,6 +1764,62 @@ def _cell(value: object, limit: int = 110) -> str:
|
|
|
1540
1764
|
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
|
1541
1765
|
|
|
1542
1766
|
|
|
1767
|
+
def _full(value: object, _limit: int = 0) -> str:
|
|
1768
|
+
"""One table cell on a HUMAN page: whitespace collapsed, pipes escaped, never shortened. The
|
|
1769
|
+
rendered trees are the complete document — a cell ending in `…` would send the reader back to
|
|
1770
|
+
the registry, which is exactly the trip the page exists to save. `_limit` is accepted and
|
|
1771
|
+
ignored so a call site shared with `_cell` reads the same."""
|
|
1772
|
+
return " ".join(str(value or "").split()).replace("|", "\\|")
|
|
1773
|
+
|
|
1774
|
+
|
|
1775
|
+
def _by_id(items: list[dict]) -> list[dict]:
|
|
1776
|
+
"""`FR-2` before `FR-10`. YAML order is whatever the last writer left; a page reads in id order."""
|
|
1777
|
+
def key(row: dict) -> tuple:
|
|
1778
|
+
m = re.match(r"^([A-Za-z]+)-(\d+)$", str(row.get("id") or ""))
|
|
1779
|
+
return (m.group(1), int(m.group(2))) if m else (str(row.get("id") or ""), 0)
|
|
1780
|
+
return sorted(items, key=key)
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def _doc_title(path: Path, fallback: str) -> str:
|
|
1784
|
+
"""The working document's own title — frontmatter `title:` first, else its first `# ` line."""
|
|
1785
|
+
if not path.exists():
|
|
1786
|
+
return fallback
|
|
1787
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
1788
|
+
fm = FM.match(text)
|
|
1789
|
+
if fm:
|
|
1790
|
+
m = re.search(r"^title:\s*[\"']?(.+?)[\"']?\s*$", fm.group(0), re.M)
|
|
1791
|
+
if m and m.group(1).strip():
|
|
1792
|
+
return m.group(1).strip()
|
|
1793
|
+
m = re.search(r"^# (.+?)\s*$", text, re.M)
|
|
1794
|
+
return m.group(1).strip() if m else fallback
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def _req_block(row: dict, level: int = 4) -> str:
|
|
1798
|
+
"""One requirement, whole: heading, the full statement when the row carries one, then the fields
|
|
1799
|
+
a reader checks it by. This is the shape the PRD used to write by hand, rebuilt from the row."""
|
|
1800
|
+
rid = str(row.get("id") or "")
|
|
1801
|
+
title = _text(row)
|
|
1802
|
+
out = [f"{'#' * level} {rid} — {title}", ""]
|
|
1803
|
+
statement = " ".join(str(row.get("statement") or "").split())
|
|
1804
|
+
if statement and statement.rstrip(".").lower() != title.rstrip(".").lower():
|
|
1805
|
+
out += [statement, ""]
|
|
1806
|
+
fields = []
|
|
1807
|
+
if rid.startswith("FR"):
|
|
1808
|
+
proof = _proof(row)
|
|
1809
|
+
fields.append(("Proof of done", proof or "—"))
|
|
1810
|
+
if row.get("capability"):
|
|
1811
|
+
fields.append(("Capability", f"`{row.get('capability')}`"))
|
|
1812
|
+
elif rid.startswith("NFR"):
|
|
1813
|
+
enforced = listy(row, "enforced_by") or ([row.get("enforced_by")] if row.get("enforced_by") else [])
|
|
1814
|
+
fields.append(("Enforced by", ", ".join(str(x) for x in enforced) or "—"))
|
|
1815
|
+
if row.get("goal"):
|
|
1816
|
+
fields.append(("Serves", f"`{row.get('goal')}`"))
|
|
1817
|
+
if row.get("component"):
|
|
1818
|
+
fields.append(("Component", f"`{row.get('component')}`"))
|
|
1819
|
+
out += [f"**{k}:** {v} " for k, v in fields]
|
|
1820
|
+
return "\n".join(out).rstrip() + "\n"
|
|
1821
|
+
|
|
1822
|
+
|
|
1543
1823
|
def _demote(block: str, by: int = 2) -> str:
|
|
1544
1824
|
"""Demote the heading level of inlined content, so it does not clash with the roll-up's own structure."""
|
|
1545
1825
|
out = []
|
|
@@ -1554,7 +1834,7 @@ def _demote(block: str, by: int = 2) -> str:
|
|
|
1554
1834
|
|
|
1555
1835
|
|
|
1556
1836
|
def page_estimate(c: Corpus) -> str:
|
|
1557
|
-
"""Table of CANDIDATE tasks. One row per `FR`, since that is a
|
|
1837
|
+
"""Table of CANDIDATE tasks. One row per `FR`, since that is a spec's ideal shape."""
|
|
1558
1838
|
mode_of = {str(pc.get("id")): c.mode_of(pc) for pc in c.pcs}
|
|
1559
1839
|
risk_of = {str(pc.get("id")): (str(pc.get("risk_accepted") or "—"),
|
|
1560
1840
|
str(pc.get("risk_note") or "—")) for pc in c.pcs}
|
|
@@ -1567,14 +1847,14 @@ def page_estimate(c: Corpus) -> str:
|
|
|
1567
1847
|
have_mandays = any(x.get("estimate_mandays") for x in c.caps)
|
|
1568
1848
|
parts = ["# estimate\n", PAGE_HEADER,
|
|
1569
1849
|
"\n**THIS IS AN ESTIMATE, FORWARD-LOOKING.** Every row below is a **candidate** "
|
|
1570
|
-
"task; the
|
|
1850
|
+
"task; the spec in `specs.yaml` is the real one. One row MAY become one spec, and three "
|
|
1571
1851
|
"neighboring rows MAY be merged into one — that merge is a human decision made when the "
|
|
1572
|
-
"
|
|
1852
|
+
"spec is opened.\n"]
|
|
1573
1853
|
if not have_mandays:
|
|
1574
1854
|
parts.append("\n**With no `estimate_mandays` on a single `CAP`**, the Load column is empty and "
|
|
1575
1855
|
"this output is only as good as a T-shirt-size estimate. It MUST be reported as such.\n")
|
|
1576
1856
|
|
|
1577
|
-
parts.append("\n| Task | FR |
|
|
1857
|
+
parts.append("\n| Task | FR | Component | mode | Exposure | Load | Priority | Depends on | Release |")
|
|
1578
1858
|
parts.append("| --- | --- | --- | --- | --- | --- | --- | --- | --- |")
|
|
1579
1859
|
for fr in c.frs:
|
|
1580
1860
|
cap_id = str(fr.get("capability", ""))
|
|
@@ -1597,7 +1877,487 @@ def page_estimate(c: Corpus) -> str:
|
|
|
1597
1877
|
return "\n".join(parts) + "\n"
|
|
1598
1878
|
|
|
1599
1879
|
|
|
1880
|
+
ID_CITE_RE = re.compile(r"\b(?:CAP|FR|NFR)-\d+\b")
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _cited_ids(text: str) -> list[str]:
|
|
1884
|
+
"""Every CAP-N / FR-N / NFR-N cited in TEXT, in first-seen order, no repeats."""
|
|
1885
|
+
seen: set[str] = set()
|
|
1886
|
+
out: list[str] = []
|
|
1887
|
+
for m in ID_CITE_RE.finditer(text):
|
|
1888
|
+
if m.group(0) not in seen:
|
|
1889
|
+
seen.add(m.group(0))
|
|
1890
|
+
out.append(m.group(0))
|
|
1891
|
+
return out
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _subsection(path: Path, parent: str, sub: str) -> str:
|
|
1895
|
+
"""Extract one `### <sub>` subsection from within a `## <parent>` block, as-is."""
|
|
1896
|
+
block = _section(path, parent)
|
|
1897
|
+
if not block:
|
|
1898
|
+
return ""
|
|
1899
|
+
lines = block.splitlines()
|
|
1900
|
+
out: list[str] = []
|
|
1901
|
+
inside = False
|
|
1902
|
+
for line in lines:
|
|
1903
|
+
if line.startswith("### "):
|
|
1904
|
+
if inside:
|
|
1905
|
+
break
|
|
1906
|
+
inside = _hname(line[4:]).startswith(_hname(sub))
|
|
1907
|
+
continue
|
|
1908
|
+
if inside:
|
|
1909
|
+
out.append(line)
|
|
1910
|
+
return "\n".join(out).strip("\n")
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _has_rows(block: str) -> bool:
|
|
1914
|
+
"""True if a `## Open` table has a real data row — not just header, separator, and the
|
|
1915
|
+
scaffold's own `| — | — | ... |` placeholder for "nothing here yet"."""
|
|
1916
|
+
lines = [ln.strip() for ln in block.splitlines() if ln.strip().startswith("|")]
|
|
1917
|
+
for line in lines[2:]:
|
|
1918
|
+
first = line.strip("|").split("|", 1)[0].strip()
|
|
1919
|
+
if first and first not in ("—", "-"):
|
|
1920
|
+
return True
|
|
1921
|
+
return False
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
def _filtered_rows(block: str, needles: list[str]) -> str:
|
|
1925
|
+
"""A markdown table, kept whole for its header and separator, with only the DATA rows that
|
|
1926
|
+
mention one of `needles` (case-insensitive substring). Returns "" if nothing matched.
|
|
1927
|
+
|
|
1928
|
+
This is how the brief and PRD deliverables show only the open questions and assumptions that
|
|
1929
|
+
are actually theirs, without `.control/questions/` carrying a field that says so.
|
|
1930
|
+
"""
|
|
1931
|
+
if not block.strip():
|
|
1932
|
+
return ""
|
|
1933
|
+
lines = block.splitlines()
|
|
1934
|
+
out: list[str] = []
|
|
1935
|
+
header_seen = 0
|
|
1936
|
+
kept_any = False
|
|
1937
|
+
for line in lines:
|
|
1938
|
+
stripped = line.strip()
|
|
1939
|
+
if stripped.startswith("|") and header_seen < 2:
|
|
1940
|
+
out.append(line)
|
|
1941
|
+
header_seen += 1
|
|
1942
|
+
continue
|
|
1943
|
+
if stripped.startswith("|"):
|
|
1944
|
+
if any(n.lower() in line.lower() for n in needles):
|
|
1945
|
+
out.append(line)
|
|
1946
|
+
kept_any = True
|
|
1947
|
+
continue
|
|
1948
|
+
out.append(line)
|
|
1949
|
+
return "\n".join(out).strip("\n") if kept_any else ""
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def _glossary_terms_used(root: Path, text: str) -> str:
|
|
1953
|
+
"""`## Entries` bullets from `product-glossary.md` whose term is a whole word somewhere in TEXT."""
|
|
1954
|
+
path = root / ".control" / "product-glossary.md"
|
|
1955
|
+
entries = _section(path, "Entries")
|
|
1956
|
+
if not entries:
|
|
1957
|
+
return ""
|
|
1958
|
+
out = []
|
|
1959
|
+
for line in entries.splitlines():
|
|
1960
|
+
m = re.match(r"^-\s*\*\*(.+?)\*\*", line.strip())
|
|
1961
|
+
if m and re.search(rf"\b{re.escape(m.group(1))}\b", text, re.I):
|
|
1962
|
+
out.append(line)
|
|
1963
|
+
return "\n".join(out)
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
AD_HEAD_RE = re.compile(r"^###\s+(AD-\d+)\s+[—-]+\s+(.*?)\s*$", re.M)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def _ad_blocks(root: Path) -> list[dict]:
|
|
1970
|
+
"""Every `### AD-N — title` block in the spine, with its Binds / Prevents / Rule lines.
|
|
1971
|
+
|
|
1972
|
+
The spine is the SSOT for an invariant and `AD-` is edited in place — so nothing else may hold
|
|
1973
|
+
its text. The rendered SDD gets it from here, which is what made the verbatim quote in the
|
|
1974
|
+
working SDD unnecessary, and unsafe.
|
|
1975
|
+
"""
|
|
1976
|
+
text = _body(root / ".how" / "_platform" / "ARCHITECTURE-SPINE.md")
|
|
1977
|
+
if not text:
|
|
1978
|
+
return []
|
|
1979
|
+
heads = list(AD_HEAD_RE.finditer(text))
|
|
1980
|
+
out: list[dict] = []
|
|
1981
|
+
for i, m in enumerate(heads):
|
|
1982
|
+
end = heads[i + 1].start() if i + 1 < len(heads) else len(text)
|
|
1983
|
+
block = text[m.end():end]
|
|
1984
|
+
field = lambda name: (re.search(rf"\*\*{name}:\*\*\s*(.*)", block) or [None, ""])[1].strip()
|
|
1985
|
+
out.append({"id": m.group(1), "title": m.group(2).strip(),
|
|
1986
|
+
"binds": field("Binds"), "prevents": field("Prevents"), "rule": field("Rule")})
|
|
1987
|
+
return out
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
def _binds_pc(ad: dict, pid: str, lc_ids: set[str]) -> bool:
|
|
1991
|
+
b = ad.get("binds", "")
|
|
1992
|
+
if not b:
|
|
1993
|
+
return False
|
|
1994
|
+
if re.search(r"\ball\b", b):
|
|
1995
|
+
return True
|
|
1996
|
+
return bool(re.search(rf"(?<![\w-]){re.escape(pid)}(?![\w-])", b)) or any(l in b for l in lc_ids)
|
|
1997
|
+
|
|
1998
|
+
|
|
1999
|
+
def _ad_table(ads: list[dict], pid: str | None = None, lc_ids: set[str] | None = None) -> str:
|
|
2000
|
+
if not ads:
|
|
2001
|
+
return "_no `AD-N` in the spine yet._"
|
|
2002
|
+
if pid is None:
|
|
2003
|
+
rows_out = ["| id | Invariant | Binds | Prevents | Rule |", "| --- | --- | --- | --- | --- |"]
|
|
2004
|
+
for ad in _by_id(ads):
|
|
2005
|
+
rows_out.append(f"| `{ad['id']}` | {_full(ad['title'])} | {_full(ad['binds'], 60)} "
|
|
2006
|
+
f"| {_full(ad['prevents'])} | {_full(ad['rule'])} |")
|
|
2007
|
+
else:
|
|
2008
|
+
rows_out = ["| id | Invariant | Binds this component | Prevents | Rule |",
|
|
2009
|
+
"| --- | --- | --- | --- | --- |"]
|
|
2010
|
+
for ad in _by_id(ads):
|
|
2011
|
+
hit = "**yes**" if _binds_pc(ad, pid, lc_ids or set()) else "no"
|
|
2012
|
+
rows_out.append(f"| `{ad['id']}` | {_full(ad['title'])} | {hit} "
|
|
2013
|
+
f"| {_full(ad['prevents'])} | {_full(ad['rule'])} |")
|
|
2014
|
+
return "\n".join(rows_out)
|
|
2015
|
+
|
|
2016
|
+
|
|
2017
|
+
def _uc_table(ucs: list[dict]) -> str:
|
|
2018
|
+
if not ucs:
|
|
2019
|
+
return "_no use case registered yet._"
|
|
2020
|
+
rows_out = ["| id | Use case | Component | Satisfies | critical |",
|
|
2021
|
+
"| --- | --- | --- | --- | --- |"]
|
|
2022
|
+
for uc in _by_id(ucs):
|
|
2023
|
+
sat = ", ".join(f"`{x}`" for x in listy(uc, "satisfies")) or "—"
|
|
2024
|
+
rows_out.append(f"| `{uc.get('id')}` | {_full(uc.get('title'))} | `{uc.get('component', '')}` "
|
|
2025
|
+
f"| {sat} | {'yes' if uc.get('critical') else 'no'} |")
|
|
2026
|
+
return "\n".join(rows_out)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _lc_table(lcs: list[dict]) -> str:
|
|
2030
|
+
if not lcs:
|
|
2031
|
+
return "_no Logical Component registered yet._"
|
|
2032
|
+
rows_out = ["| id | Name | Type | Container |", "| --- | --- | --- | --- |"]
|
|
2033
|
+
for lc in _by_id(lcs):
|
|
2034
|
+
rows_out.append(f"| `{lc.get('id')}` | {_full(lc.get('name'))} | `{lc.get('lc_type', '—')}` "
|
|
2035
|
+
f"| `{lc.get('container') or '—'}` |")
|
|
2036
|
+
return "\n".join(rows_out)
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _pc_container_table(c: Corpus) -> str:
|
|
2040
|
+
"""The PC x container matrix, RENDERED from `components.yaml`. This is the table C4 L2 used to
|
|
2041
|
+
carry by hand and `container-built` had to keep honest; rendered, there is nothing to compare."""
|
|
2042
|
+
built = [ct for ct in rows(c.components, "containers") if ct.get("built")]
|
|
2043
|
+
if not built:
|
|
2044
|
+
return "_no `built: true` container registered yet._"
|
|
2045
|
+
rows_out = ["| Container | What | Product Components living in it |", "| --- | --- | --- |"]
|
|
2046
|
+
for ct in built:
|
|
2047
|
+
cid = str(ct.get("id"))
|
|
2048
|
+
pcs = [str(pc.get("id")) for pc in c.pcs if cid in listy(pc, "containers")]
|
|
2049
|
+
rows_out.append(f"| `{cid}` | {_full(ct.get('what'))} | "
|
|
2050
|
+
f"{', '.join(f'`{x}`' for x in pcs) or '—'} |")
|
|
2051
|
+
return "\n".join(rows_out)
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
def _slot_files(root: Path, layer: str, pid: str, slot: str) -> list[Path]:
|
|
2055
|
+
d = root / layer / pid / slot
|
|
2056
|
+
return sorted(d.glob("*.md")) if d.is_dir() else []
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def _inline(path: Path, level: int = 3) -> str:
|
|
2060
|
+
"""A whole working file inlined under a heading that names it, its own headings demoted."""
|
|
2061
|
+
body = _body(path)
|
|
2062
|
+
if not body:
|
|
2063
|
+
return ""
|
|
2064
|
+
return f"\n{'#' * level} `{path.name}`\n\n{_demote(body, by=level - 1)}\n"
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def _text(row: dict) -> str:
|
|
2068
|
+
"""The one-line LABEL of a registry row. `title` is what newer corpora carry, `text` what older ones
|
|
2069
|
+
do — both are the short label and rank first. `statement` is the full sentence and comes last, so a
|
|
2070
|
+
row that has a label never renders its paragraph as the heading. Ranking it higher once made an
|
|
2071
|
+
upgrade copy `text` into `title` on every row to get a readable page."""
|
|
2072
|
+
return str(row.get("title") or row.get("text") or row.get("statement") or "")
|
|
2073
|
+
|
|
2074
|
+
|
|
2075
|
+
def _proof(row: dict) -> str:
|
|
2076
|
+
return str(row.get("proof") or row.get("proof_of_done") or "")
|
|
2077
|
+
|
|
2078
|
+
|
|
2079
|
+
def page_brief(c: Corpus) -> str:
|
|
2080
|
+
"""The brief's own sections verbatim, plus what only the registry and `.control/questions/`
|
|
2081
|
+
can complete: Goals rendered from `goals.yaml`, and the open Assumptions and
|
|
2082
|
+
Prerequisites rows. The page read at G1. Nobody edits it — it is regenerated from its sources.
|
|
2083
|
+
"""
|
|
2084
|
+
path = c.root / ".what" / "_product-brief" / "brief.md"
|
|
2085
|
+
if not path.exists():
|
|
2086
|
+
return "# brief\n\n" + PAGE_HEADER + "\n\n_no `.what/_product-brief/brief.md` yet._\n"
|
|
2087
|
+
|
|
2088
|
+
parts = [f"# {_doc_title(path, 'Product Brief')}\n", PAGE_HEADER]
|
|
2089
|
+
for heading in ("Why", "The Problem", "The Solution", "What Makes This Different",
|
|
2090
|
+
"Who This Serves"):
|
|
2091
|
+
block = _section(path, heading)
|
|
2092
|
+
if block:
|
|
2093
|
+
parts.append(f"\n## {heading}\n\n{block}\n")
|
|
2094
|
+
|
|
2095
|
+
parts.append("\n## Goals\n")
|
|
2096
|
+
if c.goals:
|
|
2097
|
+
parts.append("\nRendered from `.control/registry/goals.yaml`.\n")
|
|
2098
|
+
for g in _by_id(c.goals):
|
|
2099
|
+
title = _text(g)
|
|
2100
|
+
parts.append(f"\n### {g.get('id')} — {title}\n")
|
|
2101
|
+
statement = " ".join(str(g.get("statement") or "").split())
|
|
2102
|
+
if statement and statement.rstrip(".").lower() != title.rstrip(".").lower():
|
|
2103
|
+
parts.append(f"\n{statement}\n")
|
|
2104
|
+
if g.get("measure"):
|
|
2105
|
+
parts.append(f"\n**Measure:** {_full(g.get('measure'))} ")
|
|
2106
|
+
if g.get("why"):
|
|
2107
|
+
parts.append(f"**Why:** {_full(g.get('why'))} ")
|
|
2108
|
+
else:
|
|
2109
|
+
parts.append("\n_no goal registered yet._")
|
|
2110
|
+
|
|
2111
|
+
success = _section(path, "Success Criteria")
|
|
2112
|
+
if success:
|
|
2113
|
+
parts.append(f"\n\n## Success Criteria\n\n{success}\n")
|
|
2114
|
+
|
|
2115
|
+
scope = _section(path, "Scope")
|
|
2116
|
+
if scope:
|
|
2117
|
+
parts.append(f"\n## Scope\n\n{scope}\n")
|
|
2118
|
+
|
|
2119
|
+
constraints = _section(path, "Constraints")
|
|
2120
|
+
if constraints:
|
|
2121
|
+
parts.append(f"\n## Constraints\n\n{constraints}\n")
|
|
2122
|
+
|
|
2123
|
+
parts.append("\n## Assumptions\n")
|
|
2124
|
+
open_assumptions = _section(c.root / ".control" / "questions" / "assumptions.md", "Open")
|
|
2125
|
+
parts.append(f"\n{open_assumptions}\n" if _has_rows(open_assumptions)
|
|
2126
|
+
else "\n_none open._\n")
|
|
2127
|
+
|
|
2128
|
+
parts.append("\n## Prerequisites\n")
|
|
2129
|
+
open_prereqs = _section(c.root / ".control" / "questions" / "external.md", "Open")
|
|
2130
|
+
parts.append(f"\n{open_prereqs}\n" if _has_rows(open_prereqs) else "\n_none open._\n")
|
|
2131
|
+
|
|
2132
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(parts)) + "\n"
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def page_prd(c: Corpus, slug: str) -> str:
|
|
2136
|
+
"""This PRD's own sections verbatim, plus what only the brief, the registry, the glossary, and
|
|
2137
|
+
`.control/questions/` can complete. Nobody edits this by hand.
|
|
2138
|
+
"""
|
|
2139
|
+
path = c.root / ".what" / "_prd" / slug / "prd.md"
|
|
2140
|
+
if not path.exists():
|
|
2141
|
+
return (f"# prd-{slug}\n\n" + PAGE_HEADER +
|
|
2142
|
+
f"\n\n_no `.what/_prd/{slug}/prd.md` yet._\n")
|
|
2143
|
+
text = _body(path)
|
|
2144
|
+
brief_path = c.root / ".what" / "_product-brief" / "brief.md"
|
|
2145
|
+
|
|
2146
|
+
parts = [f"# {_doc_title(path, f'PRD — {slug}')}\n", PAGE_HEADER]
|
|
2147
|
+
|
|
2148
|
+
parts.append("\n## Why\n")
|
|
2149
|
+
why_brief = _section(brief_path, "Why")
|
|
2150
|
+
why_delta = _section(path, "1. Why This Initiative") or _section(path, "Why This Initiative")
|
|
2151
|
+
if why_brief:
|
|
2152
|
+
parts.append(f"\n{why_brief}\n")
|
|
2153
|
+
if why_delta:
|
|
2154
|
+
parts.append(f"\n**This initiative:** {why_delta}\n")
|
|
2155
|
+
|
|
2156
|
+
target_user = _section(path, "2. Target User") or _section(path, "Target User")
|
|
2157
|
+
if target_user:
|
|
2158
|
+
parts.append(f"\n## Target User\n\n{target_user}\n")
|
|
2159
|
+
|
|
2160
|
+
# This initiative's OWN requirement file is the source, not a scan of the prose. That is the
|
|
2161
|
+
# whole point of the split: which PRD a promise belongs to is structural now, and a page that
|
|
2162
|
+
# guessed it from citations would still be guessing.
|
|
2163
|
+
own = c.requirements_of(slug)
|
|
2164
|
+
own_caps = _by_id(rows(own, "capabilities"))
|
|
2165
|
+
own_frs = _by_id(rows(own, "functional"))
|
|
2166
|
+
own_nfrs = _by_id(rows(own, "nonfunctional"))
|
|
2167
|
+
own_ujs = _by_id(rows(own, "journeys"))
|
|
2168
|
+
by_id = {str(r.get("id")): r for r in own_frs + own_nfrs}
|
|
2169
|
+
|
|
2170
|
+
# § Features, with every `**Realizes:** FR-1, NFR-2` line expanded into the rows it names. That
|
|
2171
|
+
# rebuilds the shape the PRD used to write by hand — feature, then its requirements in full —
|
|
2172
|
+
# from the registry, so the reader never leaves this page for the text.
|
|
2173
|
+
features = _section(path, "3. Features") or _section(path, "Features")
|
|
2174
|
+
realized: set[str] = set()
|
|
2175
|
+
if features:
|
|
2176
|
+
out_lines: list[str] = []
|
|
2177
|
+
for line in features.splitlines():
|
|
2178
|
+
m = re.match(r"^\*\*Realizes:\*\*\s*(.*)$", line.strip())
|
|
2179
|
+
if not m:
|
|
2180
|
+
out_lines.append(line)
|
|
2181
|
+
continue
|
|
2182
|
+
ids = [i for i in re.findall(r"\b(?:FR|NFR)-\d+\b", m.group(1))]
|
|
2183
|
+
found = [by_id[i] for i in ids if i in by_id]
|
|
2184
|
+
missing = [i for i in ids if i not in by_id]
|
|
2185
|
+
if not found:
|
|
2186
|
+
out_lines.append(line)
|
|
2187
|
+
continue
|
|
2188
|
+
realized.update(str(r.get("id")) for r in found)
|
|
2189
|
+
out_lines.append("")
|
|
2190
|
+
for r in found:
|
|
2191
|
+
out_lines.append(_req_block(r, level=4))
|
|
2192
|
+
if missing:
|
|
2193
|
+
out_lines.append(f"_Also cited, not in `requirements-{slug}.yaml`: "
|
|
2194
|
+
+ ", ".join(f"`{i}`" for i in missing) + "._\n")
|
|
2195
|
+
parts.append("\n## Features\n\n" + "\n".join(out_lines).rstrip() + "\n")
|
|
2196
|
+
|
|
2197
|
+
if own_caps:
|
|
2198
|
+
parts.append("\n### Capabilities\n")
|
|
2199
|
+
parts.append("\n| id | Serves | Capability | Priority | Release | Depends on |"
|
|
2200
|
+
"\n| --- | --- | --- | --- | --- | --- |")
|
|
2201
|
+
for cap in own_caps:
|
|
2202
|
+
deps = ", ".join(f"`{x}`" for x in listy(cap, "depends_on")) or "—"
|
|
2203
|
+
parts.append(f"| `{cap.get('id')}` | `{cap.get('goal', '—')}` "
|
|
2204
|
+
f"| {_full(_text(cap))} | {cap.get('priority', '—')} "
|
|
2205
|
+
f"| {cap.get('target_release', '—')} | {deps} |")
|
|
2206
|
+
if own_ujs:
|
|
2207
|
+
parts.append("\n\n### User journeys\n")
|
|
2208
|
+
parts.append("\n| id | Journey |\n| --- | --- |")
|
|
2209
|
+
for uj in own_ujs:
|
|
2210
|
+
parts.append(f"| `{uj.get('id')}` | {_full(_text(uj))} |")
|
|
2211
|
+
rest_frs = [r for r in own_frs if str(r.get("id")) not in realized]
|
|
2212
|
+
rest_nfrs = [r for r in own_nfrs if str(r.get("id")) not in realized]
|
|
2213
|
+
if rest_frs:
|
|
2214
|
+
note = " not attached to a feature above" if realized else ""
|
|
2215
|
+
parts.append(f"\n\n### Functional requirements{note}\n")
|
|
2216
|
+
for fr in rest_frs:
|
|
2217
|
+
parts.append("\n" + _req_block(fr))
|
|
2218
|
+
if rest_nfrs:
|
|
2219
|
+
note = " not attached to a feature above" if realized else ""
|
|
2220
|
+
parts.append(f"\n\n### Non-functional requirements{note}\n")
|
|
2221
|
+
for nfr in rest_nfrs:
|
|
2222
|
+
parts.append("\n" + _req_block(nfr))
|
|
2223
|
+
if not own and _cited_ids(text):
|
|
2224
|
+
parts.append(f"\n_`requirements-{slug}.yaml` does not exist yet — this initiative's `FR` "
|
|
2225
|
+
f"and `NFR` have not been landed in a registry file of their own._\n")
|
|
2226
|
+
|
|
2227
|
+
cited = _cited_ids(text)
|
|
2228
|
+
|
|
2229
|
+
for heading, title in (("4. MVP Scope", "MVP Scope"), ("5. Success Metrics", "Success Metrics"),
|
|
2230
|
+
("6. Cross-Cutting NFRs", "Cross-Cutting NFRs"),
|
|
2231
|
+
("7. Constraints and Guardrails", "Constraints and Guardrails")):
|
|
2232
|
+
block = _section(path, heading)
|
|
2233
|
+
if block:
|
|
2234
|
+
parts.append(f"\n\n## {title}\n\n{block}\n")
|
|
2235
|
+
|
|
2236
|
+
# The UX half G2 reads beside the PRD. An EXPERIENCE.md belongs to this initiative when it
|
|
2237
|
+
# references one of this initiative's UJ ids — the template requires it to reference them.
|
|
2238
|
+
uj_ids = [str(x.get("id")) for x in rows(own, "journeys") if x.get("id")]
|
|
2239
|
+
if uj_ids:
|
|
2240
|
+
for exp in sorted((c.root / ".what").glob("*/04-usecases/EXPERIENCE.md")):
|
|
2241
|
+
etext = _body(exp)
|
|
2242
|
+
if not any(re.search(rf"(?<![\w-]){re.escape(u)}(?![\w-])", etext) for u in uj_ids):
|
|
2243
|
+
continue
|
|
2244
|
+
pcname = exp.parent.parent.name
|
|
2245
|
+
parts.append(f"\n\n## Experience — `{pcname}`\n")
|
|
2246
|
+
for heading in ("Information architecture", "Journeys", "Behaviour per surface",
|
|
2247
|
+
"Accessibility", "Edge cases"):
|
|
2248
|
+
block = _section(exp, heading)
|
|
2249
|
+
if block:
|
|
2250
|
+
parts.append(f"\n### {heading}\n\n{block}\n")
|
|
2251
|
+
|
|
2252
|
+
parts.append("\n\n## Non-Goals\n")
|
|
2253
|
+
scope_out = _subsection(brief_path, "Scope", "Scope Out")
|
|
2254
|
+
if scope_out:
|
|
2255
|
+
parts.append(f"\n**Product-wide, from the brief:**\n\n{scope_out}\n")
|
|
2256
|
+
else:
|
|
2257
|
+
parts.append("\n_none stated in the brief._\n")
|
|
2258
|
+
parts.append("\nRelease-specific exclusions are under **MVP Scope → Out of Scope for MVP** "
|
|
2259
|
+
"above.\n")
|
|
2260
|
+
|
|
2261
|
+
glossary = _glossary_terms_used(c.root, text)
|
|
2262
|
+
if glossary:
|
|
2263
|
+
parts.append(f"\n## Glossary\n\n{glossary}\n")
|
|
2264
|
+
|
|
2265
|
+
needles = cited + [slug]
|
|
2266
|
+
q_dir = c.root / ".control" / "questions"
|
|
2267
|
+
open_q = "\n\n".join(
|
|
2268
|
+
filter(None, (_filtered_rows(_section(q_dir / f"{name}.md", "Open"), needles)
|
|
2269
|
+
for name in ("blocking", "assumptions", "external"))))
|
|
2270
|
+
if open_q:
|
|
2271
|
+
parts.append(f"\n## Open Questions and Assumptions\n\n{open_q}\n")
|
|
2272
|
+
|
|
2273
|
+
rh = _section(path, "Revision History")
|
|
2274
|
+
if rh:
|
|
2275
|
+
parts.append(f"\n\n## Revision History\n\n{rh}\n")
|
|
2276
|
+
|
|
2277
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(parts)) + "\n"
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def page_srs(c: Corpus, pid: str) -> str:
|
|
2281
|
+
"""One component's SRS, complete: its own kernel prose verbatim, the UC catalogue rendered from
|
|
2282
|
+
`usecases.yaml`, and every slot file inlined. For a reader who wants ONE component whole."""
|
|
2283
|
+
path = c.root / ".what" / pid / f"SRS-{pid}.md"
|
|
2284
|
+
pc = next((x for x in c.pcs if str(x.get("id")) == pid), {})
|
|
2285
|
+
parts = [f"# {_doc_title(path, f'SRS — {pid}')}\n", PAGE_HEADER]
|
|
2286
|
+
if not path.exists():
|
|
2287
|
+
return "\n".join(parts) + f"\n\n_no `.what/{pid}/SRS-{pid}.md` yet._\n"
|
|
2288
|
+
parts.append(f"\n`mode: {c.mode_of(pc)}` · `risk_accepted: {pc.get('risk_accepted', '—')}` — "
|
|
2289
|
+
f"from `components.yaml`.\n")
|
|
2290
|
+
for heading in ("Decision Summary", "Why", "Actor Register"):
|
|
2291
|
+
block = _section(path, heading)
|
|
2292
|
+
if block:
|
|
2293
|
+
parts.append(f"\n## {heading}\n\n{block}\n")
|
|
2294
|
+
own_ucs = [uc for uc in c.ucs if str(uc.get("component") or "") == pid]
|
|
2295
|
+
parts.append(f"\n## UC Catalogue\n\nRendered from `usecases.yaml`.\n\n{_uc_table(own_ucs)}\n")
|
|
2296
|
+
for heading in ("Constraints", "Non-Goals", "Prerequisite", "Success Signal", "Design Reference"):
|
|
2297
|
+
block = _section(path, heading)
|
|
2298
|
+
if block:
|
|
2299
|
+
parts.append(f"\n## {heading}\n\n{block}\n")
|
|
2300
|
+
for slot, title in (("02-rules", "Business rules — local"), ("03-domain", "Domain"),
|
|
2301
|
+
("04-usecases", "Use cases"), ("05-scenarios", "Scenarios")):
|
|
2302
|
+
files = _slot_files(c.root, ".what", pid, slot)
|
|
2303
|
+
if files:
|
|
2304
|
+
parts.append(f"\n## {title} — `{slot}/`\n")
|
|
2305
|
+
for f in files:
|
|
2306
|
+
parts.append(_inline(f))
|
|
2307
|
+
return "\n".join(parts) + "\n"
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
def page_sdd(c: Corpus, pid: str) -> str:
|
|
2311
|
+
"""The ONE page read at G4 for one component. Seven questions, and where each is answered:
|
|
2312
|
+
what is staked -> registry; boundaries with no failure answer -> § Failure Behaviour; what
|
|
2313
|
+
stops us tomorrow -> questions/; validators + review -> status; vendor lock -> § Decision
|
|
2314
|
+
Summary; DAG -> generated/dag; top risk -> risks.yaml. Only two of the seven need SDD prose.
|
|
2315
|
+
|
|
2316
|
+
The AD-N table is rendered from the spine, not quoted. `AD-` is edited in place, so a quote
|
|
2317
|
+
goes stale the moment the spine changes and nothing sees it — the working SDD now cites ids.
|
|
2318
|
+
"""
|
|
2319
|
+
path = c.root / ".how" / pid / f"SDD-{pid}.md"
|
|
2320
|
+
pc = next((x for x in c.pcs if str(x.get("id")) == pid), {})
|
|
2321
|
+
lcs = [lc for lc in c.lcs if str(lc.get("component") or "") == pid]
|
|
2322
|
+
lc_ids = {str(lc.get("id")) for lc in lcs}
|
|
2323
|
+
parts = [f"# {_doc_title(path, f'SDD — {pid}')}\n", PAGE_HEADER,
|
|
2324
|
+
"\nThis is what the owner reads at **G4 Component** for this component.\n"]
|
|
2325
|
+
parts.append(f"\n## What is staked\n\n`mode: {c.mode_of(pc)}` · "
|
|
2326
|
+
f"`risk_accepted: {pc.get('risk_accepted', '—')}` · "
|
|
2327
|
+
f"`g4_passed: {pc.get('g4_passed', '—')}`\n\n"
|
|
2328
|
+
f"**risk_note:** {pc.get('risk_note') or '—'}\n\n"
|
|
2329
|
+
f"**owns:** {', '.join(f'`{x}`' for x in listy(pc, 'owns')) or '—'} · "
|
|
2330
|
+
f"**containers:** {', '.join(f'`{x}`' for x in listy(pc, 'containers')) or '—'}\n")
|
|
2331
|
+
if not path.exists():
|
|
2332
|
+
return "\n".join(parts) + f"\n\n_no `.how/{pid}/SDD-{pid}.md` yet._\n"
|
|
2333
|
+
ds = _section(path, "Decision Summary")
|
|
2334
|
+
parts.append(f"\n## Decision Summary\n\n{ds}\n" if ds else "\n## Decision Summary\n\n_not written yet._\n")
|
|
2335
|
+
parts.append(f"\n## Structure — Logical Components\n\nRendered from `components.yaml`.\n\n{_lc_table(lcs)}\n")
|
|
2336
|
+
st = _section(path, "Structure")
|
|
2337
|
+
if st:
|
|
2338
|
+
parts.append(f"\n### Dependency direction and responsibilities\n\n{_demote(st)}\n")
|
|
2339
|
+
parts.append("\n## Inherited Constraints — the spine's invariants\n\nRendered from "
|
|
2340
|
+
"`ARCHITECTURE-SPINE.md`. A row marked **yes** binds this component by `Binds:`; the "
|
|
2341
|
+
"rest are shown so a miss in `Binds:` is visible, not hidden.\n\n"
|
|
2342
|
+
+ _ad_table(_ad_blocks(c.root), pid, lc_ids) + "\n")
|
|
2343
|
+
for heading in ("Failure Behaviour", "Robustness Analysis", "Design Notes", "Evidence"):
|
|
2344
|
+
block = _section(path, heading)
|
|
2345
|
+
if block:
|
|
2346
|
+
parts.append(f"\n## {heading}\n\n{block}\n")
|
|
2347
|
+
for slot, title in (("01-ux", "UX — screens"), ("02-contracts", "Contracts"),
|
|
2348
|
+
("03-integrations", "Integrations"), ("04-components", "Components"),
|
|
2349
|
+
("05-model", "Data model"), ("06-flows", "Flows")):
|
|
2350
|
+
files = _slot_files(c.root, ".how", pid, slot)
|
|
2351
|
+
if files:
|
|
2352
|
+
parts.append(f"\n## {title} — `{slot}/`\n")
|
|
2353
|
+
for f in files:
|
|
2354
|
+
parts.append(_inline(f))
|
|
2355
|
+
return "\n".join(parts) + "\n"
|
|
2356
|
+
|
|
2357
|
+
|
|
1600
2358
|
def generate(c: Corpus, result: Result) -> list[Path]:
|
|
2359
|
+
"""Machine tables into `.control/generated/`; every page a human reads into the two rendered
|
|
2360
|
+
trees, at the mirror path of the working document it projects."""
|
|
1601
2361
|
out_dir = c.root / ".control" / "generated"
|
|
1602
2362
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
1603
2363
|
rtm = gen_rtm(c)
|
|
@@ -1617,14 +2377,35 @@ def generate(c: Corpus, result: Result) -> list[Path]:
|
|
|
1617
2377
|
md_path.write_text(as_markdown(name, payload), encoding="utf-8")
|
|
1618
2378
|
written += [yaml_path, md_path]
|
|
1619
2379
|
|
|
1620
|
-
#
|
|
1621
|
-
|
|
1622
|
-
for name, render in (("decisions", page_decisions),
|
|
1623
|
-
("blueprint", page_blueprint),
|
|
1624
|
-
("estimate", page_estimate)):
|
|
2380
|
+
# Two indexes that are tables for both readers, and stay beside the machine ones.
|
|
2381
|
+
for name, render in (("decisions", page_decisions), ("estimate", page_estimate)):
|
|
1625
2382
|
page = out_dir / f"{name}.md"
|
|
1626
2383
|
page.write_text(render(c), encoding="utf-8")
|
|
1627
2384
|
written.append(page)
|
|
2385
|
+
|
|
2386
|
+
# The pages a HUMAN reads: one per gate, at the mirror path of the working document.
|
|
2387
|
+
def emit(rel: Path, text: str) -> None:
|
|
2388
|
+
rel.parent.mkdir(parents=True, exist_ok=True)
|
|
2389
|
+
rel.write_text(text, encoding="utf-8")
|
|
2390
|
+
written.append(rel)
|
|
2391
|
+
|
|
2392
|
+
what_r = c.root / RENDERED_WHAT
|
|
2393
|
+
how_r = c.root / RENDERED_HOW
|
|
2394
|
+
emit(what_r / "_product-brief" / "brief.md", page_brief(c)) # G1
|
|
2395
|
+
for prd_path in sorted((c.root / ".what" / "_prd").glob("*/prd.md")):
|
|
2396
|
+
slug = prd_path.parent.name
|
|
2397
|
+
emit(what_r / "_prd" / slug / "prd.md", page_prd(c, slug)) # G2
|
|
2398
|
+
emit(how_r / "blueprint.md", page_blueprint(c)) # G3
|
|
2399
|
+
for pc in c.pcs:
|
|
2400
|
+
pid = str(pc.get("id"))
|
|
2401
|
+
emit(what_r / pid / f"SRS-{pid}.md", page_srs(c, pid))
|
|
2402
|
+
if c.mode_of(pc) != "catalog":
|
|
2403
|
+
emit(how_r / pid / f"SDD-{pid}.md", page_sdd(c, pid)) # G4
|
|
2404
|
+
|
|
2405
|
+
# These used to be written here before the rendered trees existed. A stale copy left behind
|
|
2406
|
+
# would be the one home too many this whole design removes, so it is cleared, not kept.
|
|
2407
|
+
for stale in ["brief.md", "blueprint.md", *[p.name for p in out_dir.glob("prd-*.md")]]:
|
|
2408
|
+
(out_dir / stale).unlink(missing_ok=True)
|
|
1628
2409
|
return written
|
|
1629
2410
|
|
|
1630
2411
|
|
|
@@ -1633,14 +2414,14 @@ def generate(c: Corpus, result: Result) -> list[Path]:
|
|
|
1633
2414
|
|
|
1634
2415
|
def main(argv: list[str] | None = None) -> int:
|
|
1635
2416
|
parser = argparse.ArgumentParser(
|
|
1636
|
-
prog="validate", description="
|
|
2417
|
+
prog="validate", description="goal-has-fr..id-allocated-once and the .control/generated/ generator")
|
|
1637
2418
|
parser.add_argument("--check", action="store_true",
|
|
1638
2419
|
help="check only; exit non-zero if anything is red")
|
|
1639
2420
|
parser.add_argument("--generate", action="store_true",
|
|
1640
2421
|
help="rewrite .control/generated/ (still runs the check first)")
|
|
1641
2422
|
parser.add_argument("--root", default=".", help="repo root (default: current directory)")
|
|
1642
2423
|
parser.add_argument("--asof", default=None,
|
|
1643
|
-
help="reference date for
|
|
2424
|
+
help="reference date for plan-dates, format YYYY-MM-DD (default: today). "
|
|
1644
2425
|
"Stated explicitly so a run can be repeated exactly")
|
|
1645
2426
|
args = parser.parse_args(argv)
|
|
1646
2427
|
|
|
@@ -1663,14 +2444,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1663
2444
|
if result.findings:
|
|
1664
2445
|
print(f"\nRED — {len(result.findings)} findings across {len(result.red)} validators\n")
|
|
1665
2446
|
for finding in sorted(result.findings, key=lambda f: f.sort_key):
|
|
1666
|
-
print(f" {finding.vid:<
|
|
2447
|
+
print(f" {finding.vid:<26} {finding.subject}: {finding.message}")
|
|
1667
2448
|
else:
|
|
1668
2449
|
print("\nGREEN — no findings")
|
|
1669
2450
|
|
|
1670
2451
|
if result.skipped:
|
|
1671
2452
|
print("\nSkipped:")
|
|
1672
2453
|
for vid, why in sorted(result.skipped.items()):
|
|
1673
|
-
print(f" {vid:<
|
|
2454
|
+
print(f" {vid:<26} {why}")
|
|
1674
2455
|
|
|
1675
2456
|
print(f"\nV14 reference date: {asof.isoformat()}")
|
|
1676
2457
|
return 1 if result.findings else 0
|