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,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())
|