aw-spec-cli 0.1.20 → 0.1.22
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/assets/.appweaver/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
- package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
- package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
- package/assets/.appweaver/stack-profiles/angular.json +26 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +27 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/.appweaver/templates/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +114 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-plan-coverage — which of a lane plan's elements the generated code realises.
|
|
4
|
+
|
|
5
|
+
Backs the `plan-coverage` subcommand of aw-spec-actions, called identically by the bash
|
|
6
|
+
and the powershell twin, so the two cannot drift.
|
|
7
|
+
|
|
8
|
+
WHY IT EXISTS. `implement` hands a whole plan to one generation pass and gets back the list
|
|
9
|
+
of files it wrote. A pass that stops part-way — five use-cases of seven — returns a
|
|
10
|
+
non-empty list, and a non-empty list is all the workflow used to check. The missing
|
|
11
|
+
elements have no failing test either, because nothing was ever written to fail. This
|
|
12
|
+
reads the plan's element list deterministically and answers, per element, whether the
|
|
13
|
+
lane's code names it and whether the generating model reported it done.
|
|
14
|
+
|
|
15
|
+
TWO KINDS OF ELEMENT.
|
|
16
|
+
checked an identifier the code must contain — an entity, value object, domain
|
|
17
|
+
event or use-case (backend); a component (frontend); an identifier in a
|
|
18
|
+
delta plan's ADDED table. Searched for in the lane's source path, test
|
|
19
|
+
files excluded. Entities and value objects must appear as a whole word.
|
|
20
|
+
Every other checked name may carry a suffix, because stacks add one by
|
|
21
|
+
convention: `MedicationOrderPlaced` is realised as
|
|
22
|
+
`MedicationOrderPlacedEvent`, `CancelOrder` as `CancelOrderCommand`.
|
|
23
|
+
report-only an element whose plan name rarely survives into code verbatim — an
|
|
24
|
+
endpoint route (route groups split it), a screen, a state, a MODIFIED row,
|
|
25
|
+
and the ports and adapters, which the plan names by intent and a framework
|
|
26
|
+
often realises generically (`IRepository<MedicationOrder>` for
|
|
27
|
+
`IMedicationOrderRepository`). Only the model's own report can say it was
|
|
28
|
+
done.
|
|
29
|
+
An element is a GAP when it is checked and not found, or when the model's report does not
|
|
30
|
+
say it was done — including when the report does not mention it at all.
|
|
31
|
+
|
|
32
|
+
THE REPORT. `--reported` takes the `elements` list a generation pass returned —
|
|
33
|
+
[{"element": "<name as listed>", "status": "done|blocked", "reason": "…"}] — and may be
|
|
34
|
+
given more than once; a later report overrides an earlier one for the same element, which
|
|
35
|
+
is how a completion pass's answers replace the first pass's.
|
|
36
|
+
|
|
37
|
+
`--list-only` returns the element names without scanning, for the generation prompt.
|
|
38
|
+
|
|
39
|
+
Always exits 0 with a JSON object; the workflow decides what a gap means.
|
|
40
|
+
|
|
41
|
+
Usage:
|
|
42
|
+
aw-plan-coverage.py --repo R --plan P --kind backend|frontend [--mode bootstrap|delta]
|
|
43
|
+
[--source-path S] [--test-path T] [--reported JSON]... [--list-only]
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import re
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
|
|
53
|
+
TICK = re.compile(r"`([^`]+)`")
|
|
54
|
+
|
|
55
|
+
DONE = {"done", "generated", "implemented", "ok", "exists", "existing", "complete",
|
|
56
|
+
"completed", "present", "extended", "modified", "created", "updated"}
|
|
57
|
+
|
|
58
|
+
# Directories that never hold a lane's own source.
|
|
59
|
+
SKIP_DIRS = {".git", ".hg", ".svn", ".appweaver", "node_modules", "bin", "obj", "dist",
|
|
60
|
+
"build", "out", "target", ".next", ".nuxt", ".svelte-kit", ".angular",
|
|
61
|
+
".turbo", "coverage", "__pycache__", ".venv", "venv", ".gradle", ".idea",
|
|
62
|
+
".vs", ".vscode", "specs", "guardrails", "knowledge", "foundations"}
|
|
63
|
+
CODE_EXT = {".cs", ".fs", ".vb", ".java", ".kt", ".kts", ".scala", ".groovy", ".py",
|
|
64
|
+
".go", ".rs", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue",
|
|
65
|
+
".svelte", ".astro", ".razor", ".cshtml", ".html", ".rb", ".php", ".swift",
|
|
66
|
+
".dart", ".ex", ".exs", ".clj", ".sql", ".proto", ".graphql"}
|
|
67
|
+
TEST_DIRS = {"test", "tests", "__tests__", "__test__", "spec", "specs", "e2e", "testing"}
|
|
68
|
+
TEST_FILE = re.compile(r"(\.(test|spec|e2e)\.[^.]+$)|(^test_.*\.py$)|(_test\.(py|go)$)"
|
|
69
|
+
r"|(Tests?\.(cs|fs|vb|java|kt|scala)$)|(Spec\.(java|kt|scala|groovy)$)")
|
|
70
|
+
MAX_FILES = 50000
|
|
71
|
+
MAX_BYTES = 2 * 1024 * 1024
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def emit(obj):
|
|
75
|
+
# ONE LINE: the engine parses the last line of a script's stdout.
|
|
76
|
+
sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def norm_path(p):
|
|
80
|
+
p = (p or "").replace("\\", "/").strip()
|
|
81
|
+
while p.startswith("./"):
|
|
82
|
+
p = p[2:]
|
|
83
|
+
return "" if p in ("", ".") else p.rstrip("/") + "/"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def placeholder(text):
|
|
87
|
+
"""A template slot the plan author never filled — `[Command/Query]`, `I[External]`."""
|
|
88
|
+
return "[" in text or "]" in text
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def split_row(line):
|
|
92
|
+
cells = line.strip().strip("|").split("|")
|
|
93
|
+
return [c.strip() for c in cells]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_separator(line):
|
|
97
|
+
s = line.strip()
|
|
98
|
+
return s.startswith("|") and set(s.replace("|", "").replace(":", "").strip()) <= {"-"} \
|
|
99
|
+
and "-" in s
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def read_plan(path):
|
|
103
|
+
"""The plan's lines with fenced blocks and HTML comments blanked out."""
|
|
104
|
+
text = open(path, encoding="utf-8").read()
|
|
105
|
+
text = re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S)
|
|
106
|
+
out, fence = [], False
|
|
107
|
+
for line in text.splitlines():
|
|
108
|
+
if line.lstrip().startswith("```"):
|
|
109
|
+
fence = not fence
|
|
110
|
+
out.append("")
|
|
111
|
+
continue
|
|
112
|
+
out.append("" if fence else line)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def walk_sections(lines):
|
|
117
|
+
"""Yield (h2, h3, kind, payload) for every heading and table row.
|
|
118
|
+
|
|
119
|
+
kind is "h2" / "h3" / "h4" with the heading text, or "row" with ({header: cell}).
|
|
120
|
+
h2 / h3 carry the enclosing headings' text so a caller can key on section numbers
|
|
121
|
+
("## 2." / "### 2.2") or names ("## ADDED").
|
|
122
|
+
"""
|
|
123
|
+
h2 = h3 = ""
|
|
124
|
+
headers = None
|
|
125
|
+
for i, line in enumerate(lines):
|
|
126
|
+
s = line.strip()
|
|
127
|
+
if s.startswith("#"):
|
|
128
|
+
headers = None
|
|
129
|
+
level = len(s) - len(s.lstrip("#"))
|
|
130
|
+
title = s[level:].strip()
|
|
131
|
+
if level == 2:
|
|
132
|
+
h2, h3 = title, ""
|
|
133
|
+
elif level == 3:
|
|
134
|
+
h3 = title
|
|
135
|
+
yield h2, h3, "h%d" % level, title
|
|
136
|
+
continue
|
|
137
|
+
if not s.startswith("|"):
|
|
138
|
+
headers = None
|
|
139
|
+
continue
|
|
140
|
+
if is_separator(s):
|
|
141
|
+
continue
|
|
142
|
+
nxt = lines[i + 1].strip() if i + 1 < len(lines) else ""
|
|
143
|
+
if is_separator(nxt):
|
|
144
|
+
headers = [c.lower() for c in split_row(s)]
|
|
145
|
+
continue
|
|
146
|
+
if headers:
|
|
147
|
+
cells = split_row(s)
|
|
148
|
+
yield h2, h3, "row", dict(zip(headers, cells))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def section_no(title):
|
|
152
|
+
m = re.match(r"^(\d+(?:\.\d+)?)[.\s]", title + " ")
|
|
153
|
+
return m.group(1) if m else ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def col(row, prefix):
|
|
157
|
+
for k, v in row.items():
|
|
158
|
+
if k.startswith(prefix):
|
|
159
|
+
return v
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def first_tick(text):
|
|
164
|
+
toks = [t for t in TICK.findall(text) if not placeholder(t)]
|
|
165
|
+
return toks[0].strip() if toks else ""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
WHOLE_WORD_KINDS = {"entity", "value object"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def extract(lines, kind, mode):
|
|
172
|
+
"""[(name, element_kind, checked)] in plan order, de-duplicated by name."""
|
|
173
|
+
found, seen = [], set()
|
|
174
|
+
|
|
175
|
+
def add(name, ekind, checked):
|
|
176
|
+
name = re.sub(r"\s+", " ", (name or "").strip())
|
|
177
|
+
if not name or placeholder(name) or name in seen:
|
|
178
|
+
return
|
|
179
|
+
seen.add(name)
|
|
180
|
+
found.append((name, ekind, bool(checked and IDENT.match(name))))
|
|
181
|
+
|
|
182
|
+
for h2, h3, what, payload in walk_sections(lines):
|
|
183
|
+
n2, n3 = section_no(h2), section_no(h3)
|
|
184
|
+
h2u = h2.upper()
|
|
185
|
+
if mode == "delta":
|
|
186
|
+
if what != "row":
|
|
187
|
+
continue
|
|
188
|
+
added, modified = h2u.startswith("ADDED"), h2u.startswith("MODIFIED")
|
|
189
|
+
if not (added or modified):
|
|
190
|
+
continue
|
|
191
|
+
cell = col(payload, "element")
|
|
192
|
+
ekind = col(payload, "kind").strip().lower() or ("modified" if modified else "")
|
|
193
|
+
toks = [t.strip() for t in TICK.findall(cell) if not placeholder(t)]
|
|
194
|
+
if not toks:
|
|
195
|
+
plain = cell.strip()
|
|
196
|
+
if plain and not placeholder(plain):
|
|
197
|
+
add(plain, ekind or "element", False)
|
|
198
|
+
continue
|
|
199
|
+
# A MODIFIED element already exists, so finding its name proves nothing; routes,
|
|
200
|
+
# screens and states rarely keep their plan name in code.
|
|
201
|
+
report_only = modified or ekind in ("endpoint", "binding", "screen", "state")
|
|
202
|
+
for t in toks:
|
|
203
|
+
add(t, ekind or "element", not report_only)
|
|
204
|
+
continue
|
|
205
|
+
if kind == "backend":
|
|
206
|
+
if what == "h4" and n3 in ("1.3", "2.2"):
|
|
207
|
+
add(first_tick(payload), "entity" if n3 == "1.3" else "use-case", True)
|
|
208
|
+
elif what == "h3" and n2 == "3" and not n3:
|
|
209
|
+
add(first_tick(payload), "endpoint", False)
|
|
210
|
+
elif what == "row" and n3 == "1.2":
|
|
211
|
+
add(first_tick(col(payload, "event")), "event", True)
|
|
212
|
+
elif what == "row" and n3 == "1.4":
|
|
213
|
+
add(first_tick(col(payload, "value object")), "value object", True)
|
|
214
|
+
elif what == "row" and n3 == "2.1":
|
|
215
|
+
add(first_tick(col(payload, "port")), "port", False)
|
|
216
|
+
elif what == "row" and n3 == "4.1":
|
|
217
|
+
add(first_tick(col(payload, "adapter")), "adapter", False)
|
|
218
|
+
else:
|
|
219
|
+
if what == "row" and n2 == "1":
|
|
220
|
+
add(first_tick(col(payload, "screen")), "screen", False)
|
|
221
|
+
elif what == "row" and n2 == "3":
|
|
222
|
+
add(first_tick(col(payload, "component")), "component", True)
|
|
223
|
+
return found
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
HASH_COMMENT_EXT = {".py", ".rb", ".ex", ".exs"}
|
|
227
|
+
BLOCK_COMMENT = re.compile(r"/\*.*?\*/|<!--.*?-->|@\*.*?\*@", re.S)
|
|
228
|
+
LINE_COMMENT = re.compile(r"(?<![:\"'])//[^\n]*")
|
|
229
|
+
HASH_COMMENT = re.compile(r"(?m)^\s*#[^\n]*")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def strip_comments(text, ext):
|
|
233
|
+
"""Code without its comments, so a doc comment that MENTIONS an element — "returned by
|
|
234
|
+
GetOrder" — is not taken for the element itself."""
|
|
235
|
+
text = BLOCK_COMMENT.sub(" ", text)
|
|
236
|
+
if ext in HASH_COMMENT_EXT:
|
|
237
|
+
return HASH_COMMENT.sub("", text)
|
|
238
|
+
return LINE_COMMENT.sub("", text)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def is_test_file(rel):
|
|
242
|
+
parts = rel.split("/")
|
|
243
|
+
if any(p.lower() in TEST_DIRS for p in parts[:-1]):
|
|
244
|
+
return True
|
|
245
|
+
return bool(TEST_FILE.search(parts[-1]))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def scan(repo, source_path, test_path, names):
|
|
249
|
+
"""{name: [relative files naming it]} over the lane's source path, tests excluded.
|
|
250
|
+
|
|
251
|
+
`names` holds (name, whole) keys: whole is True when the name must appear as a whole
|
|
252
|
+
word, False when an identifier that STARTS with it counts.
|
|
253
|
+
"""
|
|
254
|
+
hits = {k: [] for k in names}
|
|
255
|
+
if not names:
|
|
256
|
+
return hits
|
|
257
|
+
patterns = {(n, whole): re.compile(r"(?<![A-Za-z0-9_])" + re.escape(n) +
|
|
258
|
+
(r"(?![A-Za-z0-9_])" if whole else ""))
|
|
259
|
+
for n, whole in names}
|
|
260
|
+
base = os.path.join(repo, source_path.rstrip("/")) if source_path else repo
|
|
261
|
+
if not os.path.isdir(base):
|
|
262
|
+
return hits
|
|
263
|
+
tests = norm_path(test_path)
|
|
264
|
+
src = norm_path(source_path)
|
|
265
|
+
exclude_tests_dir = tests and tests != src
|
|
266
|
+
count = 0
|
|
267
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
268
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
269
|
+
for fn in filenames:
|
|
270
|
+
if os.path.splitext(fn)[1].lower() not in CODE_EXT:
|
|
271
|
+
continue
|
|
272
|
+
full = os.path.join(dirpath, fn)
|
|
273
|
+
rel = os.path.relpath(full, repo).replace("\\", "/")
|
|
274
|
+
if exclude_tests_dir and (rel + "/").startswith(tests):
|
|
275
|
+
continue
|
|
276
|
+
if is_test_file(os.path.relpath(full, base).replace("\\", "/")):
|
|
277
|
+
continue
|
|
278
|
+
count += 1
|
|
279
|
+
if count > MAX_FILES:
|
|
280
|
+
return hits
|
|
281
|
+
try:
|
|
282
|
+
if os.path.getsize(full) > MAX_BYTES:
|
|
283
|
+
continue
|
|
284
|
+
text = open(full, encoding="utf-8", errors="ignore").read()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
text = strip_comments(text, os.path.splitext(fn)[1].lower())
|
|
288
|
+
for key, rx in patterns.items():
|
|
289
|
+
if len(hits[key]) < 5 and key[0] in text and rx.search(text):
|
|
290
|
+
hits[key].append(rel)
|
|
291
|
+
return hits
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def norm_name(s):
|
|
295
|
+
s = (s or "").replace("`", "")
|
|
296
|
+
s = re.sub(r"\{([A-Za-z_][A-Za-z0-9_]*)(?::[^}]*)?\}", r"{\1}", s)
|
|
297
|
+
return re.sub(r"\s+", " ", s).strip().lower()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_generated(raw):
|
|
301
|
+
"""The files a generation pass wrote, as a list. `None` means the caller did not say.
|
|
302
|
+
|
|
303
|
+
The engine renders a list variable as JSON, so this normally arrives as `[]` or
|
|
304
|
+
`["a.cs", …]`; a bare comma-separated string is accepted for a caller driving the op
|
|
305
|
+
by hand. Unparseable input is treated as "did not say" rather than as "wrote nothing",
|
|
306
|
+
because reading a corrupt list as an empty one would turn a failed pass into an
|
|
307
|
+
already-implemented verdict.
|
|
308
|
+
"""
|
|
309
|
+
if raw is None:
|
|
310
|
+
return None
|
|
311
|
+
raw = (raw or "").strip()
|
|
312
|
+
if raw in ("", "[]", "None", "null"):
|
|
313
|
+
return []
|
|
314
|
+
try:
|
|
315
|
+
data = json.loads(raw)
|
|
316
|
+
except ValueError:
|
|
317
|
+
return [p.strip() for p in raw.split(",") if p.strip()]
|
|
318
|
+
if isinstance(data, list):
|
|
319
|
+
return [str(x).strip() for x in data if str(x).strip()]
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def load_reports(raw_list):
|
|
324
|
+
"""{normalised name: (status, reason)} — later reports win. Unreadable input counts as
|
|
325
|
+
no report, which leaves every report-only element unconfirmed."""
|
|
326
|
+
out, readable = {}, False
|
|
327
|
+
for raw in raw_list or []:
|
|
328
|
+
raw = (raw or "").strip()
|
|
329
|
+
if raw in ("", "None", "null"):
|
|
330
|
+
continue
|
|
331
|
+
try:
|
|
332
|
+
data = json.loads(raw)
|
|
333
|
+
except ValueError:
|
|
334
|
+
continue
|
|
335
|
+
if isinstance(data, dict):
|
|
336
|
+
data = data.get("elements") or []
|
|
337
|
+
if not isinstance(data, list):
|
|
338
|
+
continue
|
|
339
|
+
readable = True
|
|
340
|
+
for item in data:
|
|
341
|
+
if isinstance(item, str):
|
|
342
|
+
out[norm_name(item)] = ("done", "")
|
|
343
|
+
continue
|
|
344
|
+
if not isinstance(item, dict):
|
|
345
|
+
continue
|
|
346
|
+
name = item.get("element") or item.get("name") or ""
|
|
347
|
+
if not name:
|
|
348
|
+
continue
|
|
349
|
+
status = str(item.get("status") or "").strip().lower()
|
|
350
|
+
out[norm_name(name)] = (status, str(item.get("reason") or "").strip())
|
|
351
|
+
return out, readable
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def main():
|
|
355
|
+
ap = argparse.ArgumentParser(description="plan element coverage of a lane's code")
|
|
356
|
+
ap.add_argument("--repo", default=".")
|
|
357
|
+
ap.add_argument("--plan", required=True)
|
|
358
|
+
ap.add_argument("--kind", choices=["backend", "frontend"], required=True)
|
|
359
|
+
ap.add_argument("--mode", default="", help="bootstrap | delta (default: read the plan)")
|
|
360
|
+
ap.add_argument("--source-path", default="")
|
|
361
|
+
ap.add_argument("--test-path", default="")
|
|
362
|
+
ap.add_argument("--reported", action="append", default=[])
|
|
363
|
+
ap.add_argument("--generated", default=None,
|
|
364
|
+
help="the files the generation pass wrote. An EMPTY list is the "
|
|
365
|
+
"already-implemented probe: coverage is then judged on the code "
|
|
366
|
+
"alone. Omitted entirely, every element is judged both ways.")
|
|
367
|
+
ap.add_argument("--reserved", default="",
|
|
368
|
+
help="comma-separated names the lane's stack already defines in ambient "
|
|
369
|
+
"scope. Element names matching one are reported as collisions.")
|
|
370
|
+
ap.add_argument("--list-only", action="store_true")
|
|
371
|
+
args = ap.parse_args()
|
|
372
|
+
|
|
373
|
+
repo = os.path.abspath(args.repo)
|
|
374
|
+
plan = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
|
|
375
|
+
base = {"plan": args.plan, "kind": args.kind, "source_path": args.source_path,
|
|
376
|
+
"test_path": args.test_path}
|
|
377
|
+
|
|
378
|
+
if not os.path.isfile(plan):
|
|
379
|
+
base.update({"ok": False, "outcome": "no-plan",
|
|
380
|
+
"plan_exists": False, "mode": args.mode, "total": 0,
|
|
381
|
+
"names": [], "names_csv": "", "names_text": "(the plan does not exist)",
|
|
382
|
+
"elements": [], "gaps": [], "gap_names": [], "gap_names_csv": "",
|
|
383
|
+
"gap_count": 1,
|
|
384
|
+
"gaps_text": "the plan %s does not exist" % args.plan,
|
|
385
|
+
"message": "plan-coverage: the plan %s does not exist" % args.plan})
|
|
386
|
+
emit(base)
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
lines = read_plan(plan)
|
|
390
|
+
mode = args.mode if args.mode in ("bootstrap", "delta") else (
|
|
391
|
+
"delta" if any(l.strip().upper().startswith("## ADDED") for l in lines) else "bootstrap")
|
|
392
|
+
elements = extract(lines, args.kind, mode)
|
|
393
|
+
names = [n for n, _, _ in elements]
|
|
394
|
+
# NAMES THE STACK ALREADY DEFINES. A plan element called `Route` or `Task` is not a
|
|
395
|
+
# naming mistake — the plan carries the ubiquitous language and the generator spells it
|
|
396
|
+
# verbatim — but on a stack whose ambient scope defines the same name, writing it bare
|
|
397
|
+
# is ambiguous and does not compile (C# CS0104). The conflict is invisible in the file,
|
|
398
|
+
# because the framework name arrives through an implicit using and appears in no line
|
|
399
|
+
# of it, so the generator is TOLD which of its own element names are affected.
|
|
400
|
+
reserved = {r.strip() for r in (args.reserved or "").replace(";", ",").split(",") if r.strip()}
|
|
401
|
+
collisions = []
|
|
402
|
+
for n in names:
|
|
403
|
+
leaf = n.split(".")[-1]
|
|
404
|
+
if leaf in reserved and leaf not in collisions:
|
|
405
|
+
collisions.append(leaf)
|
|
406
|
+
base.update({"plan_exists": True, "mode": mode, "total": len(elements),
|
|
407
|
+
"names": names, "names_csv": ", ".join(names),
|
|
408
|
+
"collisions": collisions, "collisions_csv": ", ".join(collisions),
|
|
409
|
+
"collision_count": len(collisions),
|
|
410
|
+
# Prompt-ready: never empty, so the sentence around it still reads.
|
|
411
|
+
"collisions_text": (
|
|
412
|
+
"%s — each of these is also a type this stack defines in ambient scope, "
|
|
413
|
+
"so write it QUALIFIED at every use site" % ", ".join(collisions))
|
|
414
|
+
if collisions else "none — no plan element collides with a framework type",
|
|
415
|
+
# For a prompt: never empty, so the sentence around it still reads.
|
|
416
|
+
"names_text": ", ".join(names) if names else
|
|
417
|
+
"(none could be read from the plan — report each element you realise by "
|
|
418
|
+
"the name the plan gives it)"})
|
|
419
|
+
|
|
420
|
+
if args.list_only:
|
|
421
|
+
base.update({"ok": True, "outcome": "listed",
|
|
422
|
+
"message": "%d plan element(s) listed" % len(elements)})
|
|
423
|
+
emit(base)
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
def probe_key(name, ekind):
|
|
427
|
+
return (name.split(".")[-1], mode == "bootstrap" and ekind in WHOLE_WORD_KINDS)
|
|
428
|
+
|
|
429
|
+
checked = [n for n, _, c in elements if c]
|
|
430
|
+
probe = {probe_key(n, k) for n, k, c in elements if c}
|
|
431
|
+
hits = scan(repo, norm_path(args.source_path), args.test_path, probe)
|
|
432
|
+
reports, readable = load_reports(args.reported)
|
|
433
|
+
|
|
434
|
+
# A pass that wrote NOTHING is either a lane that is already built or one that failed,
|
|
435
|
+
# and the difference is written in the code, not in what the pass said about itself. So
|
|
436
|
+
# the report requirement is dropped for that probe: an element no scan can confirm — a
|
|
437
|
+
# port, an adapter — was confirmed by the run that created it and cannot be confirmed
|
|
438
|
+
# again without generating. An element the pass explicitly called blocked still counts
|
|
439
|
+
# against it either way.
|
|
440
|
+
generated = parse_generated(args.generated)
|
|
441
|
+
code_only = generated is not None and not generated
|
|
442
|
+
|
|
443
|
+
rows, gaps, gap_names = [], [], []
|
|
444
|
+
for name, ekind, is_checked in elements:
|
|
445
|
+
files = hits.get(probe_key(name, ekind), []) if is_checked else []
|
|
446
|
+
status, reason = reports.get(norm_name(name), ("", ""))
|
|
447
|
+
done = status in DONE
|
|
448
|
+
why = []
|
|
449
|
+
if is_checked and not files:
|
|
450
|
+
why.append("no code under %s names it" % (args.source_path or "the repo"))
|
|
451
|
+
if status and not done:
|
|
452
|
+
why.append("reported %s%s" % (status, (": " + reason) if reason else ""))
|
|
453
|
+
elif not status and not is_checked and not code_only:
|
|
454
|
+
why.append("not reported done")
|
|
455
|
+
rows.append({"element": name, "kind": ekind, "checked": is_checked,
|
|
456
|
+
"found": bool(files) if is_checked else None, "files": files,
|
|
457
|
+
"reported": status or "", "gap": bool(why)})
|
|
458
|
+
if why:
|
|
459
|
+
gaps.append("%s (%s) — %s" % (name, ekind, "; ".join(why)))
|
|
460
|
+
gap_names.append(name)
|
|
461
|
+
|
|
462
|
+
total_checked = len(checked)
|
|
463
|
+
found = sum(1 for r in rows if r["checked"] and r["found"])
|
|
464
|
+
ok = not gaps
|
|
465
|
+
|
|
466
|
+
# WHAT HAPPENED TO THIS LANE, as one word a caller can branch on. "no-plan-elements" is
|
|
467
|
+
# kept apart from "already-implemented" because they look identical from the file count
|
|
468
|
+
# and mean opposite things: a plan nothing can be read from proves nothing about the
|
|
469
|
+
# code, and a pass that wrote no files against one is unexplained, not finished.
|
|
470
|
+
if not elements:
|
|
471
|
+
outcome = "no-plan-elements"
|
|
472
|
+
elif gaps:
|
|
473
|
+
outcome = "gaps"
|
|
474
|
+
elif code_only:
|
|
475
|
+
outcome = "already-implemented"
|
|
476
|
+
else:
|
|
477
|
+
outcome = "complete"
|
|
478
|
+
|
|
479
|
+
if outcome == "no-plan-elements":
|
|
480
|
+
msg = ("no plan element could be read from %s — its layout does not follow the "
|
|
481
|
+
"plan template, so coverage was not checked" % args.plan)
|
|
482
|
+
elif outcome == "already-implemented":
|
|
483
|
+
msg = ("this pass wrote no files and every one of the %d plan element(s) is already "
|
|
484
|
+
"realised under %s — there was nothing left to write"
|
|
485
|
+
% (len(elements), args.source_path or "the repo"))
|
|
486
|
+
elif ok:
|
|
487
|
+
msg = "all %d plan element(s) realised (%d found in code, %d reported done)" % (
|
|
488
|
+
len(elements), found, len(elements) - total_checked)
|
|
489
|
+
else:
|
|
490
|
+
msg = "%d of %d plan element(s) not realised: %s" % (
|
|
491
|
+
len(gaps), len(elements), "; ".join(gaps))
|
|
492
|
+
base.update({"ok": ok, "outcome": outcome, "wrote_files": bool(generated),
|
|
493
|
+
"judged_on": "code" if code_only else "code and report",
|
|
494
|
+
"reported": readable, "checked_on_disk": total_checked,
|
|
495
|
+
"found_on_disk": found, "elements": rows, "gaps": gaps,
|
|
496
|
+
"gap_names": gap_names, "gap_names_csv": ", ".join(gap_names),
|
|
497
|
+
"gap_count": len(gaps),
|
|
498
|
+
"gaps_text": "; ".join(gaps) if gaps else "none", "message": msg})
|
|
499
|
+
emit(base)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
main()
|