wdi-method 0.5.3 → 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.
- package/bin/wdi-method.js +17 -0
- package/kit/.constitution/method/README.md +5 -3
- package/kit/.constitution/method/document/architecture-guide.md +209 -209
- package/kit/.constitution/method/document/corpus-guide.md +2 -2
- package/kit/.constitution/method/document/sdd-guide.md +3 -2
- package/kit/.constitution/method/document/templates/architecture-spine.md +100 -100
- package/kit/.constitution/method/document/templates/inventory.md +2 -2
- package/kit/.constitution/method/document/templates/sdd.md +1 -1
- package/kit/.constitution/method/document/templates/srs.md +149 -149
- package/kit/.constitution/method/document/templates/uc.md +69 -69
- package/kit/.constitution/method/scripts/inventory.py +99 -293
- package/kit/.constitution/method/scripts/validate.py +1676 -1648
- package/kit/.constitution/method/why/portability.md +8 -1
- package/kit/.constitution/project/README.md +8 -0
- package/kit/.constitution/project/inventory-readers.py +85 -0
- package/kit/skills/wdi-blueprint/SKILL.md +203 -203
- package/kit/skills/wdi-build/SKILL.md +4 -2
- package/kit/skills/wdi-init/SKILL.md +38 -4
- package/kit/skills/wdi-log/SKILL.md +107 -107
- package/kit/skills/wdi-report/SKILL.md +6 -6
- package/kit-overlay/README.md +5 -3
- package/kit-overlay/portability.md +8 -1
- package/package.json +1 -1
- package/scaffold/.control/registry/decisions.yaml +3 -3
|
@@ -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 — "
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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
|
|