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.
- package/README.md +252 -222
- package/bin/wdi-method.js +1030 -1029
- package/kit/.constitution/document/delivery-flow-guide.md +1 -1
- package/kit/.constitution/document/templates/cross-cutting.md +4 -4
- package/kit/.constitution/document/templates/model.md +2 -2
- package/kit/.constitution/document/templates/questions.md +10 -9
- package/kit/.constitution/document/templates/srs.md +2 -2
- package/kit/.constitution/scripts/inventory.py +102 -100
- package/kit/.constitution/scripts/timeline.py +665 -665
- package/kit/.constitution/scripts/validate.py +314 -312
- package/kit/assets/bmad-custom/bmad-advanced-elicitation.toml +15 -15
- package/kit/assets/bmad-custom/bmad-architecture.toml +17 -15
- package/kit/assets/bmad-custom/bmad-build-auto.toml +5 -5
- package/kit/assets/bmad-custom/bmad-build.toml +52 -52
- package/kit/assets/bmad-custom/bmad-code-review.toml +6 -5
- package/kit/assets/bmad-custom/bmad-correct-course.toml +28 -27
- package/kit/assets/bmad-custom/bmad-deep-recon.toml +12 -11
- package/kit/assets/bmad-custom/bmad-prd.toml +22 -22
- package/kit/assets/bmad-custom/bmad-product-brief.toml +34 -34
- package/kit/assets/bmad-custom/bmad-retrospective.toml +9 -9
- package/kit/assets/bmad-custom/bmad-spec.toml +9 -8
- package/kit/assets/bmad-custom/bmad-ux.toml +7 -7
- package/kit/assets/bmad-custom/config.toml +3 -3
- package/kit/skills/wdi-report/SKILL.md +5 -5
- package/package.json +2 -2
- package/scaffold/.control/product-glossary.md +21 -21
- package/scaffold/.control/project-non-technical-log.md +23 -23
- package/scaffold/.control/questions/answered.md +11 -11
- package/scaffold/.control/questions/assumptions.md +15 -15
- package/scaffold/.control/questions/blocking.md +21 -21
- package/scaffold/.control/questions/external.md +11 -11
- package/scaffold/.control/registry/components.yaml +21 -21
- package/scaffold/.control/registry/defects.yaml +3 -3
- package/scaffold/.control/registry/index.yaml +46 -46
- package/scaffold/.control/registry/requirements.yaml +15 -15
- package/scaffold/.control/registry/risks.yaml +5 -5
- 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 **
|
|
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
|
-
`
|
|
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
|
-
|
|
|
62
|
+
| What | Kind | Why no component explains it | Who touches it | The shape every toucher obeys |
|
|
63
63
|
| --- | --- | --- | --- | --- |
|
|
64
64
|
|
|
65
|
-
<!-- `
|
|
66
|
-
|
|
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: "
|
|
36
|
-
|
|
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
|
-
# {
|
|
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
|
|
34
|
-
|
|
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
|
-
##
|
|
51
|
+
## Open
|
|
51
52
|
|
|
52
53
|
<!-- list: blocking · external -->
|
|
53
54
|
|
|
54
|
-
| id |
|
|
55
|
+
| id | Question | Blocks | Owner | Before |
|
|
55
56
|
|---|---|---|---|---|
|
|
56
57
|
|
|
57
58
|
<!-- list: assumptions — keep this shape instead
|
|
58
|
-
| id |
|
|
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
|
-
##
|
|
66
|
+
## Answered
|
|
66
67
|
|
|
67
68
|
<!-- list: answered only.
|
|
68
69
|
|
|
69
|
-
| id |
|
|
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} |
|
|
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
|
|
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 —
|
|
6
|
+
"""inventory — derives the three inventories from code, then compares them against the plan.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
The three inventories — tables, endpoints, screens — are 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
|
|
12
|
-
|
|
13
|
-
code
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
A plan-versus-reality gap is a FINDING, and it is reported. It MUST NOT be patched over by
|
|
17
|
+
editing the other side — that 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
|
|
20
|
-
inventory --write
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
endpoint
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
-
# ------------------------------------------------------------------
|
|
45
|
+
# ------------------------------------------------------------------ read patterns
|
|
45
46
|
|
|
46
|
-
# CREATE TABLE [IF NOT EXISTS] `
|
|
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 —
|
|
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
|
-
#
|
|
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
|
-
#
|
|
65
|
-
#
|
|
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 #
|
|
93
|
+
key: str # the row's stable identity, used for comparison
|
|
93
94
|
cells: list[str]
|
|
94
|
-
source: str #
|
|
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
|
-
"""
|
|
115
|
+
"""Only a migration's Up section.
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
# ---------------------------------------------------------------------
|
|
130
|
+
# --------------------------------------------------------------------- table
|
|
130
131
|
|
|
131
132
|
|
|
132
133
|
def table_owner(root: Path) -> dict[str, str]:
|
|
133
|
-
"""
|
|
134
|
+
"""Every table's owner, read from `owns` and `platform_owns` in components.yaml.
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
[NEEDS CONFIRMATION]
|
|
137
|
-
|
|
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()}
|
|
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]] = {} #
|
|
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"
|
|
188
|
-
f"V21
|
|
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("
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
219
|
+
"""Walk one function: record its routes, then follow the mounts it sets up.
|
|
219
220
|
|
|
220
|
-
|
|
221
|
-
`member_api.go`
|
|
222
|
-
|
|
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/
|
|
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
|
-
"
|
|
272
|
-
"
|
|
273
|
-
"
|
|
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("
|
|
289
|
-
"
|
|
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/
|
|
299
|
+
out.unread.append("web/ does not exist — no page can be read")
|
|
298
300
|
return out
|
|
299
301
|
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
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.
|
|
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 #
|
|
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("
|
|
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("
|
|
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("
|
|
343
|
-
"
|
|
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
|
-
# -------------------------------------------------------
|
|
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`)
|
|
365
|
+
"""(`states`, `platform_rows`) from frontmatter — the owner's decision, not a pattern result.
|
|
364
366
|
|
|
365
|
-
`states`
|
|
366
|
-
|
|
367
|
-
|
|
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`
|
|
370
|
-
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
-
#
|
|
418
|
-
#
|
|
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
|
-
#
|
|
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
|
-
"""
|
|
431
|
+
"""STABLE numbering: a row that already has a number keeps it; a new one takes the next.
|
|
430
432
|
|
|
431
|
-
|
|
432
|
-
|
|
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()}
|
|
450
|
-
f"
|
|
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="
|
|
469
|
+
description="Derive the three inventories from code, then compare against the plan")
|
|
468
470
|
parser.add_argument("--check", action="store_true",
|
|
469
|
-
help="
|
|
471
|
+
help="derive and report; write nothing (default)")
|
|
470
472
|
parser.add_argument("--write", action="store_true",
|
|
471
|
-
help="
|
|
473
|
+
help="rewrite the ## Rows section from the derived result")
|
|
472
474
|
parser.add_argument("--kind", choices=KINDS, action="append",
|
|
473
|
-
help="
|
|
474
|
-
parser.add_argument("--root", default=".", help="
|
|
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}
|
|
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("
|
|
495
|
-
"
|
|
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)}
|
|
498
|
-
print(f" {len(derived.rows)}
|
|
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) #
|
|
502
|
-
extra = sorted(derived_keys - set(keys)) #
|
|
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"
|
|
507
|
+
print(f" FINDING planned but not read in code: {item}")
|
|
506
508
|
for item in extra:
|
|
507
|
-
print(f"
|
|
509
|
+
print(f" FINDING present in code but not recorded in the plan: {item}")
|
|
508
510
|
for note in derived.unread:
|
|
509
|
-
print(f"
|
|
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
|
|
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"
|
|
519
|
+
print(f" wrote {rel} — the ## Rows section only")
|
|
518
520
|
|
|
519
|
-
print(f"\n{findings}
|
|
521
|
+
print(f"\n{findings} plan-versus-code gaps.")
|
|
520
522
|
if findings:
|
|
521
|
-
print("
|
|
522
|
-
"
|
|
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
|
|