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.
Files changed (62) hide show
  1. package/assets/.appweaver/ORCH-DIALECT.md +8 -3
  2. package/assets/.appweaver/STACK-PROFILES.md +87 -8
  3. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
  4. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
  5. package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
  6. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
  7. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
  8. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
  9. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
  10. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
  11. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
  12. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
  13. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
  22. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
  23. package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
  24. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
  25. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
  26. package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
  27. package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
  28. package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
  29. package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
  30. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
  31. package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
  32. package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
  33. package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
  34. package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
  35. package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
  36. package/assets/.appweaver/stack-profiles/angular.json +26 -0
  37. package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
  39. package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
  40. package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
  41. package/assets/.appweaver/stack-profiles/node-express.json +27 -0
  42. package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
  43. package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
  44. package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
  45. package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
  46. package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
  47. package/assets/.appweaver/templates/ui-model-template.md +67 -0
  48. package/assets/.appweaver/templates/workflow-model-template.md +62 -0
  49. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
  50. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
  51. package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
  52. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
  53. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
  54. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
  55. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
  56. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
  57. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +114 -0
  58. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
  59. package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
  60. package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
  61. package/package.json +1 -1
  62. 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()