wdi-method 0.4.3 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +252 -222
  2. package/bin/wdi-method.js +1030 -1029
  3. package/kit/.constitution/document/delivery-flow-guide.md +1 -1
  4. package/kit/.constitution/document/templates/cross-cutting.md +4 -4
  5. package/kit/.constitution/document/templates/model.md +2 -2
  6. package/kit/.constitution/document/templates/questions.md +10 -9
  7. package/kit/.constitution/document/templates/srs.md +2 -2
  8. package/kit/.constitution/scripts/inventory.py +102 -100
  9. package/kit/.constitution/scripts/timeline.py +665 -665
  10. package/kit/.constitution/scripts/validate.py +314 -312
  11. package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -15
  12. package/kit/assets/bmad-custom/bmad-architecture.toml +17 -15
  13. package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
  14. package/kit/assets/bmad-custom/bmad-build.toml +52 -52
  15. package/kit/assets/bmad-custom/bmad-code-review.toml +6 -5
  16. package/kit/assets/bmad-custom/bmad-correct-course.toml +28 -27
  17. package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
  18. package/kit/assets/bmad-custom/bmad-prd.toml +22 -22
  19. package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -34
  20. package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -9
  21. package/kit/assets/bmad-custom/bmad-spec.toml +9 -8
  22. package/kit/assets/bmad-custom/bmad-ux.toml +7 -7
  23. package/kit/assets/bmad-custom/config.toml +3 -3
  24. package/kit/skills/wdi-report/SKILL.md +5 -5
  25. package/package.json +2 -2
  26. package/scaffold/.control/product-glossary.md +21 -21
  27. package/scaffold/.control/project-non-technical-log.md +23 -23
  28. package/scaffold/.control/questions/answered.md +11 -11
  29. package/scaffold/.control/questions/assumptions.md +15 -15
  30. package/scaffold/.control/questions/blocking.md +21 -21
  31. package/scaffold/.control/questions/external.md +11 -11
  32. package/scaffold/.control/registry/components.yaml +21 -21
  33. package/scaffold/.control/registry/defects.yaml +3 -3
  34. package/scaffold/.control/registry/index.yaml +46 -46
  35. package/scaffold/.control/registry/requirements.yaml +15 -15
  36. package/scaffold/.control/registry/risks.yaml +5 -5
  37. package/scaffold/.control/registry/usecases.yaml +6 -6
@@ -159,7 +159,7 @@ MUST NOT be negotiated.
159
159
 
160
160
  ## Gate checklists
161
161
 
162
- Each question is answered **ya / tidak / ubah**. One "tidak" on a ★ question holds the gate.
162
+ Each question is answered **yes / no / change**. One "no" on a ★ question holds the gate.
163
163
 
164
164
  **On `mode: catalog`, only the ★ questions are asked** — fourteen across all five gates. The rest stay here
165
165
  as material, and asking them is never wrong; requiring them is.
@@ -53,17 +53,17 @@ updated: '{YYYY-MM-DD}'
53
53
  one component depends on it. corpus-guide.md owns that test, and it refuses the one use people
54
54
  reach for: "the owner is hard to decide".
55
55
 
56
- `Jenis` is data · endpoint · job · screen, and the list is open. What is not open is the test.
56
+ `Kind` is data · endpoint · job · screen, and the list is open. What is not open is the test.
57
57
 
58
58
  `_platform` has no `FR`, so there is no owner-FR for another component to point at. What replaces
59
59
  "one writer" is ONE DOCUMENTED SHAPE — stated here, once. A component that wants it different is
60
60
  proposing a change to this file, not making a local choice. -->
61
61
 
62
- | Apa | Jenis | Kenapa tidak ada komponen yang menjelaskannya | Siapa yang menyentuhnya | Bentuk yang setiap penyentuh patuhi |
62
+ | What | Kind | Why no component explains it | Who touches it | The shape every toucher obeys |
63
63
  | --- | --- | --- | --- | --- |
64
64
 
65
- <!-- `Siapa yang menyentuhnya` menyebut komponen, dan menyebut lebih dari satu adalah keadaan NORMAL —
66
- itu separuh alasan barisnya ada di sini. Satu penyentuh adalah sinyal ia milik komponen itu. -->
65
+ <!-- `Who touches it` names components, and naming more than one is a NORMAL state that is half the
66
+ reason the row is here. A single toucher is a signal it belongs to that component instead. -->
67
67
 
68
68
  ## Other product-level agreements
69
69
 
@@ -32,8 +32,8 @@ updated: '{YYYY-MM-DD}'
32
32
 
33
33
  ## Relationships
34
34
 
35
- <!-- Direction and cardinality. State them as sentences a person would say: "satu member punya nol
36
- atau satu sponsor". -->
35
+ <!-- Direction and cardinality. State them as sentences a person would say: "one member has zero
36
+ or one sponsor". -->
37
37
 
38
38
  ## State Lifecycle
39
39
 
@@ -6,7 +6,7 @@ created: '{YYYY-MM-DD}'
6
6
  updated: '{YYYY-MM-DD}'
7
7
  ---
8
8
 
9
- # {Pertanyaan yang Memblokir | Asumsi | Menunggu Pihak Luar | Terjawab}
9
+ # {Blocking Questions | Assumptions | Waiting on an Outside Party | Answered}
10
10
 
11
11
  <!-- TEMPLATE GUIDE — act on these comments, then delete them.
12
12
 
@@ -30,8 +30,9 @@ updated: '{YYYY-MM-DD}'
30
30
  A ROW MOVES BETWEEN FILES WHEN ITS CLASS CHANGES, and it MUST NOT be copied into a second one.
31
31
 
32
32
  Ids stay OQ-, allocated from the highest ever used including closed ones. An id MUST NOT be
33
- reused. The literal values inside the tables stay Bahasa Indonesia [BELUM DITETAPKAN] and the
34
- rest wherever they appear, including inside an English template. -->
33
+ reused. The prose inside the tables follows the product's `doc_language`; a machine-facing
34
+ marker such as `[NEEDS CONFIRMATION]` stays English wherever it appears — `language-guide.md`
35
+ owns that split. -->
35
36
 
36
37
  ## The class test
37
38
 
@@ -47,26 +48,26 @@ updated: '{YYYY-MM-DD}'
47
48
 
48
49
  A question MUST NOT be filed as blocking "to be safe". That habit is what produced 146 ids. -->
49
50
 
50
- ## Terbuka
51
+ ## Open
51
52
 
52
53
  <!-- list: blocking · external -->
53
54
 
54
- | id | Pertanyaan | Memblokir | Owner | Sebelum |
55
+ | id | Question | Blocks | Owner | Before |
55
56
  |---|---|---|---|---|
56
57
 
57
58
  <!-- list: assumptions — keep this shape instead
58
- | id | Asumsi | Akibat kalau salah | Diambil | Oleh |
59
- |---|---|---|---|---|
59
+ | id | Assumption | Cost if wrong | Taken | By |
60
+ |---|---|---|---|---|---|
60
61
  -->
61
62
 
62
63
  <!-- An empty list is a legitimate state and MUST be written as one, with the date and one line
63
64
  saying why. An empty table with no sentence reads as an unfinished file. -->
64
65
 
65
- ## Terjawab
66
+ ## Answered
66
67
 
67
68
  <!-- list: answered only.
68
69
 
69
- | id | Pertanyaan | Jawaban | Tanggal | Oleh |
70
+ | id | Question | Answer | Date | By |
70
71
 
71
72
  The answer is written beside the question, not in place of it. The record of what was once
72
73
  uncertain is what stops the same question being asked again in three months.
@@ -66,7 +66,7 @@ reviewed: # V13. Diisi hanya setelah bmad-review benar-benar dija
66
66
 
67
67
  | id | Use case | Actor | Satisfies | critical |
68
68
  | --- | --- | --- | --- | --- |
69
- | UC-{n} | {a sentence a user would say} | {from the Actor Register} | {FR-n} | tidak |
69
+ | UC-{n} | {a sentence a user would say} | {from the Actor Register} | {FR-n} | no |
70
70
 
71
71
  ## Constraints · [G3]
72
72
 
@@ -106,7 +106,7 @@ reviewed: # V13. Diisi hanya setelah bmad-review benar-benar dija
106
106
 
107
107
  ## Gate Checklist · [G3]
108
108
 
109
- <!-- The gate questions as they apply to THIS component, answered ya / tidak / ubah. The full list
109
+ <!-- The gate questions as they apply to THIS component, answered yes / no / change. The full list
110
110
  lives in delivery-flow-guide.md and MUST NOT be copied here. At mode: catalog only the starred
111
111
  questions are asked. -->
112
112
 
@@ -3,32 +3,33 @@
3
3
  # requires-python = ">=3.11"
4
4
  # dependencies = ["pyyaml>=6"]
5
5
  # ///
6
- """inventory — menurunkan tiga inventaris dari kode, lalu mengadunya dengan rencananya.
6
+ """inventory — derives the three inventories from code, then compares them against the plan.
7
7
 
8
- Tiga inventaristabel, endpoint, layaradalah keluaran G3 Blueprint dan ADA pada setiap `mode`,
9
- termasuk `catalog`. Mereka lahir dua cara, dan `derived_from` di frontmatter menyatakan yang mana:
8
+ The three inventories tables, endpoints, screensare a G3 Blueprint output and EXIST at every
9
+ `mode`, including `catalog`. They are born two ways, and `derived_from` in the frontmatter states
10
+ which one:
10
11
 
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.
12
+ plan no code yet. Written as a PLAN by wdi-blueprint. Nothing can be derived,
13
+ because there is no source yet.
14
+ code the code already exists. Derived FIRST by this script, then compared against the plan.
14
15
 
15
- Selisih rencana-versus-kenyataan adalah TEMUAN, dan ia dilaporkan. Ia MUST NOT ditambal dengan
16
- menyunting sisi yang lainitu mengubah pekerjaan yang bisa terlupa jadi pekerjaan yang pasti
17
- terlupa. Script ini karena itu punya dua mode dan yang pertama adalah defaultnya:
16
+ A plan-versus-reality gap is a FINDING, and it is reported. It MUST NOT be patched over by
17
+ editing the other sidethat turns work that could be forgotten into work that definitely
18
+ will be. This script therefore has two modes, and the first is the default:
18
19
 
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
20
+ inventory --check derive, compare, report. Writes NOT ONE file
21
+ inventory --write rewrite the ## Rows section from what was derived, then report the gap
21
22
 
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.
23
+ Determinism is the contract, same as validate.py: two runs over the same code MUST produce the
24
+ same result. That is why every iteration is ordered and none depends on the wall clock.
24
25
 
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.
26
+ THE STATED-UP-FRONT LIMIT. This is a pattern reader, not a compiler. It reads:
27
+ table CREATE TABLE statements in src/internal/platform/migrate/migrations/*.sql
28
+ endpoint route registrations on the Gin router in src/**/*.go
29
+ screen route components in the React SPA in web/*/src/**/*.tsx
30
+ Whatever the pattern cannot read is reported as unreadNOT guessed, and NOT silently
31
+ dropped. An inventory MUST NOT be assembled from a README or from a route name that merely
32
+ looks plausible.
32
33
  """
33
34
 
34
35
  from __future__ import annotations
@@ -41,15 +42,15 @@ from pathlib import Path
41
42
 
42
43
  KINDS = ("db", "api", "screen")
43
44
 
44
- # ------------------------------------------------------------------ pola baca
45
+ # ------------------------------------------------------------------ read patterns
45
46
 
46
- # CREATE TABLE [IF NOT EXISTS] `nama` | nama
47
+ # CREATE TABLE [IF NOT EXISTS] `name` | name
47
48
  RE_TABLE = re.compile(
48
49
  r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?",
49
50
  re.I)
50
51
  RE_DROP_TABLE = re.compile(
51
52
  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
+ # PRIMARY KEY / UNIQUE / FOREIGN KEY — key columns, not every column
53
54
  RE_KEYCOL = re.compile(
54
55
  r"(?:PRIMARY\s+KEY|UNIQUE(?:\s+KEY)?|FOREIGN\s+KEY)[^(\n]*\(([^)]*)\)", re.I)
55
56
 
@@ -58,11 +59,11 @@ RE_ROUTE = re.compile(
58
59
  r"\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
59
60
  RE_GROUP = re.compile(r"\.\s*Group\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
60
61
 
61
- # Sebuah route group DIBUAT di satu berkas dan DIPAKAI di berkas lain:
62
+ # A route group is CREATED in one file and USED in another:
62
63
  # portal.go:26 mountMemberAPI(engine.Group("/api"), ...)
63
64
  # 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.
65
+ # Because of that, a prefix CANNOT be inferred per file. It must follow its mount, and the host
66
+ # comes along from the mount point in app.go. The patterns below are what make that trace possible.
66
67
  RE_FUNC_DEF = re.compile(r"^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", re.M)
67
68
  # mux.Handle(cfg.HostPublic, routing.NewPublicWithStore(...)) -> host `public`, entry NewPublicWithStore
68
69
  RE_HOST_MOUNT = re.compile(
@@ -89,9 +90,9 @@ RE_ROUTE_TSX = re.compile(
89
90
 
90
91
  @dataclass
91
92
  class Row:
92
- key: str # identitas stabil baris, dipakai membandingkan
93
+ key: str # the row's stable identity, used for comparison
93
94
  cells: list[str]
94
- source: str # berkas tempat ia terbaca
95
+ source: str # the file where it was read
95
96
 
96
97
 
97
98
  @dataclass
@@ -111,11 +112,11 @@ RE_DOWN = re.compile(r"^\s*--\s*\+(?:goose|migrate)\s+Down\b", re.I | re.M)
111
112
 
112
113
 
113
114
  def up_section(text: str) -> str:
114
- """Hanya bagian Up sebuah migrasi.
115
+ """Only a migration's Up section.
115
116
 
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.
117
+ The Down section holds a DROP TABLE for every table its Up creates, so reading the whole
118
+ file makes every table read as dropped. This split MUST happen BEFORE comments are
119
+ stripped, because the goose marker itself is a comment.
119
120
  """
120
121
  match = RE_DOWN.search(text)
121
122
  return text[:match.start()] if match else text
@@ -126,15 +127,15 @@ def strip_sql_comments(text: str) -> str:
126
127
  return "\n".join(re.sub(r"(--|#).*$", "", line) for line in text.splitlines())
127
128
 
128
129
 
129
- # --------------------------------------------------------------------- tabel
130
+ # --------------------------------------------------------------------- table
130
131
 
131
132
 
132
133
  def table_owner(root: Path) -> dict[str, str]:
133
- """Pemilik tiap tabel, dibaca dari `owns` dan `platform_owns` di components.yaml.
134
+ """Every table's owner, read from `owns` and `platform_owns` in components.yaml.
134
135
 
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.
136
+ The owner column CAN be derived as soon as an `owns` value is set, so writing it as
137
+ [NEEDS CONFIRMATION] would flag as unknown something the registry has already stated.
138
+ Whatever no one claims stays [NEEDS CONFIRMATION] — that is a finding, not a gap in the rule.
138
139
  """
139
140
  import yaml as _yaml
140
141
  path = root / ".control/registry/components.yaml"
@@ -155,10 +156,10 @@ def derive_db(root: Path) -> Derived:
155
156
  owner = table_owner(root)
156
157
  folder = root / "src/internal/platform/migrate/migrations"
157
158
  if not folder.is_dir():
158
- out.unread.append(f"{folder.as_posix()} tidak adatidak ada migrasi yang bisa dibaca")
159
+ out.unread.append(f"{folder.as_posix()} does not exist no migration can be read")
159
160
  return out
160
161
 
161
- created: dict[str, tuple[str, str]] = {} # tabel -> (kolom kunci, berkas)
162
+ created: dict[str, tuple[str, str]] = {} # table -> (key columns, file)
162
163
  dropped: set[str] = set()
163
164
  for path in sorted(folder.glob("*.sql")):
164
165
  body = strip_sql_comments(up_section(read(path)))
@@ -184,10 +185,10 @@ def derive_db(root: Path) -> Derived:
184
185
  f"`{who}`" if who else "[NEEDS CONFIRMATION]",
185
186
  "[NEEDS CONFIRMATION]", keys, "published"]))
186
187
  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")
188
+ out.unread.append(f"table `{name}` is claimed by neither `owns` nor `platform_owns` — "
189
+ f"V21 does not see it, and no one is authorized to write it")
189
190
  if dropped:
190
- out.unread.append("tabel yang di-DROP di bagian Up dan karena itu tidak didaftarkan: "
191
+ out.unread.append("tables dropped in the Up section and therefore not registered: "
191
192
  + ", ".join(sorted(dropped)))
192
193
  return out
193
194
 
@@ -196,7 +197,7 @@ def derive_db(root: Path) -> Derived:
196
197
 
197
198
 
198
199
  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
+ """Every Go function in src/ with its body, plus the list of files read."""
200
201
  bodies: dict[str, str] = {}
201
202
  files: list[str] = []
202
203
  for path in sorted((root / "src").rglob("*.go")):
@@ -215,11 +216,12 @@ def _go_funcs(root: Path) -> tuple[dict[str, str], list[str]]:
215
216
  def _walk(fn: str, host: str, prefix: str, router_vars: dict[str, str],
216
217
  bodies: dict[str, str], out: dict[tuple[str, str, str], str],
217
218
  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
+ """Walk one function: record its routes, then follow the mounts it sets up.
219
220
 
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.
221
+ A function MAY be mounted from more than one host — `mountSharedPublicReads` is called from
222
+ `member_api.go` AND `public_api.go` — so this recursion deliberately does not memoize per
223
+ function, only per (function, host, prefix). Without that, the endpoints on the second host
224
+ disappear without a trace.
223
225
  """
224
226
  if depth > 8 or (fn, host, prefix) in seen_calls:
225
227
  return
@@ -258,7 +260,7 @@ def _walk(fn: str, host: str, prefix: str, router_vars: dict[str, str],
258
260
  def derive_api(root: Path) -> Derived:
259
261
  out = Derived()
260
262
  if not (root / "src").is_dir():
261
- out.unread.append("src/ tidak adatidak ada registrasi route yang bisa dibaca")
263
+ out.unread.append("src/ does not exist no route registration can be read")
262
264
  return out
263
265
 
264
266
  bodies, files = _go_funcs(root)
@@ -268,9 +270,9 @@ def derive_api(root: Path) -> Derived:
268
270
  entries.append((host.lower(), fn))
269
271
  if not entries:
270
272
  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")
273
+ "not one host mount point was read (pattern `Handle(cfg.Host<X>, <Fn>(`) — "
274
+ "every path below MUST be checked by hand, since a group's prefix cannot be "
275
+ "traced without its mount point")
274
276
 
275
277
  found: dict[tuple[str, str, str], str] = {}
276
278
  for host, fn in sorted(set(entries)):
@@ -285,8 +287,8 @@ def derive_api(root: Path) -> Derived:
285
287
  cells=[host, method, f"`{path_str}`", owner,
286
288
  "[NEEDS CONFIRMATION]", "published"]))
287
289
  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
+ out.unread.append("not one route registration was read in src/**/*.go — "
291
+ "if the API genuinely does not exist yet, `derived_from: plan` is correct")
290
292
  return out
291
293
 
292
294
 
@@ -294,13 +296,13 @@ def derive_screen(root: Path) -> Derived:
294
296
  out = Derived()
295
297
  folder = root / "web"
296
298
  if not folder.is_dir():
297
- out.unread.append("web/ tidak adatidak ada halaman yang bisa dibaca")
299
+ out.unread.append("web/ does not exist no page can be read")
298
300
  return out
299
301
 
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.
302
+ # THE KEY IS (spa, route), NOT the route alone. This product has two SPAs built separately,
303
+ # and both declare `/`, `/login`, and `*`. Keying on the route alone silently collapses the
304
+ # duplicates: 26 screens read as 23. A route is not a screen's identity in a product with
305
+ # multiple SPAsthe host is part of that identity.
304
306
  seen: dict[tuple[str, str], tuple[str, str]] = {}
305
307
  for path in sorted(folder.rglob("*.tsx")):
306
308
  if "node_modules" in path.parts or path.name.endswith(".test.tsx"):
@@ -320,7 +322,7 @@ def derive_screen(root: Path) -> Derived:
320
322
 
321
323
  for spa, route in sorted(seen):
322
324
  if route in states:
323
- continue # ia keadaan dari layar lain, bukan baris tersendiri
325
+ continue # it is a state of another screen, not a row of its own
324
326
  component, rel = seen[(spa, route)]
325
327
  extra = folded.get(f"{spa}:{route}") or []
326
328
  state_cell = ", ".join(f"`{r}`" for r in sorted(extra)) if extra else "—"
@@ -331,16 +333,16 @@ def derive_screen(root: Path) -> Derived:
331
333
  orphan = sorted(r for r in states
332
334
  if not any(states[r] == route for _, route in seen))
333
335
  if orphan:
334
- out.unread.append("rute-keadaan yang induknya tidak terbaca di kode: " + ", ".join(orphan))
336
+ out.unread.append("state-routes whose parent was not read in the code: " + ", ".join(orphan))
335
337
  if not seen:
336
- out.unread.append("tidak satu pun <Route path=... element={<X />}> terbaca di web/**/*.tsx")
338
+ out.unread.append("not one <Route path=... element={<X />}> was read in web/**/*.tsx")
337
339
  else:
338
340
  shared = sorted({r for _, r in seen} & {r for s, r in seen if s != sorted({x for x, _ in seen})[0]})
339
341
  dupes = sorted({r for s, r in seen} )
340
342
  collide = sorted({r for r in dupes if sum(1 for s2, r2 in seen if r2 == r) > 1})
341
343
  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
+ out.unread.append("routes declared by MORE THAN ONE SPA, and therefore not a "
345
+ "screen's identity on their own: " + ", ".join(collide))
344
346
  return out
345
347
 
346
348
 
@@ -352,7 +354,7 @@ HEADERS = {
352
354
  }
353
355
 
354
356
 
355
- # ------------------------------------------------------- rencana yang tercatat
357
+ # ------------------------------------------------------- the recorded plan
356
358
 
357
359
 
358
360
  ROW_RE = re.compile(r"^\|\s*(\d+)\s*\|(.*)\|\s*$")
@@ -360,14 +362,14 @@ FM_RE = re.compile(r"\A---\n(.*?)\n---", re.S)
360
362
 
361
363
 
362
364
  def decisions(path: Path) -> tuple[dict[str, str], set[str]]:
363
- """(`states`, `platform_rows`) dari frontmatter — keputusan pemilik, bukan hasil pola.
365
+ """(`states`, `platform_rows`) from frontmatter — the owner's decision, not a pattern result.
364
366
 
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.
367
+ `states` maps a state-route to its parent screen's route. A state is NOT a screen: ux-guide
368
+ already demands every screen have an empty and an error state, so a state is a column on its
369
+ parent's row, not a second row.
368
370
 
369
- `platform_rows` menyebut baris yang dimiliki `_platform`. Tidak ada janji Product Component di
370
- belakangnya, dan corpus-guide memiliki tes dua-bagiannya.
371
+ `platform_rows` names rows owned by `_platform`. There is no Product Component promise
372
+ behind them, and corpus-guide owns their two-part test.
371
373
  """
372
374
  if not path.exists():
373
375
  return {}, set()
@@ -385,7 +387,7 @@ def decisions(path: Path) -> tuple[dict[str, str], set[str]]:
385
387
 
386
388
 
387
389
  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."""
390
+ """Read the ## Rows section from the recorded inventory. None if the file does not exist yet."""
389
391
  if not path.exists():
390
392
  return {}, None
391
393
  text = read(path)
@@ -408,17 +410,17 @@ def plan_rows(path: Path) -> tuple[dict[int, list[str]], str | None]:
408
410
 
409
411
 
410
412
  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."""
413
+ """Stable identity of a plan row, built the same way as the derived Row.key."""
412
414
  out: dict[str, int] = {}
413
415
  for number, cells in sorted(rows.items()):
414
416
  if kind == "db" and cells:
415
417
  out[cells[0].strip("`")] = number
416
418
  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
+ # The host is part of the identity: one mount function MAY be mounted on more than
420
+ # one host, and without the host in the key the two endpoints collapse into one row.
419
421
  out[f"{cells[0]} {cells[1].upper()} {cells[2].strip('`')}"] = number
420
422
  elif kind == "screen" and len(cells) >= 2:
421
- # Screen ditulis `<spa>/<Component>`; spa-nya bagian identitas, sama seperti di derivasi.
423
+ # A screen is written `<spa>/<Component>`; the spa is part of the identity, same as in derivation.
422
424
  screen = cells[0].strip("`")
423
425
  spa = screen.split("/", 1)[0] if "/" in screen else "?"
424
426
  out[f"{spa}:{cells[1].strip('`')}"] = number
@@ -426,10 +428,10 @@ def plan_keys(kind: str, rows: dict[int, list[str]]) -> dict[str, int]:
426
428
 
427
429
 
428
430
  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.
431
+ """STABLE numbering: a row that already has a number keeps it; a new one takes the next.
430
432
 
431
- Menomori ulang berarti mengganti nama setiap berkas sesudahnya dan mematahkan setiap tautan yang
432
- menunjuk ke arahnya, jadi ia MUST NOT dilakukanjuga ketika baris di tengah menghilang.
433
+ Renumbering means renaming every reference to it afterward and breaking every link that
434
+ points to it, so it MUST NOT be done even when a row in the middle disappears.
433
435
  """
434
436
  next_no = max(keys.values(), default=0) + 1
435
437
  lines = ["| " + " | ".join(HEADERS[kind]) + " |",
@@ -446,8 +448,8 @@ def render_rows(kind: str, derived: Derived, keys: dict[str, int]) -> str:
446
448
  def write_rows(path: Path, block: str) -> None:
447
449
  text = read(path)
448
450
  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
+ raise SystemExit(f"inventory: {path.as_posix()} has no `## Rows` section — "
452
+ f"give it birth first from templates/inventory.md")
451
453
  head, _, rest = text.partition("## Rows")
452
454
  tail = ""
453
455
  for marker in ("\n## ",):
@@ -464,19 +466,19 @@ def write_rows(path: Path, block: str) -> None:
464
466
  def main(argv: list[str] | None = None) -> int:
465
467
  parser = argparse.ArgumentParser(
466
468
  prog="inventory",
467
- description="Turunkan tiga inventaris dari kode, lalu adu dengan rencananya")
469
+ description="Derive the three inventories from code, then compare against the plan")
468
470
  parser.add_argument("--check", action="store_true",
469
- help="turunkan dan laporkan; tidak menulis apa pun (default)")
471
+ help="derive and report; write nothing (default)")
470
472
  parser.add_argument("--write", action="store_true",
471
- help="tulis ulang bagian ## Rows dari hasil derivasi")
473
+ help="rewrite the ## Rows section from the derived result")
472
474
  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
+ help="restrict to one kind; may be repeated")
476
+ parser.add_argument("--root", default=".", help="repo root (default: current directory)")
475
477
  args = parser.parse_args(argv)
476
478
 
477
479
  root = Path(args.root).resolve()
478
480
  if not (root / ".control" / "registry").is_dir():
479
- print(f"inventory: {root} tidak punya .control/registry/ — salah akar repo?", file=sys.stderr)
481
+ print(f"inventory: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
480
482
  return 2
481
483
 
482
484
  kinds = args.kind or list(KINDS)
@@ -491,35 +493,35 @@ def main(argv: list[str] | None = None) -> int:
491
493
 
492
494
  print(f"\n=== {kind} — {rel}")
493
495
  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
+ print(" the file does not exist yet. Give it birth from templates/inventory.md; "
497
+ "until that happens there is no plan to compare against")
496
498
  else:
497
- print(f" derived_from: {mode} · {len(recorded)} baris tercatat")
498
- print(f" {len(derived.rows)} baris terbaca dari kode")
499
+ print(f" derived_from: {mode} · {len(recorded)} rows recorded")
500
+ print(f" {len(derived.rows)} rows read from code")
499
501
 
500
502
  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
+ missing = sorted(set(keys) - derived_keys) # planned, not present in code
504
+ extra = sorted(derived_keys - set(keys)) # present in code, not planned
503
505
 
504
506
  for item in missing:
505
- print(f" TEMUAN direncanakan tetapi tidak terbaca di kode: {item}")
507
+ print(f" FINDING planned but not read in code: {item}")
506
508
  for item in extra:
507
- print(f" TEMUAN ada di kode tetapi tidak tercatat di rencana: {item}")
509
+ print(f" FINDING present in code but not recorded in the plan: {item}")
508
510
  for note in derived.unread:
509
- print(f" TIDAK TERBACA {note}")
511
+ print(f" UNREAD {note}")
510
512
  findings += len(missing) + len(extra)
511
513
 
512
514
  if args.write:
513
515
  if not path.exists():
514
- print(" --write dilewati: berkasnya belum ada")
516
+ print(" --write skipped: the file does not exist yet")
515
517
  continue
516
518
  write_rows(path, render_rows(kind, derived, keys))
517
- print(f" tulis {rel} — bagian ## Rows saja")
519
+ print(f" wrote {rel} — the ## Rows section only")
518
520
 
519
- print(f"\n{findings} selisih rencana-versus-kode.")
521
+ print(f"\n{findings} plan-versus-code gaps.")
520
522
  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
+ print("This gap is a FINDING, not hand work. It is routed to the skill that owns "
524
+ "its side, and MUST NOT be patched over by editing the other side.")
523
525
  return 1 if findings else 0
524
526
 
525
527