aw-spec-cli 0.1.19 → 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.
Files changed (75) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +17 -9
  2. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  3. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +26 -3
  4. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  5. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
  6. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +4 -2
  7. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
  8. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +1 -1
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +121 -42
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -7
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +145 -52
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -10
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +306 -28
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +1 -1
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +1 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +114 -30
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +385 -61
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +407 -107
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +49 -11
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +1 -1
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +19 -11
  31. package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
  32. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +683 -92
  33. package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
  34. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
  35. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +625 -64
  36. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
  37. package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
  38. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  39. package/assets/.appweaver/scripts/python/aw-inventory.py +1829 -63
  40. package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
  41. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
  42. package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
  43. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  44. package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
  45. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
  46. package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
  47. package/assets/.appweaver/stack-profiles/node-express.json +1 -0
  48. package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
  49. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  50. package/assets/.appweaver/templates/delta-design-template.md +1 -1
  51. package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
  52. package/assets/.appweaver/templates/delta-spec-template.md +4 -3
  53. package/assets/.appweaver/templates/design-template.md +16 -11
  54. package/assets/.appweaver/templates/okf-concept-template.md +11 -1
  55. package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
  56. package/assets/.appweaver/templates/source-map-template.md +5 -3
  57. package/assets/.appweaver/templates/spec-template.md +17 -17
  58. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  59. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  60. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
  61. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
  62. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  63. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  64. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  65. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  66. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  67. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  68. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  69. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  70. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +70 -0
  71. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  72. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  73. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  74. package/package.json +1 -1
  75. package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
@@ -53,6 +53,9 @@ ACCEPTED_BASH_ONLY = {
53
53
  # Aggregate overlap between open model deltas: two markdown regexes over a directory
54
54
  # listing. Native on both sides for the same reason as the pair above.
55
55
  "cmd_cr_overlap",
56
+ # analyze-plan gap routing: one pass over a JSON list and two string tests. PowerShell
57
+ # parses the list natively, beside its native plan-gate-stamp / plan-gate-check.
58
+ "cmd_plan_gaps",
56
59
  # Header stamping and wave discovery are a few lines of regex over a markdown header.
57
60
  # PowerShell does that natively at a fraction of the cost of spawning python, and both
58
61
  # sides are short enough to review as a pair — which is the bar for this list.
@@ -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()