aw-spec-cli 0.1.21 → 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/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
- 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-specify.orch.yaml +100 -0
- 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 +190 -209
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +182 -224
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
- 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 +6 -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 +38 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +26 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -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/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
- package/package.json +1 -1
|
@@ -17,6 +17,15 @@ in the bounded context: a sibling feature's merge moves a master THIS delta was
|
|
|
17
17
|
against and leaves nothing in this feature's spec. So the scan covers the context's model
|
|
18
18
|
and every feature's spec, and reports which document carried the evidence.
|
|
19
19
|
|
|
20
|
+
It also owns the WAVE ORDERING the two gates of the CR loop read in opposite directions —
|
|
21
|
+
merge refuses a change with an open predecessor below it, recall refuses one with an open
|
|
22
|
+
dependant above it. Both asked the question with their own copy of the reader, in each of
|
|
23
|
+
two shells, and every copy answered "wave 1" when it could not read a `**Wave**:` header.
|
|
24
|
+
That default is not a neutral one: the shipped template line is `**Wave**: [N]`, so an
|
|
25
|
+
UNSTAMPED delta reads as wave 1, and a subject at wave 1 can have no predecessor at all —
|
|
26
|
+
the fence quietly switches itself off for exactly the change being merged. Here an
|
|
27
|
+
unreadable wave is None, and a caller is told which changes it could not order.
|
|
28
|
+
|
|
20
29
|
Importable (the bash payloads load it by path) and a CLI (the PowerShell twins call it), so
|
|
21
30
|
both shells answer from the same code.
|
|
22
31
|
|
|
@@ -24,7 +33,14 @@ Usage:
|
|
|
24
33
|
aw-crstate.py merged --cr <id> --doc <path> [--doc <path>…]
|
|
25
34
|
-> {"merged": bool, "evidence": "<the row>", "document": "<path>"}
|
|
26
35
|
aw-crstate.py merged-open --cr <id> --feature-dir <p> --context-dir <p>
|
|
27
|
-
-> {"merged_open": [{cr_id, feature, document, evidence, aggregates, shared, same_feature}],
|
|
36
|
+
-> {"merged_open": [{cr_id, feature, document, evidence, aggregates, shared, same_feature}],
|
|
37
|
+
"blocking": [...], "blocking_count": n, "blocking_ids": "…", "blocking_text": "…"}
|
|
38
|
+
aw-crstate.py wave-order --cr <id> --feature-dir <p> --context-dir <p>
|
|
39
|
+
[--direction lower|higher] [--scope context|feature]
|
|
40
|
+
-> {"wave": int|null, "wave_known": bool, "related": [{cr_id, wave, feature}],
|
|
41
|
+
"related_count": n, "related_ids": "…", "unknown": [...], "unknown_count": n,
|
|
42
|
+
"unknown_ids": "…", "unknown_blocks": bool, "wave_ordering_ok": bool,
|
|
43
|
+
"wave_problem": "…"}
|
|
28
44
|
"""
|
|
29
45
|
|
|
30
46
|
import argparse
|
|
@@ -36,6 +52,13 @@ import sys
|
|
|
36
52
|
|
|
37
53
|
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
38
54
|
TICK_RX = re.compile(r"`([^`]+)`")
|
|
55
|
+
WAVE_RX = re.compile(r"^\*\*Wave\*\*:[ \t]*(\d+)", re.M)
|
|
56
|
+
|
|
57
|
+
# The lowest wave a change can legitimately carry. cr-wave-derive computes a change's
|
|
58
|
+
# required wave as `max(predecessor waves, default 0) + 1`, so wave 1 is the floor: nothing
|
|
59
|
+
# sits below it, and a change AT it can have no predecessor. That is what makes an
|
|
60
|
+
# unreadable neighbour harmless to a wave-1 merge and ambiguous to every other one.
|
|
61
|
+
FLOOR = 1
|
|
39
62
|
|
|
40
63
|
|
|
41
64
|
def read(path):
|
|
@@ -127,6 +150,91 @@ def open_changes(ctx):
|
|
|
127
150
|
return found
|
|
128
151
|
|
|
129
152
|
|
|
153
|
+
def wave_of(ctx, feature, cr_id):
|
|
154
|
+
"""The wave a change states in its own delta documents, or None when none of them
|
|
155
|
+
carries a readable `**Wave**:` header.
|
|
156
|
+
|
|
157
|
+
The documents are read in the order cr-wave-derive stamps them, so a change cannot be
|
|
158
|
+
ordered one way by the wave fence and another by the derivation that sets it.
|
|
159
|
+
"""
|
|
160
|
+
paths = [os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)]
|
|
161
|
+
if feature:
|
|
162
|
+
d = os.path.join(ctx, feature, "changes", cr_id)
|
|
163
|
+
paths.append(os.path.join(d, "delta-spec.md"))
|
|
164
|
+
paths.extend(sorted(glob.glob(os.path.join(glob.escape(d), "delta-*.md"))))
|
|
165
|
+
for p in paths:
|
|
166
|
+
m = WAVE_RX.search(read(p))
|
|
167
|
+
if m:
|
|
168
|
+
return int(m.group(1))
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _whose(rec, show_feature):
|
|
173
|
+
"""Which feature a change belongs to, for a reader who has never opened it. The scan is
|
|
174
|
+
context-wide, so the id alone names something the operator may have no way to find — and
|
|
175
|
+
finalize-delta resolves a change under the ACTIVE feature, so the feature is the step
|
|
176
|
+
between reading the refusal and acting on it, not a courtesy."""
|
|
177
|
+
if not show_feature:
|
|
178
|
+
return ""
|
|
179
|
+
if rec.get("feature"):
|
|
180
|
+
return ", feature %s" % rec["feature"]
|
|
181
|
+
return ", model delta only — no changes/ directory in this context"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def wave_order(ctx, feature_dir, cr_id, direction="lower", scope="context"):
|
|
185
|
+
"""The open changes this one is ordered against, and the ones it cannot be ordered against.
|
|
186
|
+
|
|
187
|
+
`direction` is which side of this change matters: "lower" for the merge fence (a
|
|
188
|
+
predecessor lands first) and "higher" for the recall fence (a dependant was built on
|
|
189
|
+
top). `scope` is how wide the scan runs — context-wide for merge, because two features
|
|
190
|
+
of one context share domain-model.md, and this feature only for recall.
|
|
191
|
+
|
|
192
|
+
A change whose wave cannot be read is NOT ordered and never silently counted as either.
|
|
193
|
+
It blocks where it is genuinely ambiguous: for the merge fence only above the floor,
|
|
194
|
+
since nothing can precede a wave-1 change; for the recall fence always, because waves
|
|
195
|
+
have no ceiling and any unreadable change could sit above this one.
|
|
196
|
+
"""
|
|
197
|
+
this_feat = os.path.basename(os.path.normpath(feature_dir))
|
|
198
|
+
wave = wave_of(ctx, this_feat, cr_id)
|
|
199
|
+
show_feature = scope == "context"
|
|
200
|
+
rows, unknown = [], []
|
|
201
|
+
for other, feat in sorted(open_changes(ctx).items()):
|
|
202
|
+
if other == cr_id or (scope == "feature" and feat != this_feat):
|
|
203
|
+
continue
|
|
204
|
+
w = wave_of(ctx, feat, other)
|
|
205
|
+
rec = {"cr_id": other, "wave": w, "feature": feat}
|
|
206
|
+
if w is None:
|
|
207
|
+
unknown.append(rec)
|
|
208
|
+
elif wave is not None and (w < wave if direction == "lower" else w > wave):
|
|
209
|
+
rows.append(rec)
|
|
210
|
+
|
|
211
|
+
unknown_blocks = bool(unknown) and (direction == "higher" or
|
|
212
|
+
(wave is not None and wave > FLOOR))
|
|
213
|
+
problem = ""
|
|
214
|
+
if wave is None:
|
|
215
|
+
problem = ("change %s carries no readable `**Wave**:` header of its own, so it "
|
|
216
|
+
"cannot be ordered against anything: every open change reads as neither "
|
|
217
|
+
"above nor below it, and this fence has nothing left to check" % cr_id)
|
|
218
|
+
elif unknown_blocks:
|
|
219
|
+
problem = ("%d open change(s) carry no readable `**Wave**:` header, so they cannot "
|
|
220
|
+
"be placed above or below change %s (wave %d): %s"
|
|
221
|
+
% (len(unknown), cr_id, wave,
|
|
222
|
+
", ".join("%s%s" % (u["cr_id"], _whose(u, show_feature))
|
|
223
|
+
for u in unknown)))
|
|
224
|
+
return {
|
|
225
|
+
"wave": wave, "wave_known": wave is not None,
|
|
226
|
+
"related": rows, "related_count": len(rows),
|
|
227
|
+
"related_ids": ", ".join("%s (wave %d%s)" % (r["cr_id"], r["wave"],
|
|
228
|
+
_whose(r, show_feature)) for r in rows),
|
|
229
|
+
"unknown": unknown, "unknown_count": len(unknown),
|
|
230
|
+
"unknown_ids": ", ".join("%s%s" % (u["cr_id"], _whose(u, show_feature))
|
|
231
|
+
for u in unknown),
|
|
232
|
+
"unknown_blocks": unknown_blocks,
|
|
233
|
+
"wave_ordering_ok": wave is not None and not unknown_blocks,
|
|
234
|
+
"wave_problem": problem,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
130
238
|
def merged_open(ctx, feature_dir, cr_id):
|
|
131
239
|
"""Changes that are MERGED into a master and still OPEN — the state in which the masters
|
|
132
240
|
carry a change the version does not yet reflect.
|
|
@@ -166,6 +274,13 @@ def merged_open(ctx, feature_dir, cr_id):
|
|
|
166
274
|
return out
|
|
167
275
|
|
|
168
276
|
|
|
277
|
+
def merged_open_text(rows):
|
|
278
|
+
"""Merged-but-open changes as a reader can act on them. The feature is what turns an id
|
|
279
|
+
into somewhere to go: a sibling's merge is counted here, and close-delta runs under the
|
|
280
|
+
feature that owns the change."""
|
|
281
|
+
return ", ".join("%s%s" % (r["cr_id"], _whose(r, True)) for r in rows)
|
|
282
|
+
|
|
283
|
+
|
|
169
284
|
def main():
|
|
170
285
|
ap = argparse.ArgumentParser(description="one definition of a merged change")
|
|
171
286
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
@@ -176,17 +291,27 @@ def main():
|
|
|
176
291
|
o.add_argument("--cr", required=True)
|
|
177
292
|
o.add_argument("--feature-dir", required=True)
|
|
178
293
|
o.add_argument("--context-dir", required=True)
|
|
294
|
+
w = sub.add_parser("wave-order")
|
|
295
|
+
w.add_argument("--cr", required=True)
|
|
296
|
+
w.add_argument("--feature-dir", required=True)
|
|
297
|
+
w.add_argument("--context-dir", required=True)
|
|
298
|
+
w.add_argument("--direction", choices=["lower", "higher"], default="lower")
|
|
299
|
+
w.add_argument("--scope", choices=["context", "feature"], default="context")
|
|
179
300
|
args = ap.parse_args()
|
|
180
301
|
|
|
181
302
|
if args.cmd == "merged":
|
|
182
303
|
row, doc = merged_in(args.doc, args.cr)
|
|
183
304
|
out = {"merged": bool(row), "evidence": row, "document": doc}
|
|
305
|
+
elif args.cmd == "wave-order":
|
|
306
|
+
out = wave_order(args.context_dir, args.feature_dir, args.cr,
|
|
307
|
+
args.direction, args.scope)
|
|
184
308
|
else:
|
|
185
309
|
rows = merged_open(args.context_dir, args.feature_dir, args.cr)
|
|
186
310
|
blocking = [r for r in rows if r["blocking"]]
|
|
187
311
|
out = {"merged_open": rows, "blocking": blocking,
|
|
188
312
|
"blocking_count": len(blocking),
|
|
189
|
-
"blocking_ids": ", ".join(r["cr_id"] for r in blocking)
|
|
313
|
+
"blocking_ids": ", ".join(r["cr_id"] for r in blocking),
|
|
314
|
+
"blocking_text": merged_open_text(blocking)}
|
|
190
315
|
sys.stdout.write(json.dumps(out, separators=(",", ":")) + "\n")
|
|
191
316
|
|
|
192
317
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-gaps — split a gate's findings by WHO CAN CLOSE THEM.
|
|
4
|
+
|
|
5
|
+
A conformance gate returns findings that look alike and are not: one says the code does not
|
|
6
|
+
do what the change asked, another says the delta PLAN asked for the wrong thing, a third
|
|
7
|
+
says the delta spec or the model never said it. They read the same in a list and they are
|
|
8
|
+
closed by three different commands — and sending a plan defect to `implement` cannot work,
|
|
9
|
+
because implement generates FROM the plan and will faithfully reproduce it.
|
|
10
|
+
|
|
11
|
+
The checks already tag each finding with the command that closes it (`fix`). This turns that
|
|
12
|
+
tag into the routing the caller reports: per-route counts, the ids involved, and a line of
|
|
13
|
+
prose per route that a gate message can print without assembling it itself.
|
|
14
|
+
|
|
15
|
+
A finding whose `fix` is missing or unrecognised is NOT quietly folded into the biggest
|
|
16
|
+
bucket. It goes to `review` and is counted there, because a finding nobody can be told to
|
|
17
|
+
close is worse than one routed to the wrong place: the first is visible, the second is not.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
aw-gaps.py route --gaps '<json>' [--gaps '<json>']...
|
|
21
|
+
-> {total, implement_count/_refs/_text, plan_count/…, specify_count/…,
|
|
22
|
+
review_count/…, high_count, summary, routes}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
# The commands a finding can be routed to, in the order a reader should act on them:
|
|
30
|
+
# fix the spec before the plan that realises it, and the plan before the code it maps to.
|
|
31
|
+
ROUTES = ["specify", "plan", "implement", "review"]
|
|
32
|
+
|
|
33
|
+
HOW = {
|
|
34
|
+
"specify": "/appweaver specify {cr} — the delta spec or the model delta is what is missing",
|
|
35
|
+
"plan": "/appweaver plan {cr} — the delta PLAN is what is wrong, so re-derive it "
|
|
36
|
+
"(then /appweaver analyze-plan-delta {cr} and /appweaver implement {cr})",
|
|
37
|
+
"implement": "/appweaver implement {cr} — the code is missing or does not do what the "
|
|
38
|
+
"change asked",
|
|
39
|
+
"review": "no `fix` command was recorded for these — read them in the report and decide",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(raw_list):
|
|
44
|
+
"""Every findings list the caller passed, flattened. Accepts the list itself or the
|
|
45
|
+
whole `{ok, gaps}` envelope, and ignores what it cannot parse rather than failing the
|
|
46
|
+
gate that called it — a router that crashes turns a reportable red into a stack trace."""
|
|
47
|
+
out = []
|
|
48
|
+
for raw in raw_list or []:
|
|
49
|
+
raw = (raw or "").strip()
|
|
50
|
+
if raw in ("", "[]", "None", "null"):
|
|
51
|
+
continue
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
continue
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
data = data.get("gaps") or []
|
|
58
|
+
if isinstance(data, list):
|
|
59
|
+
out.extend(d for d in data if isinstance(d, dict))
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def route_of(gap):
|
|
64
|
+
fix = str(gap.get("fix") or "").strip().lower()
|
|
65
|
+
# The tag may arrive as a whole command line ("/appweaver plan CR-…"); the verb is what
|
|
66
|
+
# routes, and anything else is unrecognised rather than guessed at.
|
|
67
|
+
for r in ("specify", "plan", "implement"):
|
|
68
|
+
if fix == r or fix.startswith(r + " ") or fix.endswith("/" + r) or ("appweaver " + r) in fix:
|
|
69
|
+
return r
|
|
70
|
+
return "review"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def describe(gap):
|
|
74
|
+
ref = str(gap.get("ref") or "").strip() or "(no id)"
|
|
75
|
+
where = str(gap.get("where") or "").strip()
|
|
76
|
+
what = str(gap.get("gap") or "").strip()
|
|
77
|
+
sev = str(gap.get("severity") or "").strip()
|
|
78
|
+
check = str(gap.get("check") or "").strip()
|
|
79
|
+
head = "%s%s" % (ref, " [%s%s]" % (sev, "/" + check if check else "") if sev or check else "")
|
|
80
|
+
tail = " at %s" % where if where and where.lower() != "absent" else ""
|
|
81
|
+
return "%s%s — %s" % (head, tail, what or "no detail recorded")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main():
|
|
85
|
+
ap = argparse.ArgumentParser(description="split gate findings by the command that closes them")
|
|
86
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
87
|
+
r = sub.add_parser("route")
|
|
88
|
+
r.add_argument("--gaps", action="append", default=[])
|
|
89
|
+
r.add_argument("--cr", default="<CR-id>", help="woven into each route's command line")
|
|
90
|
+
args = ap.parse_args()
|
|
91
|
+
|
|
92
|
+
gaps = load(args.gaps)
|
|
93
|
+
buckets = {k: [] for k in ROUTES}
|
|
94
|
+
for g in gaps:
|
|
95
|
+
buckets[route_of(g)].append(g)
|
|
96
|
+
|
|
97
|
+
out = {"total": len(gaps),
|
|
98
|
+
"high_count": sum(1 for g in gaps
|
|
99
|
+
if str(g.get("severity") or "").lower() == "high"),
|
|
100
|
+
"routes": {k: buckets[k] for k in ROUTES}}
|
|
101
|
+
parts = []
|
|
102
|
+
for k in ROUTES:
|
|
103
|
+
rows = buckets[k]
|
|
104
|
+
refs = [str(g.get("ref") or "").strip() for g in rows if str(g.get("ref") or "").strip()]
|
|
105
|
+
out[k + "_count"] = len(rows)
|
|
106
|
+
out[k + "_refs"] = ", ".join(dict.fromkeys(refs))
|
|
107
|
+
out[k + "_text"] = ("%s:\n%s" % (HOW[k].format(cr=args.cr),
|
|
108
|
+
"\n".join(" - " + describe(g) for g in rows))
|
|
109
|
+
if rows else "none")
|
|
110
|
+
if rows:
|
|
111
|
+
parts.append("%d → %s" % (len(rows), HOW[k].format(cr=args.cr).split(" — ")[0]))
|
|
112
|
+
out["summary"] = ("%d finding(s), %d high. Routed: %s"
|
|
113
|
+
% (len(gaps), out["high_count"], "; ".join(parts))) if gaps else \
|
|
114
|
+
"no findings"
|
|
115
|
+
sys.stdout.write(json.dumps(out, separators=(",", ":")) + "\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
|
@@ -54,13 +54,29 @@ ACCEPTED_BASH_ONLY = {
|
|
|
54
54
|
# listing. Native on both sides for the same reason as the pair above.
|
|
55
55
|
"cmd_cr_overlap",
|
|
56
56
|
# analyze-plan gap routing: one pass over a JSON list and two string tests. PowerShell
|
|
57
|
-
# parses the list natively
|
|
57
|
+
# parses the list natively. (plan-gate-stamp / plan-gate-check are no longer beside it:
|
|
58
|
+
# their rule moved into python/aw-plangate.py, which both shells invoke.)
|
|
58
59
|
"cmd_plan_gaps",
|
|
59
60
|
# Header stamping and wave discovery are a few lines of regex over a markdown header.
|
|
60
61
|
# PowerShell does that natively at a fraction of the cost of spawning python, and both
|
|
61
62
|
# sides are short enough to review as a pair — which is the bar for this list.
|
|
62
63
|
"_stamp_header",
|
|
63
64
|
"_max_open_wave",
|
|
65
|
+
# Reading ONE `**Name**:` header out of a markdown file: the read half of _stamp_header
|
|
66
|
+
# above, and native on the PowerShell side as Get-MdHeader for the same reason. 11 lines
|
|
67
|
+
# against 8.
|
|
68
|
+
"_read_header",
|
|
69
|
+
# Writing .appweaver/feature.json and echoing what it now points at — a JSON write and a
|
|
70
|
+
# path join. PowerShell does both natively rather than spawning python to write six keys.
|
|
71
|
+
"cmd_set_feature",
|
|
72
|
+
# The pre-merge snapshot: copy a change's documents into changes/<id>/pre-merge/ and
|
|
73
|
+
# count them. Directory walk plus file copy, which is native territory in both shells.
|
|
74
|
+
"cmd_cr_snapshot",
|
|
75
|
+
# The two merge-fence readers. Their hard questions — has this change merged, what is
|
|
76
|
+
# ordered against it — were moved into python/aw-crstate.py, which BOTH shells call, so
|
|
77
|
+
# what is dual-authored here is argument parsing and JSON assembly around a shared core.
|
|
78
|
+
"cmd_cr_merge_check",
|
|
79
|
+
"cmd_cr_recall_check",
|
|
64
80
|
"cmd_checklist_read",
|
|
65
81
|
"cmd_checklist_apply",
|
|
66
82
|
"cmd_compose_constitution",
|
|
@@ -297,6 +297,29 @@ def norm_name(s):
|
|
|
297
297
|
return re.sub(r"\s+", " ", s).strip().lower()
|
|
298
298
|
|
|
299
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
|
+
|
|
300
323
|
def load_reports(raw_list):
|
|
301
324
|
"""{normalised name: (status, reason)} — later reports win. Unreadable input counts as
|
|
302
325
|
no report, which leaves every report-only element unconfirmed."""
|
|
@@ -337,6 +360,13 @@ def main():
|
|
|
337
360
|
ap.add_argument("--source-path", default="")
|
|
338
361
|
ap.add_argument("--test-path", default="")
|
|
339
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.")
|
|
340
370
|
ap.add_argument("--list-only", action="store_true")
|
|
341
371
|
args = ap.parse_args()
|
|
342
372
|
|
|
@@ -346,7 +376,8 @@ def main():
|
|
|
346
376
|
"test_path": args.test_path}
|
|
347
377
|
|
|
348
378
|
if not os.path.isfile(plan):
|
|
349
|
-
base.update({"ok": False, "
|
|
379
|
+
base.update({"ok": False, "outcome": "no-plan",
|
|
380
|
+
"plan_exists": False, "mode": args.mode, "total": 0,
|
|
350
381
|
"names": [], "names_csv": "", "names_text": "(the plan does not exist)",
|
|
351
382
|
"elements": [], "gaps": [], "gap_names": [], "gap_names_csv": "",
|
|
352
383
|
"gap_count": 1,
|
|
@@ -360,15 +391,35 @@ def main():
|
|
|
360
391
|
"delta" if any(l.strip().upper().startswith("## ADDED") for l in lines) else "bootstrap")
|
|
361
392
|
elements = extract(lines, args.kind, mode)
|
|
362
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)
|
|
363
406
|
base.update({"plan_exists": True, "mode": mode, "total": len(elements),
|
|
364
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",
|
|
365
415
|
# For a prompt: never empty, so the sentence around it still reads.
|
|
366
416
|
"names_text": ", ".join(names) if names else
|
|
367
417
|
"(none could be read from the plan — report each element you realise by "
|
|
368
418
|
"the name the plan gives it)"})
|
|
369
419
|
|
|
370
420
|
if args.list_only:
|
|
371
|
-
base.update({"ok": True, "
|
|
421
|
+
base.update({"ok": True, "outcome": "listed",
|
|
422
|
+
"message": "%d plan element(s) listed" % len(elements)})
|
|
372
423
|
emit(base)
|
|
373
424
|
return
|
|
374
425
|
|
|
@@ -380,6 +431,15 @@ def main():
|
|
|
380
431
|
hits = scan(repo, norm_path(args.source_path), args.test_path, probe)
|
|
381
432
|
reports, readable = load_reports(args.reported)
|
|
382
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
|
+
|
|
383
443
|
rows, gaps, gap_names = [], [], []
|
|
384
444
|
for name, ekind, is_checked in elements:
|
|
385
445
|
files = hits.get(probe_key(name, ekind), []) if is_checked else []
|
|
@@ -390,7 +450,7 @@ def main():
|
|
|
390
450
|
why.append("no code under %s names it" % (args.source_path or "the repo"))
|
|
391
451
|
if status and not done:
|
|
392
452
|
why.append("reported %s%s" % (status, (": " + reason) if reason else ""))
|
|
393
|
-
elif not status and not is_checked:
|
|
453
|
+
elif not status and not is_checked and not code_only:
|
|
394
454
|
why.append("not reported done")
|
|
395
455
|
rows.append({"element": name, "kind": ekind, "checked": is_checked,
|
|
396
456
|
"found": bool(files) if is_checked else None, "files": files,
|
|
@@ -402,16 +462,36 @@ def main():
|
|
|
402
462
|
total_checked = len(checked)
|
|
403
463
|
found = sum(1 for r in rows if r["checked"] and r["found"])
|
|
404
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.
|
|
405
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":
|
|
406
480
|
msg = ("no plan element could be read from %s — its layout does not follow the "
|
|
407
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"))
|
|
408
486
|
elif ok:
|
|
409
487
|
msg = "all %d plan element(s) realised (%d found in code, %d reported done)" % (
|
|
410
488
|
len(elements), found, len(elements) - total_checked)
|
|
411
489
|
else:
|
|
412
490
|
msg = "%d of %d plan element(s) not realised: %s" % (
|
|
413
491
|
len(gaps), len(elements), "; ".join(gaps))
|
|
414
|
-
base.update({"ok": ok, "
|
|
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,
|
|
415
495
|
"found_on_disk": found, "elements": rows, "gaps": gaps,
|
|
416
496
|
"gap_names": gap_names, "gap_names_csv": ", ".join(gap_names),
|
|
417
497
|
"gap_count": len(gaps),
|