wdi-method 0.5.4 → 0.5.9

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.
@@ -1,69 +1,69 @@
1
- ---
2
- type: uc
3
- id: UC-{NN} # allocated from .control/registry/usecases.yaml
4
- component: '{pc}'
5
- satisfies: [] # FR ids — V2 checks every FR has at least one UC
6
- critical: false # true ONLY when it touches money, personal data, or an irreversible
7
- # action. Nothing else — the two elastic criteria are repealed
8
- created: '{YYYY-MM-DD}'
9
- ---
10
-
11
- # UC-{NN} — {a sentence the user would say}
12
-
13
- <!-- TEMPLATE GUIDE — act on these comments, then delete them.
14
-
15
- THE TITLE RULE, and G3 asks it as a starred question: the title MUST be a sentence a user would
16
- actually say — "Mendaftar lewat tautan sponsor" — and MUST NOT be a system term — "Referral
17
- attribution handler". A system-shaped title means the functional analysis has already leaked
18
- into design.
19
-
20
- WHICH USE CASES GET ONE OF THESE FILES IS DECIDED BY `mode`, NOT BY `critical`:
21
- catalog none. The catalogue line in the SRS is the whole record
22
- outline · guarded the use cases the component exists for, AT MOST 3
23
- deep every UC marked `critical`
24
- `critical` therefore decides something only at `deep`. Everywhere else it is a label the estimate
25
- and the review read, and it MUST NOT be used to justify writing a file the mode does not ask for.
26
-
27
- At most EIGHT steps in the Basic Flow. A flow needing more is either two use cases, or it has
28
- started describing implementation. -->
29
-
30
- ## Trigger
31
-
32
- <!-- What starts this. An actor doing something, a schedule, or an external event. -->
33
-
34
- ## Precondition
35
-
36
- <!-- What MUST already be true. Authentication state, prior data, prior status. -->
37
-
38
- ## Main Flow
39
-
40
- <!-- Numbered, one step per line, alternating actor and system. Keep to the happy path — branches
41
- go below or, if long, into a SCN- file in 05-scenarios/. No screen names, no endpoints, no
42
- table names: this is behaviour, not design. -->
43
-
44
- 1.
45
- 2.
46
-
47
- ## Alternate Flows
48
-
49
- <!-- Branches that still end in success. Reference the main-flow step they leave from. -->
50
-
51
- | From step | Condition | What happens |
52
- | --- | --- | --- |
53
-
54
- ## Failure Flows
55
-
56
- <!-- Branches that end without the goal. For a `critical` UC this section MUST answer "what happens
57
- if it fails halfway" — the paired SDD carries the technical side of the same question. -->
58
-
59
- | From step | Failure | What the system does | What the user is left with |
60
- | --- | --- | --- | --- |
61
-
62
- ## Outcome
63
-
64
- <!-- The state the system and the user are left in when the main flow completes. -->
65
-
66
- ## Business Rules
67
-
68
- <!-- BR ids that govern this use case, referenced not restated. The rules themselves live in
69
- 02-rules/. -->
1
+ ---
2
+ type: uc
3
+ id: UC-{NN} # allocated from .control/registry/usecases.yaml
4
+ component: '{pc}'
5
+ satisfies: [] # FR ids — V2 checks every FR has at least one UC
6
+ critical: false # true ONLY when it touches money, personal data, or an irreversible
7
+ # action. Nothing else — the two elastic criteria are repealed
8
+ created: '{YYYY-MM-DD}'
9
+ ---
10
+
11
+ # UC-{NN} — {a sentence the user would say}
12
+
13
+ <!-- TEMPLATE GUIDE — act on these comments, then delete them.
14
+
15
+ THE TITLE RULE, and G3 asks it as a starred question: the title MUST be a sentence a user would
16
+ actually say — "Sign up through an invitation link" — and MUST NOT be a system term —
17
+ "Invitation attribution handler". A system-shaped title means the functional analysis has already leaked
18
+ into design.
19
+
20
+ WHICH USE CASES GET ONE OF THESE FILES IS DECIDED BY `mode`, NOT BY `critical`:
21
+ catalog none. The catalogue line in the SRS is the whole record
22
+ outline · guarded the use cases the component exists for, AT MOST 3
23
+ deep every UC marked `critical`
24
+ `critical` therefore decides something only at `deep`. Everywhere else it is a label the estimate
25
+ and the review read, and it MUST NOT be used to justify writing a file the mode does not ask for.
26
+
27
+ At most EIGHT steps in the Basic Flow. A flow needing more is either two use cases, or it has
28
+ started describing implementation. -->
29
+
30
+ ## Trigger
31
+
32
+ <!-- What starts this. An actor doing something, a schedule, or an external event. -->
33
+
34
+ ## Precondition
35
+
36
+ <!-- What MUST already be true. Authentication state, prior data, prior status. -->
37
+
38
+ ## Main Flow
39
+
40
+ <!-- Numbered, one step per line, alternating actor and system. Keep to the happy path — branches
41
+ go below or, if long, into a SCN- file in 05-scenarios/. No screen names, no endpoints, no
42
+ table names: this is behaviour, not design. -->
43
+
44
+ 1.
45
+ 2.
46
+
47
+ ## Alternate Flows
48
+
49
+ <!-- Branches that still end in success. Reference the main-flow step they leave from. -->
50
+
51
+ | From step | Condition | What happens |
52
+ | --- | --- | --- |
53
+
54
+ ## Failure Flows
55
+
56
+ <!-- Branches that end without the goal. For a `critical` UC this section MUST answer "what happens
57
+ if it fails halfway" — the paired SDD carries the technical side of the same question. -->
58
+
59
+ | From step | Failure | What the system does | What the user is left with |
60
+ | --- | --- | --- | --- |
61
+
62
+ ## Outcome
63
+
64
+ <!-- The state the system and the user are left in when the main flow completes. -->
65
+
66
+ ## Business Rules
67
+
68
+ <!-- BR ids that govern this use case, referenced not restated. The rules themselves live in
69
+ 02-rules/. -->
@@ -23,70 +23,36 @@ will be. This script therefore has two modes, and the first is the default:
23
23
  Determinism is the contract, same as validate.py: two runs over the same code MUST produce the
24
24
  same result. That is why every iteration is ordered and none depends on the wall clock.
25
25
 
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 unread — NOT 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.
26
+ WHERE THE STACK LIVES, AND WHY NOT HERE. Comparing, reporting, rendering, and keeping the numbers
27
+ stable do not depend on a language. READING the code does — a migration folder, a router, a screen
28
+ route are each written differently in every stack. So this file holds none of that. It loads three
29
+ functions from the product:
30
+
31
+ .constitution/project/inventory-readers.py derive_db · derive_api · derive_screen
32
+
33
+ That path is the room: `wdi-method update` never overwrites it and `promote` never publishes it, so
34
+ a product owns how its own code is read while every product shares this engine. What the package
35
+ seeds there is a SKELETON — no patterns, no stack, and it says so. The `wdi-init` skill, intent
36
+ `readers`, writes it against the repo actually in front of it, which is why no example ships: an
37
+ example is a guess about somebody else's stack, and guessing is the thing this script exists to
38
+ refuse. An unwritten reader is reported, never worked around.
39
+
40
+ THE STATED-UP-FRONT LIMIT, which no stack changes: this is a pattern reader, not a compiler.
41
+ Whatever a reader cannot read is reported as unread — NOT guessed, and NOT silently dropped. An
42
+ inventory MUST NOT be assembled from a README or from a route name that merely looks plausible.
33
43
  """
34
44
 
35
45
  from __future__ import annotations
36
46
 
37
47
  import argparse
48
+ import importlib.util
38
49
  import re
39
50
  import sys
40
51
  from dataclasses import dataclass, field
41
52
  from pathlib import Path
42
53
 
43
54
  KINDS = ("db", "api", "screen")
44
-
45
- # ------------------------------------------------------------------ read patterns
46
-
47
- # CREATE TABLE [IF NOT EXISTS] `name` | name
48
- RE_TABLE = re.compile(
49
- r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?",
50
- re.I)
51
- RE_DROP_TABLE = re.compile(
52
- r"DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`\"]?([A-Za-z_][A-Za-z0-9_]*)[`\"]?", re.I)
53
- # PRIMARY KEY / UNIQUE / FOREIGN KEY — key columns, not every column
54
- RE_KEYCOL = re.compile(
55
- r"(?:PRIMARY\s+KEY|UNIQUE(?:\s+KEY)?|FOREIGN\s+KEY)[^(\n]*\(([^)]*)\)", re.I)
56
-
57
- # r.GET("/path", ...) · group.POST(`/path`, ...) · r.Handle("GET", "/path", ...)
58
- RE_ROUTE = re.compile(
59
- r"\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
60
- RE_GROUP = re.compile(r"\.\s*Group\s*\(\s*[`\"]([^`\"]+)[`\"]", re.I)
61
-
62
- # A route group is CREATED in one file and USED in another:
63
- # portal.go:26 mountMemberAPI(engine.Group("/api"), ...)
64
- # member_api.go api.GET("/me", ...)
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.
67
- RE_FUNC_DEF = re.compile(r"^func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", re.M)
68
- # mux.Handle(cfg.HostPublic, routing.NewPublicWithStore(...)) -> host `public`, entry NewPublicWithStore
69
- RE_HOST_MOUNT = re.compile(
70
- r"Handle\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\.Host([A-Za-z0-9_]+)\s*,\s*"
71
- r"(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*\(")
72
- # mountX(engine.Group("/api"), ...) | mountX(api, ...)
73
- RE_CALL_GROUP = re.compile(
74
- r"\b([a-z][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*Group\s*\(\s*[`\"]([^`\"]*)[`\"]\s*\)")
75
- RE_CALL_PLAIN = re.compile(r"\b([a-z][A-Za-z0-9_]*)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*[,)]")
76
- # api := engine.Group("/x") | protected := api.Group("")
77
- RE_ASSIGN_GROUP = re.compile(
78
- r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*Group\s*\(\s*[`\"]([^`\"]*)[`\"]",
79
- re.M)
80
- RE_ASSIGN_ENGINE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:?=\s*gin\.New\s*\(", re.M)
81
- RE_ROUTE_ON = re.compile(
82
- r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*[`\"]([^`\"]+)[`\"]",
83
- re.I)
84
-
85
- # <Route path="/x" element={<Thing />} /> — react-router
86
- RE_ROUTE_TSX = re.compile(
87
- r"<Route\s[^>]*path\s*=\s*[{\"']+([^\"'}]+)[\"'}]+[^>]*?"
88
- r"element\s*=\s*\{\s*<\s*([A-Za-z0-9_]+)", re.S)
89
-
55
+ READERS = Path(".constitution") / "project" / "inventory-readers.py"
90
56
 
91
57
  @dataclass
92
58
  class Row:
@@ -108,245 +74,6 @@ def read(path: Path) -> str:
108
74
  return ""
109
75
 
110
76
 
111
- RE_DOWN = re.compile(r"^\s*--\s*\+(?:goose|migrate)\s+Down\b", re.I | re.M)
112
-
113
-
114
- def up_section(text: str) -> str:
115
- """Only a migration's Up section.
116
-
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.
120
- """
121
- match = RE_DOWN.search(text)
122
- return text[:match.start()] if match else text
123
-
124
-
125
- def strip_sql_comments(text: str) -> str:
126
- text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
127
- return "\n".join(re.sub(r"(--|#).*$", "", line) for line in text.splitlines())
128
-
129
-
130
- # --------------------------------------------------------------------- table
131
-
132
-
133
- def table_owner(root: Path) -> dict[str, str]:
134
- """Every table's owner, read from `owns` and `platform_owns` in components.yaml.
135
-
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.
139
- """
140
- import yaml as _yaml
141
- path = root / ".control/registry/components.yaml"
142
- if not path.exists():
143
- return {}
144
- data = _yaml.safe_load(path.read_text(encoding="utf-8")) or {}
145
- out: dict[str, str] = {}
146
- for pc in (data.get("product_components") or []):
147
- for entity in (pc.get("owns") or []):
148
- out[str(entity)] = str(pc.get("id"))
149
- for entity in (data.get("platform_owns") or []):
150
- out.setdefault(str(entity), "_platform")
151
- return out
152
-
153
-
154
- def derive_db(root: Path) -> Derived:
155
- out = Derived()
156
- owner = table_owner(root)
157
- folder = root / "src/internal/platform/migrate/migrations"
158
- if not folder.is_dir():
159
- out.unread.append(f"{folder.as_posix()} does not exist — no migration can be read")
160
- return out
161
-
162
- created: dict[str, tuple[str, str]] = {} # table -> (key columns, file)
163
- dropped: set[str] = set()
164
- for path in sorted(folder.glob("*.sql")):
165
- body = strip_sql_comments(up_section(read(path)))
166
- rel = path.relative_to(root).as_posix()
167
- for stmt in body.split(";"):
168
- match = RE_TABLE.search(stmt)
169
- if match:
170
- name = match.group(1)
171
- keys = sorted({c.strip().strip("`\"") for group in RE_KEYCOL.findall(stmt)
172
- for c in group.split(",") if c.strip()})
173
- created[name] = (", ".join(f"`{k}`" for k in keys) or "—", rel)
174
- continue
175
- for name in RE_DROP_TABLE.findall(stmt):
176
- dropped.add(name)
177
-
178
- for name in sorted(created):
179
- if name in dropped:
180
- continue
181
- keys, rel = created[name]
182
- who = owner.get(name)
183
- out.rows.append(Row(key=name, source=rel,
184
- cells=[f"`{name}`",
185
- f"`{who}`" if who else "[NEEDS CONFIRMATION]",
186
- "[NEEDS CONFIRMATION]", keys, "published"]))
187
- if not who:
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")
190
- if dropped:
191
- out.unread.append("tables dropped in the Up section and therefore not registered: "
192
- + ", ".join(sorted(dropped)))
193
- return out
194
-
195
-
196
- # ------------------------------------------------------------------ endpoint
197
-
198
-
199
- def _go_funcs(root: Path) -> tuple[dict[str, str], list[str]]:
200
- """Every Go function in src/ with its body, plus the list of files read."""
201
- bodies: dict[str, str] = {}
202
- files: list[str] = []
203
- for path in sorted((root / "src").rglob("*.go")):
204
- if path.name.endswith("_test.go"):
205
- continue
206
- body = read(path)
207
- files.append(body)
208
- marks = [(m.start(), m.group(1), m.group(2)) for m in RE_FUNC_DEF.finditer(body)]
209
- for i, (start, name, params) in enumerate(marks):
210
- end = marks[i + 1][0] if i + 1 < len(marks) else len(body)
211
- bodies[name] = body[start:end]
212
- bodies[name + "\x00params"] = params
213
- return bodies, files
214
-
215
-
216
- def _walk(fn: str, host: str, prefix: str, router_vars: dict[str, str],
217
- bodies: dict[str, str], out: dict[tuple[str, str, str], str],
218
- seen_calls: set[tuple[str, str, str]], depth: int = 0) -> None:
219
- """Walk one function: record its routes, then follow the mounts it sets up.
220
-
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.
225
- """
226
- if depth > 8 or (fn, host, prefix) in seen_calls:
227
- return
228
- seen_calls.add((fn, host, prefix))
229
- body = bodies.get(fn)
230
- if body is None:
231
- return
232
-
233
- local = dict(router_vars)
234
- params = bodies.get(fn + "\x00params", "")
235
- for piece in params.split(","):
236
- piece = piece.strip()
237
- if "gin.RouterGroup" in piece or "gin.Engine" in piece:
238
- local[piece.split()[0]] = prefix
239
- for m in RE_ASSIGN_ENGINE.finditer(body):
240
- local[m.group(1)] = prefix
241
- for m in RE_ASSIGN_GROUP.finditer(body):
242
- base = local.get(m.group(2))
243
- if base is not None:
244
- local[m.group(1)] = (base + m.group(3)).rstrip("/")
245
-
246
- for var, method, raw in RE_ROUTE_ON.findall(body):
247
- if not raw.startswith("/") or var not in local:
248
- continue
249
- full = (local[var] + raw).replace("//", "/")
250
- out.setdefault((host, method.upper(), full), fn)
251
-
252
- for callee, var, grp in RE_CALL_GROUP.findall(body):
253
- if var in local and callee in bodies:
254
- _walk(callee, host, (local[var] + grp).rstrip("/"), {}, bodies, out, seen_calls, depth + 1)
255
- for callee, var in RE_CALL_PLAIN.findall(body):
256
- if var in local and callee in bodies:
257
- _walk(callee, host, local[var], {}, bodies, out, seen_calls, depth + 1)
258
-
259
-
260
- def derive_api(root: Path) -> Derived:
261
- out = Derived()
262
- if not (root / "src").is_dir():
263
- out.unread.append("src/ does not exist — no route registration can be read")
264
- return out
265
-
266
- bodies, files = _go_funcs(root)
267
- entries: list[tuple[str, str]] = []
268
- for body in files:
269
- for host, fn in RE_HOST_MOUNT.findall(body):
270
- entries.append((host.lower(), fn))
271
- if not entries:
272
- out.unread.append(
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")
276
-
277
- found: dict[tuple[str, str, str], str] = {}
278
- for host, fn in sorted(set(entries)):
279
- _walk(fn, host, "", {}, bodies, found, set())
280
-
281
- _, plat = decisions(root / ".how/_platform/inventory-api.md")
282
- for (host, method, path_str) in sorted(found):
283
- key = f"{host} {method} {path_str}"
284
- owner = "`_platform`" if (key in plat or f"{method} {path_str}" in plat
285
- or path_str in plat) else "[NEEDS CONFIRMATION]"
286
- out.rows.append(Row(key=key, source=found[(host, method, path_str)],
287
- cells=[host, method, f"`{path_str}`", owner,
288
- "[NEEDS CONFIRMATION]", "published"]))
289
- if not found:
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")
292
- return out
293
-
294
-
295
- def derive_screen(root: Path) -> Derived:
296
- out = Derived()
297
- folder = root / "web"
298
- if not folder.is_dir():
299
- out.unread.append("web/ does not exist — no page can be read")
300
- return out
301
-
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 SPAs — the host is part of that identity.
306
- seen: dict[tuple[str, str], tuple[str, str]] = {}
307
- for path in sorted(folder.rglob("*.tsx")):
308
- if "node_modules" in path.parts or path.name.endswith(".test.tsx"):
309
- continue
310
- rel = path.relative_to(root).as_posix()
311
- parts = path.relative_to(folder).parts
312
- spa = parts[0] if parts else "?"
313
- for route, component in RE_ROUTE_TSX.findall(read(path)):
314
- seen.setdefault((spa, route.strip()), (component, rel))
315
-
316
- states, _ = decisions(root / ".how/_platform/inventory-screen.md")
317
- folded: dict[str, list[str]] = {}
318
- for (spa, route) in sorted(seen):
319
- parent = states.get(route)
320
- if parent:
321
- folded.setdefault(f"{spa}:{parent}", []).append(route)
322
-
323
- for spa, route in sorted(seen):
324
- if route in states:
325
- continue # it is a state of another screen, not a row of its own
326
- component, rel = seen[(spa, route)]
327
- extra = folded.get(f"{spa}:{route}") or []
328
- state_cell = ", ".join(f"`{r}`" for r in sorted(extra)) if extra else "—"
329
- out.rows.append(Row(key=f"{spa}:{route}", source=rel,
330
- cells=[f"`{spa}/{component}`", f"`{route}`", state_cell,
331
- "[NEEDS CONFIRMATION]", "[NEEDS CONFIRMATION]"]))
332
-
333
- orphan = sorted(r for r in states
334
- if not any(states[r] == route for _, route in seen))
335
- if orphan:
336
- out.unread.append("state-routes whose parent was not read in the code: " + ", ".join(orphan))
337
- if not seen:
338
- out.unread.append("not one <Route path=... element={<X />}> was read in web/**/*.tsx")
339
- else:
340
- shared = sorted({r for _, r in seen} & {r for s, r in seen if s != sorted({x for x, _ in seen})[0]})
341
- dupes = sorted({r for s, r in seen} )
342
- collide = sorted({r for r in dupes if sum(1 for s2, r2 in seen if r2 == r) > 1})
343
- if 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))
346
- return out
347
-
348
-
349
- DERIVERS = {"db": derive_db, "api": derive_api, "screen": derive_screen}
350
77
  HEADERS = {
351
78
  "db": ("No", "Table", "Owning component", "What it holds", "Key columns", "Status"),
352
79
  "api": ("No", "Host", "Method", "Path", "Owning component", "Description", "Status"),
@@ -460,6 +187,70 @@ def write_rows(path: Path, block: str) -> None:
460
187
  path.write_text(head + "## Rows\n\n" + block + "\n" + tail, encoding="utf-8")
461
188
 
462
189
 
190
+ # ------------------------------------------------------------- the product's readers
191
+
192
+
193
+ def load_readers(root: Path):
194
+ """Load the product's three readers, or return None if it has not written them.
195
+
196
+ Three names are injected BEFORE the module executes, so a reader neither imports them nor
197
+ redeclares them: one definition, and it is this file's. That also means a reader written against
198
+ an older shape fails loudly at import rather than silently producing rows the engine then
199
+ mis-renders.
200
+
201
+ Row · Derived the shape every reader returns
202
+ decisions an inventory's own `platform_rows:` and `states:`. Those are a judgement
203
+ the pattern cannot derive, declared in the artifact they govern, so every
204
+ reader needs them and none should re-implement reading them
205
+
206
+ Nothing else is offered. A reader needing more of the engine is a sign the seam is in the wrong
207
+ place, and moving the seam is the fix — not reaching through it.
208
+ """
209
+ file = root / READERS
210
+ if not file.exists():
211
+ return None
212
+ spec = importlib.util.spec_from_file_location("wdi_inventory_readers", file)
213
+ if spec is None or spec.loader is None:
214
+ raise SystemExit(f"inventory: {READERS.as_posix()} could not be loaded as Python")
215
+ mod = importlib.util.module_from_spec(spec)
216
+ mod.Row, mod.Derived, mod.decisions = Row, Derived, decisions
217
+ spec.loader.exec_module(mod)
218
+ missing = [f"derive_{k}" for k in KINDS if not callable(getattr(mod, f"derive_{k}", None))]
219
+ if missing:
220
+ raise SystemExit(f"inventory: {READERS.as_posix()} defines no {', '.join(missing)} — "
221
+ f"the engine expects one function per kind, each taking the repo root")
222
+ return mod
223
+
224
+
225
+ def is_skeleton(mod) -> bool:
226
+ """The seeded skeleton announces itself, because silence would be a lie.
227
+
228
+ A skeleton returns no rows, and so does a product that genuinely stores no tables. In the
229
+ output those are the same thing — "0 rows read from code" — and only one of them is true. The
230
+ flag is what keeps an unwritten reader from reading as a finished one.
231
+ """
232
+ return bool(getattr(mod, "SKELETON", False))
233
+
234
+
235
+ def shaped(kind: str, result) -> Derived:
236
+ """Refuse a reader's answer that is not the shape the engine renders.
237
+
238
+ A reader is the product's code, so it is the one part of this run the method did not write.
239
+ Checking its shape here turns a wrong return into one clear sentence, rather than a
240
+ TypeError thrown from inside render_rows with no clue whose fault it was.
241
+ """
242
+ rows = getattr(result, "rows", None)
243
+ unread = getattr(result, "unread", None)
244
+ if not isinstance(rows, list) or not isinstance(unread, list):
245
+ raise SystemExit(f"inventory: derive_{kind} returned {type(result).__name__}, "
246
+ f"which carries no `rows` and `unread` lists")
247
+ for row in rows:
248
+ if not all(hasattr(row, attr) for attr in ("key", "cells", "source")):
249
+ raise SystemExit(f"inventory: derive_{kind} returned a row without key, cells, "
250
+ f"and source — see the contract in {READERS.as_posix()}")
251
+ return result
252
+
253
+
463
254
  # ----------------------------------------------------------------------- CLI
464
255
 
465
256
 
@@ -481,13 +272,28 @@ def main(argv: list[str] | None = None) -> int:
481
272
  print(f"inventory: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
482
273
  return 2
483
274
 
275
+ readers = load_readers(root)
276
+ # NOT an error, and NOT a reason to guess. A product that has not written its readers has said
277
+ # nothing about how its code is read, and inventing an answer here is the one failure this
278
+ # script exists to prevent. Both states get the same refusal and the same next step.
279
+ if readers is None or is_skeleton(readers):
280
+ state = ("has no" if readers is None else "still has the seeded skeleton at")
281
+ print(f"inventory: this product {state} {READERS.as_posix()}, so nothing can be derived "
282
+ f"from code.\n"
283
+ f" The engine is generic; reading a stack is not. Write the three readers for this "
284
+ f"repo with\n"
285
+ f" the `wdi-init` skill, intent `readers` — it reads the code in front of it rather "
286
+ f"than\n"
287
+ f" starting from somebody else's stack.", file=sys.stderr)
288
+ return 2
289
+
484
290
  kinds = args.kind or list(KINDS)
485
291
  findings = 0
486
292
 
487
293
  for kind in kinds:
488
294
  path = root / f".how/_platform/inventory-{kind}.md"
489
295
  rel = path.relative_to(root).as_posix()
490
- derived = DERIVERS[kind](root)
296
+ derived = shaped(kind, getattr(readers, f"derive_{kind}")(root))
491
297
  recorded, mode = plan_rows(path)
492
298
  keys = plan_keys(kind, recorded)
493
299