aw-spec-cli 0.1.20 → 0.1.21
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/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 +107 -48
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +17 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +265 -20
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +89 -15
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +354 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +651 -76
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +600 -59
- package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
- package/assets/.appweaver/stack-profiles/node-express.json +1 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- 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 +70 -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,423 @@
|
|
|
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 load_reports(raw_list):
|
|
301
|
+
"""{normalised name: (status, reason)} — later reports win. Unreadable input counts as
|
|
302
|
+
no report, which leaves every report-only element unconfirmed."""
|
|
303
|
+
out, readable = {}, False
|
|
304
|
+
for raw in raw_list or []:
|
|
305
|
+
raw = (raw or "").strip()
|
|
306
|
+
if raw in ("", "None", "null"):
|
|
307
|
+
continue
|
|
308
|
+
try:
|
|
309
|
+
data = json.loads(raw)
|
|
310
|
+
except ValueError:
|
|
311
|
+
continue
|
|
312
|
+
if isinstance(data, dict):
|
|
313
|
+
data = data.get("elements") or []
|
|
314
|
+
if not isinstance(data, list):
|
|
315
|
+
continue
|
|
316
|
+
readable = True
|
|
317
|
+
for item in data:
|
|
318
|
+
if isinstance(item, str):
|
|
319
|
+
out[norm_name(item)] = ("done", "")
|
|
320
|
+
continue
|
|
321
|
+
if not isinstance(item, dict):
|
|
322
|
+
continue
|
|
323
|
+
name = item.get("element") or item.get("name") or ""
|
|
324
|
+
if not name:
|
|
325
|
+
continue
|
|
326
|
+
status = str(item.get("status") or "").strip().lower()
|
|
327
|
+
out[norm_name(name)] = (status, str(item.get("reason") or "").strip())
|
|
328
|
+
return out, readable
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main():
|
|
332
|
+
ap = argparse.ArgumentParser(description="plan element coverage of a lane's code")
|
|
333
|
+
ap.add_argument("--repo", default=".")
|
|
334
|
+
ap.add_argument("--plan", required=True)
|
|
335
|
+
ap.add_argument("--kind", choices=["backend", "frontend"], required=True)
|
|
336
|
+
ap.add_argument("--mode", default="", help="bootstrap | delta (default: read the plan)")
|
|
337
|
+
ap.add_argument("--source-path", default="")
|
|
338
|
+
ap.add_argument("--test-path", default="")
|
|
339
|
+
ap.add_argument("--reported", action="append", default=[])
|
|
340
|
+
ap.add_argument("--list-only", action="store_true")
|
|
341
|
+
args = ap.parse_args()
|
|
342
|
+
|
|
343
|
+
repo = os.path.abspath(args.repo)
|
|
344
|
+
plan = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
|
|
345
|
+
base = {"plan": args.plan, "kind": args.kind, "source_path": args.source_path,
|
|
346
|
+
"test_path": args.test_path}
|
|
347
|
+
|
|
348
|
+
if not os.path.isfile(plan):
|
|
349
|
+
base.update({"ok": False, "plan_exists": False, "mode": args.mode, "total": 0,
|
|
350
|
+
"names": [], "names_csv": "", "names_text": "(the plan does not exist)",
|
|
351
|
+
"elements": [], "gaps": [], "gap_names": [], "gap_names_csv": "",
|
|
352
|
+
"gap_count": 1,
|
|
353
|
+
"gaps_text": "the plan %s does not exist" % args.plan,
|
|
354
|
+
"message": "plan-coverage: the plan %s does not exist" % args.plan})
|
|
355
|
+
emit(base)
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
lines = read_plan(plan)
|
|
359
|
+
mode = args.mode if args.mode in ("bootstrap", "delta") else (
|
|
360
|
+
"delta" if any(l.strip().upper().startswith("## ADDED") for l in lines) else "bootstrap")
|
|
361
|
+
elements = extract(lines, args.kind, mode)
|
|
362
|
+
names = [n for n, _, _ in elements]
|
|
363
|
+
base.update({"plan_exists": True, "mode": mode, "total": len(elements),
|
|
364
|
+
"names": names, "names_csv": ", ".join(names),
|
|
365
|
+
# For a prompt: never empty, so the sentence around it still reads.
|
|
366
|
+
"names_text": ", ".join(names) if names else
|
|
367
|
+
"(none could be read from the plan — report each element you realise by "
|
|
368
|
+
"the name the plan gives it)"})
|
|
369
|
+
|
|
370
|
+
if args.list_only:
|
|
371
|
+
base.update({"ok": True, "message": "%d plan element(s) listed" % len(elements)})
|
|
372
|
+
emit(base)
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
def probe_key(name, ekind):
|
|
376
|
+
return (name.split(".")[-1], mode == "bootstrap" and ekind in WHOLE_WORD_KINDS)
|
|
377
|
+
|
|
378
|
+
checked = [n for n, _, c in elements if c]
|
|
379
|
+
probe = {probe_key(n, k) for n, k, c in elements if c}
|
|
380
|
+
hits = scan(repo, norm_path(args.source_path), args.test_path, probe)
|
|
381
|
+
reports, readable = load_reports(args.reported)
|
|
382
|
+
|
|
383
|
+
rows, gaps, gap_names = [], [], []
|
|
384
|
+
for name, ekind, is_checked in elements:
|
|
385
|
+
files = hits.get(probe_key(name, ekind), []) if is_checked else []
|
|
386
|
+
status, reason = reports.get(norm_name(name), ("", ""))
|
|
387
|
+
done = status in DONE
|
|
388
|
+
why = []
|
|
389
|
+
if is_checked and not files:
|
|
390
|
+
why.append("no code under %s names it" % (args.source_path or "the repo"))
|
|
391
|
+
if status and not done:
|
|
392
|
+
why.append("reported %s%s" % (status, (": " + reason) if reason else ""))
|
|
393
|
+
elif not status and not is_checked:
|
|
394
|
+
why.append("not reported done")
|
|
395
|
+
rows.append({"element": name, "kind": ekind, "checked": is_checked,
|
|
396
|
+
"found": bool(files) if is_checked else None, "files": files,
|
|
397
|
+
"reported": status or "", "gap": bool(why)})
|
|
398
|
+
if why:
|
|
399
|
+
gaps.append("%s (%s) — %s" % (name, ekind, "; ".join(why)))
|
|
400
|
+
gap_names.append(name)
|
|
401
|
+
|
|
402
|
+
total_checked = len(checked)
|
|
403
|
+
found = sum(1 for r in rows if r["checked"] and r["found"])
|
|
404
|
+
ok = not gaps
|
|
405
|
+
if not elements:
|
|
406
|
+
msg = ("no plan element could be read from %s — its layout does not follow the "
|
|
407
|
+
"plan template, so coverage was not checked" % args.plan)
|
|
408
|
+
elif ok:
|
|
409
|
+
msg = "all %d plan element(s) realised (%d found in code, %d reported done)" % (
|
|
410
|
+
len(elements), found, len(elements) - total_checked)
|
|
411
|
+
else:
|
|
412
|
+
msg = "%d of %d plan element(s) not realised: %s" % (
|
|
413
|
+
len(gaps), len(elements), "; ".join(gaps))
|
|
414
|
+
base.update({"ok": ok, "reported": readable, "checked_on_disk": total_checked,
|
|
415
|
+
"found_on_disk": found, "elements": rows, "gaps": gaps,
|
|
416
|
+
"gap_names": gap_names, "gap_names_csv": ", ".join(gap_names),
|
|
417
|
+
"gap_count": len(gaps),
|
|
418
|
+
"gaps_text": "; ".join(gaps) if gaps else "none", "message": msg})
|
|
419
|
+
emit(base)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
if __name__ == "__main__":
|
|
423
|
+
main()
|