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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/bin/wdi-method.js +906 -0
  4. package/kit/.constitution/README.md +66 -0
  5. package/kit/.constitution/codebase/brownfield-guide.md +18 -0
  6. package/kit/.constitution/codebase/conventions-guide.md +18 -0
  7. package/kit/.constitution/codebase/stack-guide.md +18 -0
  8. package/kit/.constitution/constitution.md +135 -0
  9. package/kit/.constitution/document/architecture-guide.md +209 -0
  10. package/kit/.constitution/document/bmad-guide.md +151 -0
  11. package/kit/.constitution/document/bmad-skill-register.md +71 -0
  12. package/kit/.constitution/document/brief-guide.md +94 -0
  13. package/kit/.constitution/document/corpus-guide.md +341 -0
  14. package/kit/.constitution/document/decision-guide.md +134 -0
  15. package/kit/.constitution/document/delivery-flow-guide.md +338 -0
  16. package/kit/.constitution/document/prd-guide.md +202 -0
  17. package/kit/.constitution/document/sdd-guide.md +206 -0
  18. package/kit/.constitution/document/srs-guide.md +147 -0
  19. package/kit/.constitution/document/templates/addendum.md +71 -0
  20. package/kit/.constitution/document/templates/architecture-spine.md +100 -0
  21. package/kit/.constitution/document/templates/brief.md +110 -0
  22. package/kit/.constitution/document/templates/c4.md +77 -0
  23. package/kit/.constitution/document/templates/contract.md +52 -0
  24. package/kit/.constitution/document/templates/cross-cutting.md +80 -0
  25. package/kit/.constitution/document/templates/decision.md +64 -0
  26. package/kit/.constitution/document/templates/design-system.md +67 -0
  27. package/kit/.constitution/document/templates/flow.md +51 -0
  28. package/kit/.constitution/document/templates/integration.md +69 -0
  29. package/kit/.constitution/document/templates/inventory.md +69 -0
  30. package/kit/.constitution/document/templates/lc.md +47 -0
  31. package/kit/.constitution/document/templates/lifecycle.md +52 -0
  32. package/kit/.constitution/document/templates/meeting.md +47 -0
  33. package/kit/.constitution/document/templates/model.md +55 -0
  34. package/kit/.constitution/document/templates/oq.md +45 -0
  35. package/kit/.constitution/document/templates/prd.md +226 -0
  36. package/kit/.constitution/document/templates/questions.md +75 -0
  37. package/kit/.constitution/document/templates/rtr.md +61 -0
  38. package/kit/.constitution/document/templates/rules.md +47 -0
  39. package/kit/.constitution/document/templates/scn.md +40 -0
  40. package/kit/.constitution/document/templates/sdd.md +146 -0
  41. package/kit/.constitution/document/templates/srs.md +149 -0
  42. package/kit/.constitution/document/templates/structure-codebase.md +129 -0
  43. package/kit/.constitution/document/templates/structure-document.md +90 -0
  44. package/kit/.constitution/document/templates/uc.md +69 -0
  45. package/kit/.constitution/document/templates/ux.md +77 -0
  46. package/kit/.constitution/document/ux-guide.md +99 -0
  47. package/kit/.constitution/language-guide.md +69 -0
  48. package/kit/.constitution/method/README.md +169 -0
  49. package/kit/.constitution/method/artifact-map.md +155 -0
  50. package/kit/.constitution/method/portability.md +91 -0
  51. package/kit/.constitution/method/rationale.md +246 -0
  52. package/kit/.constitution/method-glossary.md +169 -0
  53. package/kit/.constitution/project/README.md +68 -0
  54. package/kit/.constitution/repo-guide.md +97 -0
  55. package/kit/.constitution/scripts/inventory.py +527 -0
  56. package/kit/.constitution/scripts/timeline.py +665 -0
  57. package/kit/.constitution/scripts/validate.py +1579 -0
  58. package/kit/.constitution/structure-guide.md +202 -0
  59. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -0
  60. package/kit/assets/bmad-custom/bmad-architecture.toml +31 -0
  61. package/kit/assets/bmad-custom/bmad-build-auto.toml +10 -0
  62. package/kit/assets/bmad-custom/bmad-build.toml +52 -0
  63. package/kit/assets/bmad-custom/bmad-code-review.toml +12 -0
  64. package/kit/assets/bmad-custom/bmad-correct-course.toml +27 -0
  65. package/kit/assets/bmad-custom/bmad-deep-recon.toml +11 -0
  66. package/kit/assets/bmad-custom/bmad-prd.toml +42 -0
  67. package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -0
  68. package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -0
  69. package/kit/assets/bmad-custom/bmad-spec.toml +15 -0
  70. package/kit/assets/bmad-custom/bmad-ux.toml +19 -0
  71. package/kit/assets/bmad-custom/config.toml +6 -0
  72. package/kit/skills/wdi-blueprint/SKILL.md +203 -0
  73. package/kit/skills/wdi-build/SKILL.md +244 -0
  74. package/kit/skills/wdi-component/SKILL.md +172 -0
  75. package/kit/skills/wdi-decision/.gitkeep +0 -0
  76. package/kit/skills/wdi-decision/SKILL.md +183 -0
  77. package/kit/skills/wdi-help/.gitkeep +0 -0
  78. package/kit/skills/wdi-help/SKILL.md +98 -0
  79. package/kit/skills/wdi-init/SKILL.md +149 -0
  80. package/kit/skills/wdi-log/SKILL.md +107 -0
  81. package/kit/skills/wdi-problem/SKILL.md +90 -0
  82. package/kit/skills/wdi-product/SKILL.md +127 -0
  83. package/kit/skills/wdi-question/.gitkeep +0 -0
  84. package/kit/skills/wdi-question/SKILL.md +88 -0
  85. package/kit/skills/wdi-reconcile/.gitkeep +0 -0
  86. package/kit/skills/wdi-reconcile/SKILL.md +146 -0
  87. package/kit/skills/wdi-report/SKILL.md +205 -0
  88. package/kit/skills/wdi-review/SKILL.md +118 -0
  89. package/kit/skills/wdi-systematic-debugging/SKILL.md +193 -0
  90. package/kit/skills/wdi-systematic-debugging/references/condition-based-waiting.md +115 -0
  91. package/kit/skills/wdi-systematic-debugging/references/defense-in-depth.md +122 -0
  92. package/kit/skills/wdi-systematic-debugging/references/find-polluter.sh +72 -0
  93. package/kit/skills/wdi-systematic-debugging/references/root-cause-tracing.md +169 -0
  94. package/kit/skills/wdi-ux/SKILL.md +145 -0
  95. package/kit-overlay/AGENTS.md +196 -0
  96. package/kit-overlay/README.md +66 -0
  97. package/kit-overlay/constitution.md +135 -0
  98. package/kit-overlay/portability.md +91 -0
  99. package/kit-overlay/repo-guide.md +97 -0
  100. package/lib/agents-block.mjs +52 -0
  101. package/lib/identity.mjs +65 -0
  102. package/package.json +37 -0
  103. package/scaffold/.control/decisions/.gitkeep +0 -0
  104. package/scaffold/.control/generated/.gitkeep +2 -0
  105. package/scaffold/.control/meetings/.gitkeep +0 -0
  106. package/scaffold/.control/memlog/.gitkeep +0 -0
  107. package/scaffold/.control/product-glossary.md +21 -0
  108. package/scaffold/.control/project-non-technical-log.md +23 -0
  109. package/scaffold/.control/questions/answered.md +11 -0
  110. package/scaffold/.control/questions/assumptions.md +15 -0
  111. package/scaffold/.control/questions/blocking.md +21 -0
  112. package/scaffold/.control/questions/external.md +11 -0
  113. package/scaffold/.control/registry/components.yaml +21 -0
  114. package/scaffold/.control/registry/decisions.yaml +6 -0
  115. package/scaffold/.control/registry/defects.yaml +3 -0
  116. package/scaffold/.control/registry/index.yaml +46 -0
  117. package/scaffold/.control/registry/requirements.yaml +15 -0
  118. package/scaffold/.control/registry/risks.yaml +5 -0
  119. package/scaffold/.control/registry/usecases.yaml +6 -0
  120. package/scaffold/.control/registry/waves.yaml +5 -0
  121. package/scaffold/.control/structure-codebase.md +19 -0
  122. package/scaffold/.control/structure-document.md +25 -0
@@ -0,0 +1,527 @@
1
+ #!/usr/bin/env -S uv run --script
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml>=6"]
5
+ # ///
6
+ """inventory — menurunkan tiga inventaris dari kode, lalu mengadunya dengan rencananya.
7
+
8
+ Tiga inventaris — tabel, endpoint, layar — adalah keluaran G3 Blueprint dan ADA pada setiap `mode`,
9
+ termasuk `catalog`. Mereka lahir dua cara, dan `derived_from` di frontmatter menyatakan yang mana:
10
+
11
+ plan belum ada kode. Ditulis sebagai RENCANA oleh wdi-blueprint. Tidak ada yang bisa
12
+ diturunkan, karena belum ada sumbernya.
13
+ code kodenya sudah ada. Diturunkan LEBIH DULU oleh script ini, lalu diadu dengan rencananya.
14
+
15
+ Selisih rencana-versus-kenyataan adalah TEMUAN, dan ia dilaporkan. Ia MUST NOT ditambal dengan
16
+ menyunting sisi yang lain — itu mengubah pekerjaan yang bisa terlupa jadi pekerjaan yang pasti
17
+ terlupa. Script ini karena itu punya dua mode dan yang pertama adalah defaultnya:
18
+
19
+ inventory --check turunkan, adu, laporkan. TIDAK menulis satu berkas pun
20
+ inventory --write tulis ulang bagian ## Rows dari apa yang diturunkan, lalu laporkan selisihnya
21
+
22
+ Determinisme adalah kontraknya, sama seperti validate.py: dua run atas kode yang sama MUST memberi
23
+ hasil yang sama. Karena itu semua iterasi terurut dan tidak ada yang bergantung pada jam dinding.
24
+
25
+ BATAS YANG DINYATAKAN DI MUKA. Ini pembaca pola, bukan compiler. Ia membaca:
26
+ tabel pernyataan CREATE TABLE di src/internal/platform/migrate/migrations/*.sql
27
+ endpoint registrasi route pada router Gin di src/**/*.go
28
+ layar komponen route pada SPA React di web/*/src/**/*.tsx
29
+ Apa yang tidak terbaca pola itu dilaporkan sebagai yang tidak terbaca — TIDAK ditebak, dan TIDAK
30
+ dihilangkan diam-diam. Sebuah inventaris MUST NOT dirakit dari README atau dari nama route yang
31
+ tampak masuk akal.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import re
38
+ import sys
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+
42
+ KINDS = ("db", "api", "screen")
43
+
44
+ # ------------------------------------------------------------------ pola baca
45
+
46
+ # CREATE TABLE [IF NOT EXISTS] `nama` | nama
47
+ RE_TABLE = re.compile(
48
+ r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?",
49
+ re.I)
50
+ RE_DROP_TABLE = re.compile(
51
+ r"DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?", re.I)
52
+ # PRIMARY KEY / UNIQUE / FOREIGN KEY — kolom kunci, bukan seluruh kolom
53
+ RE_KEYCOL = re.compile(
54
+ r"(?:PRIMARY\s+KEY|UNIQUE(?:\s+KEY)?|FOREIGN\s+KEY)[^(\n]*\(([^)]*)\)", re.I)
55
+
56
+ # r.GET("/path", ...) · group.POST(`/path`, ...) · r.Handle("GET", "/path", ...)
57
+ RE_ROUTE = re.compile(
58
+ r"\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
59
+ RE_GROUP = re.compile(r"\.\s*Group\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
60
+
61
+ # Sebuah route group DIBUAT di satu berkas dan DIPAKAI di berkas lain:
62
+ # portal.go:26 mountMemberAPI(engine.Group("/api"), ...)
63
+ # member_api.go api.GET("/me", ...)
64
+ # Karena itu prefiks TIDAK dapat disimpulkan per berkas. Ia harus mengikuti mount-nya, dan host-nya
65
+ # ikut dari titik pasang di app.go. Pola di bawah yang membuat penelusuran itu mungkin.
66
+ RE_FUNC_DEF = re.compile(r"^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", re.M)
67
+ # mux.Handle(cfg.HostPublic, routing.NewPublicWithStore(...)) -> host `public`, entry NewPublicWithStore
68
+ RE_HOST_MOUNT = re.compile(
69
+ r"Handle\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\.Host([A-Za-z0-9_]+)\s*,\s*"
70
+ r"(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\(")
71
+ # mountX(engine.Group("/api"), ...) | mountX(api, ...)
72
+ RE_CALL_GROUP = re.compile(
73
+ r"\b([a-z][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*Group\s*\(\s*[`\"]([^`\"]*)[`\"]\s*\)")
74
+ RE_CALL_PLAIN = re.compile(r"\b([a-z][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*[,)]")
75
+ # api := engine.Group("/x") | protected := api.Group("")
76
+ RE_ASSIGN_GROUP = re.compile(
77
+ r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*Group\s*\(\s*[`\"]([^`\"]*)[`\"]",
78
+ re.M)
79
+ RE_ASSIGN_ENGINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*gin\.New\s*\(", re.M)
80
+ RE_ROUTE_ON = re.compile(
81
+ r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*[`\"]([^`\"]+)[`\"]",
82
+ re.I)
83
+
84
+ # <Route path="/x" element={<Thing />} /> — react-router
85
+ RE_ROUTE_TSX = re.compile(
86
+ r"<Route\s[^>]*path\s*=\s*[{\"']+([^\"'}]+)[\"'}]+[^>]*?"
87
+ r"element\s*=\s*\{\s*<\s*([A-Za-z0-9_]+)", re.S)
88
+
89
+
90
+ @dataclass
91
+ class Row:
92
+ key: str # identitas stabil baris, dipakai membandingkan
93
+ cells: list[str]
94
+ source: str # berkas tempat ia terbaca
95
+
96
+
97
+ @dataclass
98
+ class Derived:
99
+ rows: list[Row] = field(default_factory=list)
100
+ unread: list[str] = field(default_factory=list)
101
+
102
+
103
+ def read(path: Path) -> str:
104
+ try:
105
+ return path.read_text(encoding="utf-8", errors="replace")
106
+ except OSError:
107
+ return ""
108
+
109
+
110
+ RE_DOWN = re.compile(r"^\s*--\s*\+(?:goose|migrate)\s+Down\b", re.I | re.M)
111
+
112
+
113
+ def up_section(text: str) -> str:
114
+ """Hanya bagian Up sebuah migrasi.
115
+
116
+ Bagian Down memuat DROP TABLE untuk setiap tabel yang Up-nya membuat, jadi membaca seluruh berkas
117
+ membuat setiap tabel terbaca sebagai dibuang. Pemisahannya MUST terjadi SEBELUM komentar
118
+ dibuang, karena penanda goose sendiri adalah komentar.
119
+ """
120
+ match = RE_DOWN.search(text)
121
+ return text[:match.start()] if match else text
122
+
123
+
124
+ def strip_sql_comments(text: str) -> str:
125
+ text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
126
+ return "\n".join(re.sub(r"(--|#).*$", "", line) for line in text.splitlines())
127
+
128
+
129
+ # --------------------------------------------------------------------- tabel
130
+
131
+
132
+ def table_owner(root: Path) -> dict[str, str]:
133
+ """Pemilik tiap tabel, dibaca dari `owns` dan `platform_owns` di components.yaml.
134
+
135
+ Kolom pemilik BISA diturunkan sejak nilai `owns` disetel, jadi menuliskannya sebagai
136
+ [NEEDS CONFIRMATION] akan menandai sebagai tidak diketahui sesuatu yang registry sudah nyatakan.
137
+ Yang tidak diklaim siapa pun tetap [NEEDS CONFIRMATION] — itu temuan, bukan celah.
138
+ """
139
+ import yaml as _yaml
140
+ path = root / ".control/registry/components.yaml"
141
+ if not path.exists():
142
+ return {}
143
+ data = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
144
+ out: dict[str, str] = {}
145
+ for pc in (data.get("product_components") or []):
146
+ for entity in (pc.get("owns") or []):
147
+ out[str(entity)] = str(pc.get("id"))
148
+ for entity in (data.get("platform_owns") or []):
149
+ out.setdefault(str(entity), "_platform")
150
+ return out
151
+
152
+
153
+ def derive_db(root: Path) -> Derived:
154
+ out = Derived()
155
+ owner = table_owner(root)
156
+ folder = root / "src/internal/platform/migrate/migrations"
157
+ if not folder.is_dir():
158
+ out.unread.append(f"{folder.as_posix()} tidak ada — tidak ada migrasi yang bisa dibaca")
159
+ return out
160
+
161
+ created: dict[str, tuple[str, str]] = {} # tabel -> (kolom kunci, berkas)
162
+ dropped: set[str] = set()
163
+ for path in sorted(folder.glob("*.sql")):
164
+ body = strip_sql_comments(up_section(read(path)))
165
+ rel = path.relative_to(root).as_posix()
166
+ for stmt in body.split(";"):
167
+ match = RE_TABLE.search(stmt)
168
+ if match:
169
+ name = match.group(1)
170
+ keys = sorted({c.strip().strip("`\"") for group in RE_KEYCOL.findall(stmt)
171
+ for c in group.split(",") if c.strip()})
172
+ created[name] = (", ".join(f"`{k}`" for k in keys) or "—", rel)
173
+ continue
174
+ for name in RE_DROP_TABLE.findall(stmt):
175
+ dropped.add(name)
176
+
177
+ for name in sorted(created):
178
+ if name in dropped:
179
+ continue
180
+ keys, rel = created[name]
181
+ who = owner.get(name)
182
+ out.rows.append(Row(key=name, source=rel,
183
+ cells=[f"`{name}`",
184
+ f"`{who}`" if who else "[NEEDS CONFIRMATION]",
185
+ "[NEEDS CONFIRMATION]", keys, "published"]))
186
+ if not who:
187
+ out.unread.append(f"tabel `{name}` tidak diklaim `owns` maupun `platform_owns` — "
188
+ f"V21 tidak melihatnya, dan tidak ada yang berwenang menulisnya")
189
+ if dropped:
190
+ out.unread.append("tabel yang di-DROP di bagian Up dan karena itu tidak didaftarkan: "
191
+ + ", ".join(sorted(dropped)))
192
+ return out
193
+
194
+
195
+ # ------------------------------------------------------------------ endpoint
196
+
197
+
198
+ def _go_funcs(root: Path) -> tuple[dict[str, str], list[str]]:
199
+ """Tiap fungsi Go di src/ dengan body-nya, plus daftar berkas yang dibaca."""
200
+ bodies: dict[str, str] = {}
201
+ files: list[str] = []
202
+ for path in sorted((root / "src").rglob("*.go")):
203
+ if path.name.endswith("_test.go"):
204
+ continue
205
+ body = read(path)
206
+ files.append(body)
207
+ marks = [(m.start(), m.group(1), m.group(2)) for m in RE_FUNC_DEF.finditer(body)]
208
+ for i, (start, name, params) in enumerate(marks):
209
+ end = marks[i + 1][0] if i + 1 < len(marks) else len(body)
210
+ bodies[name] = body[start:end]
211
+ bodies[name + "\x00params"] = params
212
+ return bodies, files
213
+
214
+
215
+ def _walk(fn: str, host: str, prefix: str, router_vars: dict[str, str],
216
+ bodies: dict[str, str], out: dict[tuple[str, str, str], str],
217
+ seen_calls: set[tuple[str, str, str]], depth: int = 0) -> None:
218
+ """Telusuri satu fungsi: catat route-nya, lalu ikuti mount yang ia pasang.
219
+
220
+ Sebuah fungsi MAY dipasang dari lebih dari satu host — `mountSharedPublicReads` dipanggil dari
221
+ `member_api.go` DAN `public_api.go` — jadi rekursi ini sengaja tidak memoisasi per fungsi,
222
+ hanya per (fungsi, host, prefiks). Tanpa itu, endpoint pada host kedua hilang tanpa jejak.
223
+ """
224
+ if depth > 8 or (fn, host, prefix) in seen_calls:
225
+ return
226
+ seen_calls.add((fn, host, prefix))
227
+ body = bodies.get(fn)
228
+ if body is None:
229
+ return
230
+
231
+ local = dict(router_vars)
232
+ params = bodies.get(fn + "\x00params", "")
233
+ for piece in params.split(","):
234
+ piece = piece.strip()
235
+ if "gin.RouterGroup" in piece or "gin.Engine" in piece:
236
+ local[piece.split()[0]] = prefix
237
+ for m in RE_ASSIGN_ENGINE.finditer(body):
238
+ local[m.group(1)] = prefix
239
+ for m in RE_ASSIGN_GROUP.finditer(body):
240
+ base = local.get(m.group(2))
241
+ if base is not None:
242
+ local[m.group(1)] = (base + m.group(3)).rstrip("/")
243
+
244
+ for var, method, raw in RE_ROUTE_ON.findall(body):
245
+ if not raw.startswith("/") or var not in local:
246
+ continue
247
+ full = (local[var] + raw).replace("//", "/")
248
+ out.setdefault((host, method.upper(), full), fn)
249
+
250
+ for callee, var, grp in RE_CALL_GROUP.findall(body):
251
+ if var in local and callee in bodies:
252
+ _walk(callee, host, (local[var] + grp).rstrip("/"), {}, bodies, out, seen_calls, depth + 1)
253
+ for callee, var in RE_CALL_PLAIN.findall(body):
254
+ if var in local and callee in bodies:
255
+ _walk(callee, host, local[var], {}, bodies, out, seen_calls, depth + 1)
256
+
257
+
258
+ def derive_api(root: Path) -> Derived:
259
+ out = Derived()
260
+ if not (root / "src").is_dir():
261
+ out.unread.append("src/ tidak ada — tidak ada registrasi route yang bisa dibaca")
262
+ return out
263
+
264
+ bodies, files = _go_funcs(root)
265
+ entries: list[tuple[str, str]] = []
266
+ for body in files:
267
+ for host, fn in RE_HOST_MOUNT.findall(body):
268
+ entries.append((host.lower(), fn))
269
+ if not entries:
270
+ out.unread.append(
271
+ "tidak satu pun titik pasang host terbaca (pola `Handle(cfg.Host<X>, <Fn>(`) — "
272
+ "seluruh path di bawah ini MUST diperiksa tangan, sebab prefiks group tidak dapat "
273
+ "ditelusuri tanpa titik pasangnya")
274
+
275
+ found: dict[tuple[str, str, str], str] = {}
276
+ for host, fn in sorted(set(entries)):
277
+ _walk(fn, host, "", {}, bodies, found, set())
278
+
279
+ _, plat = decisions(root / ".how/_platform/inventory-api.md")
280
+ for (host, method, path_str) in sorted(found):
281
+ key = f"{host} {method} {path_str}"
282
+ owner = "`_platform`" if (key in plat or f"{method} {path_str}" in plat
283
+ or path_str in plat) else "[NEEDS CONFIRMATION]"
284
+ out.rows.append(Row(key=key, source=found[(host, method, path_str)],
285
+ cells=[host, method, f"`{path_str}`", owner,
286
+ "[NEEDS CONFIRMATION]", "published"]))
287
+ if not found:
288
+ out.unread.append("tidak satu pun registrasi route terbaca di src/**/*.go — "
289
+ "bila API-nya memang belum ada, `derived_from: plan` yang benar")
290
+ return out
291
+
292
+
293
+ def derive_screen(root: Path) -> Derived:
294
+ out = Derived()
295
+ folder = root / "web"
296
+ if not folder.is_dir():
297
+ out.unread.append("web/ tidak ada — tidak ada halaman yang bisa dibaca")
298
+ return out
299
+
300
+ # KUNCINYA (spa, rute), BUKAN rute saja. Produk ini punya dua SPA yang dibangun terpisah, dan
301
+ # keduanya mendeklarasikan `/`, `/login`, dan `*`. Mengunci pada rute saja menghilangkan yang
302
+ # kembar tanpa suara: 26 layar terbaca sebagai 23. Sebuah rute bukan identitas sebuah layar di
303
+ # produk ber-SPA jamak — hostnya bagian dari identitas itu.
304
+ seen: dict[tuple[str, str], tuple[str, str]] = {}
305
+ for path in sorted(folder.rglob("*.tsx")):
306
+ if "node_modules" in path.parts or path.name.endswith(".test.tsx"):
307
+ continue
308
+ rel = path.relative_to(root).as_posix()
309
+ parts = path.relative_to(folder).parts
310
+ spa = parts[0] if parts else "?"
311
+ for route, component in RE_ROUTE_TSX.findall(read(path)):
312
+ seen.setdefault((spa, route.strip()), (component, rel))
313
+
314
+ states, _ = decisions(root / ".how/_platform/inventory-screen.md")
315
+ folded: dict[str, list[str]] = {}
316
+ for (spa, route) in sorted(seen):
317
+ parent = states.get(route)
318
+ if parent:
319
+ folded.setdefault(f"{spa}:{parent}", []).append(route)
320
+
321
+ for spa, route in sorted(seen):
322
+ if route in states:
323
+ continue # ia keadaan dari layar lain, bukan baris tersendiri
324
+ component, rel = seen[(spa, route)]
325
+ extra = folded.get(f"{spa}:{route}") or []
326
+ state_cell = ", ".join(f"`{r}`" for r in sorted(extra)) if extra else "—"
327
+ out.rows.append(Row(key=f"{spa}:{route}", source=rel,
328
+ cells=[f"`{spa}/{component}`", f"`{route}`", state_cell,
329
+ "[NEEDS CONFIRMATION]", "[NEEDS CONFIRMATION]"]))
330
+
331
+ orphan = sorted(r for r in states
332
+ if not any(states[r] == route for _, route in seen))
333
+ if orphan:
334
+ out.unread.append("rute-keadaan yang induknya tidak terbaca di kode: " + ", ".join(orphan))
335
+ if not seen:
336
+ out.unread.append("tidak satu pun <Route path=... element={<X />}> terbaca di web/**/*.tsx")
337
+ else:
338
+ shared = sorted({r for _, r in seen} & {r for s, r in seen if s != sorted({x for x, _ in seen})[0]})
339
+ dupes = sorted({r for s, r in seen} )
340
+ collide = sorted({r for r in dupes if sum(1 for s2, r2 in seen if r2 == r) > 1})
341
+ if collide:
342
+ out.unread.append("rute yang dideklarasikan LEBIH DARI SATU SPA, dan karena itu bukan "
343
+ "identitas layar dengan sendirinya: " + ", ".join(collide))
344
+ return out
345
+
346
+
347
+ DERIVERS = {"db": derive_db, "api": derive_api, "screen": derive_screen}
348
+ HEADERS = {
349
+ "db": ("No", "Table", "Owning component", "What it holds", "Key columns", "Status"),
350
+ "api": ("No", "Host", "Method", "Path", "Owning component", "Description", "Status"),
351
+ "screen": ("No", "Screen", "Route", "States", "Owning component", "UC served"),
352
+ }
353
+
354
+
355
+ # ------------------------------------------------------- rencana yang tercatat
356
+
357
+
358
+ ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|(.*)\|\s*$")
359
+ FM_RE = re.compile(r"\A---\n(.*?)\n---", re.S)
360
+
361
+
362
+ def decisions(path: Path) -> tuple[dict[str, str], set[str]]:
363
+ """(`states`, `platform_rows`) dari frontmatter — keputusan pemilik, bukan hasil pola.
364
+
365
+ `states` memetakan rute-keadaan ke rute layar induknya. Sebuah keadaan BUKAN layar: ux-guide
366
+ sudah menuntut tiap layar punya keadaan kosong dan error, jadi keadaan adalah kolom pada baris
367
+ induknya, bukan baris kedua.
368
+
369
+ `platform_rows` menyebut baris yang dimiliki `_platform`. Tidak ada janji Product Component di
370
+ belakangnya, dan corpus-guide memiliki tes dua-bagiannya.
371
+ """
372
+ if not path.exists():
373
+ return {}, set()
374
+ match = FM_RE.match(read(path))
375
+ if not match:
376
+ return {}, set()
377
+ try:
378
+ import yaml as _yaml
379
+ fm = _yaml.safe_load(match.group(1)) or {}
380
+ except Exception:
381
+ return {}, set()
382
+ states = {str(k): str(v) for k, v in (fm.get("states") or {}).items()}
383
+ plat = {str(x) for x in (fm.get("platform_rows") or [])}
384
+ return states, plat
385
+
386
+
387
+ def plan_rows(path: Path) -> tuple[dict[int, list[str]], str | None]:
388
+ """Baca bagian ## Rows dari inventaris yang tercatat. None bila berkasnya belum ada."""
389
+ if not path.exists():
390
+ return {}, None
391
+ text = read(path)
392
+ inside = False
393
+ rows: dict[int, list[str]] = {}
394
+ for line in text.splitlines():
395
+ if line.startswith("## "):
396
+ inside = line[3:].strip().lower().startswith("rows")
397
+ continue
398
+ if not inside:
399
+ continue
400
+ match = ROW_RE.match(line.strip())
401
+ if match:
402
+ rows[int(match.group(1))] = [c.strip() for c in match.group(2).split("|")]
403
+ mode = "plan"
404
+ fm = re.search(r"^derived_from:\s*(\w+)", text, re.M)
405
+ if fm:
406
+ mode = fm.group(1)
407
+ return rows, mode
408
+
409
+
410
+ def plan_keys(kind: str, rows: dict[int, list[str]]) -> dict[str, int]:
411
+ """Identitas stabil baris rencana, disusun sama dengan Row.key hasil derivasi."""
412
+ out: dict[str, int] = {}
413
+ for number, cells in sorted(rows.items()):
414
+ if kind == "db" and cells:
415
+ out[cells[0].strip("`")] = number
416
+ elif kind == "api" and len(cells) >= 3:
417
+ # Host bagian identitas: satu fungsi mount MAY dipasang di lebih dari satu host, dan
418
+ # tanpa host di kunci kedua endpoint itu melebur jadi satu baris.
419
+ out[f"{cells[0]} {cells[1].upper()} {cells[2].strip('`')}"] = number
420
+ elif kind == "screen" and len(cells) >= 2:
421
+ # Screen ditulis `<spa>/<Component>`; spa-nya bagian identitas, sama seperti di derivasi.
422
+ screen = cells[0].strip("`")
423
+ spa = screen.split("/", 1)[0] if "/" in screen else "?"
424
+ out[f"{spa}:{cells[1].strip('`')}"] = number
425
+ return out
426
+
427
+
428
+ def render_rows(kind: str, derived: Derived, keys: dict[str, int]) -> str:
429
+ """Nomor STABIL: baris yang sudah punya nomor mempertahankannya, yang baru mengambil berikutnya.
430
+
431
+ Menomori ulang berarti mengganti nama setiap berkas sesudahnya dan mematahkan setiap tautan yang
432
+ menunjuk ke arahnya, jadi ia MUST NOT dilakukan — juga ketika baris di tengah menghilang.
433
+ """
434
+ next_no = max(keys.values(), default=0) + 1
435
+ lines = ["| " + " | ".join(HEADERS[kind]) + " |",
436
+ "| " + " | ".join("---" for _ in HEADERS[kind]) + " |"]
437
+ for row in derived.rows:
438
+ number = keys.get(row.key)
439
+ if number is None:
440
+ number = next_no
441
+ next_no += 1
442
+ lines.append(f"| {number} | " + " | ".join(row.cells) + " |")
443
+ return "\n".join(lines)
444
+
445
+
446
+ def write_rows(path: Path, block: str) -> None:
447
+ text = read(path)
448
+ if "## Rows" not in text:
449
+ raise SystemExit(f"inventory: {path.as_posix()} tidak punya bagian `## Rows` — "
450
+ f"lahirkan dulu dari templates/inventory.md")
451
+ head, _, rest = text.partition("## Rows")
452
+ tail = ""
453
+ for marker in ("\n## ",):
454
+ idx = rest.find(marker)
455
+ if idx != -1:
456
+ tail = rest[idx:]
457
+ break
458
+ path.write_text(head + "## Rows\n\n" + block + "\n" + tail, encoding="utf-8")
459
+
460
+
461
+ # ----------------------------------------------------------------------- CLI
462
+
463
+
464
+ def main(argv: list[str] | None = None) -> int:
465
+ parser = argparse.ArgumentParser(
466
+ prog="inventory",
467
+ description="Turunkan tiga inventaris dari kode, lalu adu dengan rencananya")
468
+ parser.add_argument("--check", action="store_true",
469
+ help="turunkan dan laporkan; tidak menulis apa pun (default)")
470
+ parser.add_argument("--write", action="store_true",
471
+ help="tulis ulang bagian ## Rows dari hasil derivasi")
472
+ parser.add_argument("--kind", choices=KINDS, action="append",
473
+ help="batasi ke satu jenis; boleh diulang")
474
+ parser.add_argument("--root", default=".", help="akar repo (default: direktori sekarang)")
475
+ args = parser.parse_args(argv)
476
+
477
+ root = Path(args.root).resolve()
478
+ if not (root / ".control" / "registry").is_dir():
479
+ print(f"inventory: {root} tidak punya .control/registry/ — salah akar repo?", file=sys.stderr)
480
+ return 2
481
+
482
+ kinds = args.kind or list(KINDS)
483
+ findings = 0
484
+
485
+ for kind in kinds:
486
+ path = root / f".how/_platform/inventory-{kind}.md"
487
+ rel = path.relative_to(root).as_posix()
488
+ derived = DERIVERS[kind](root)
489
+ recorded, mode = plan_rows(path)
490
+ keys = plan_keys(kind, recorded)
491
+
492
+ print(f"\n=== {kind} — {rel}")
493
+ if mode is None:
494
+ print(" berkasnya belum ada. Lahirkan dari templates/inventory.md; sampai itu terjadi "
495
+ "tidak ada rencana yang bisa diadu")
496
+ else:
497
+ print(f" derived_from: {mode} · {len(recorded)} baris tercatat")
498
+ print(f" {len(derived.rows)} baris terbaca dari kode")
499
+
500
+ derived_keys = {row.key for row in derived.rows}
501
+ missing = sorted(set(keys) - derived_keys) # direncanakan, tidak ada di kode
502
+ extra = sorted(derived_keys - set(keys)) # ada di kode, tidak direncanakan
503
+
504
+ for item in missing:
505
+ print(f" TEMUAN direncanakan tetapi tidak terbaca di kode: {item}")
506
+ for item in extra:
507
+ print(f" TEMUAN ada di kode tetapi tidak tercatat di rencana: {item}")
508
+ for note in derived.unread:
509
+ print(f" TIDAK TERBACA {note}")
510
+ findings += len(missing) + len(extra)
511
+
512
+ if args.write:
513
+ if not path.exists():
514
+ print(" --write dilewati: berkasnya belum ada")
515
+ continue
516
+ write_rows(path, render_rows(kind, derived, keys))
517
+ print(f" tulis {rel} — bagian ## Rows saja")
518
+
519
+ print(f"\n{findings} selisih rencana-versus-kode.")
520
+ if findings:
521
+ print("Selisih ini TEMUAN, bukan pekerjaan tangan. Ia dirutekan ke skill yang memiliki "
522
+ "sisinya, dan MUST NOT ditambal dengan menyunting sisi yang lain.")
523
+ return 1 if findings else 0
524
+
525
+
526
+ if __name__ == "__main__":
527
+ raise SystemExit(main())