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