aw-spec-cli 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +22 -1
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +6 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +144 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +78 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +283 -25
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +202 -40
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +557 -57
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +101 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +141 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +135 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +772 -216
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +708 -209
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +319 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +19 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +503 -0
- package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
- package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +257 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
- package/assets/.appweaver/stack-profiles/angular.json +26 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +39 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +20 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +27 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/.appweaver/templates/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +10 -6
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +4 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +3 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +5 -3
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +5 -1
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +13 -5
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +2 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +6 -2
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +114 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +3 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +10 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +8 -1
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/.state/smoke.state.json +0 -32
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
# aw-xref.ps1 list --aggregate Y [--root DIR] [--bundle knowledge]
|
|
9
9
|
# aw-xref.ps1 concept-upsert --aggregate X [--source-files a,b,c] [--description D] [--origin ...] [--root DIR]
|
|
10
10
|
# aw-xref.ps1 concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR]
|
|
11
|
-
# aw-xref.ps1 concept-paths --aggregate X | --feature-dir <dir> [--
|
|
11
|
+
# aw-xref.ps1 concept-paths --aggregate X | --feature-dir <dir> [--under <subtree>]...
|
|
12
|
+
# [--root DIR] [--bundle knowledge]
|
|
13
|
+
# (--under keeps only paths inside that subtree, so a caller
|
|
14
|
+
# generating ONE lane is not handed another lane's files)
|
|
12
15
|
# aw-xref.ps1 feature-aggregates --feature-dir <dir> [--root DIR]
|
|
13
16
|
# (the aggregate(s) a feature acts on: its spec's Acts-on line,
|
|
14
17
|
# then the boundary map, then the feature name itself)
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-crstate — ONE definition of "this change has been merged", for every reader of it.
|
|
4
|
+
|
|
5
|
+
Three places ask the question and each answered it differently: cr-merge-check's
|
|
6
|
+
merged-open fence, cr-recall-check's already-merged guard, and close-delta through it.
|
|
7
|
+
All three tested `cr_id in spec.md` — the id ANYWHERE in the file, including quoted in a
|
|
8
|
+
rationale, and matching a prefix (`CR-001` inside `CR-0011`). A guard that fires on prose
|
|
9
|
+
is a guard nobody can trust, and three copies of it drift.
|
|
10
|
+
|
|
11
|
+
THE EVIDENCE IS A CHANGE HISTORY ROW. finalize-delta appends one when it merges, carrying
|
|
12
|
+
the change's id in the row's CR cell. So a change is merged when its id is a CELL of such a
|
|
13
|
+
row — matched whole, in a table row, never as a substring of a paragraph.
|
|
14
|
+
|
|
15
|
+
WHERE IT LOOKS. `spec.md` is per feature, but `domain-model.md` is shared by every feature
|
|
16
|
+
in the bounded context: a sibling feature's merge moves a master THIS delta was authored
|
|
17
|
+
against and leaves nothing in this feature's spec. So the scan covers the context's model
|
|
18
|
+
and every feature's spec, and reports which document carried the evidence.
|
|
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
|
+
|
|
29
|
+
Importable (the bash payloads load it by path) and a CLI (the PowerShell twins call it), so
|
|
30
|
+
both shells answer from the same code.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
aw-crstate.py merged --cr <id> --doc <path> [--doc <path>…]
|
|
34
|
+
-> {"merged": bool, "evidence": "<the row>", "document": "<path>"}
|
|
35
|
+
aw-crstate.py merged-open --cr <id> --feature-dir <p> --context-dir <p>
|
|
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": "…"}
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import glob
|
|
48
|
+
import json
|
|
49
|
+
import os
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
CR_RX = re.compile(r"CR-\d{8}-\d{6}-[0-9A-Za-z]+")
|
|
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
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read(path):
|
|
65
|
+
try:
|
|
66
|
+
return open(path, encoding="utf-8").read()
|
|
67
|
+
except Exception:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cells(line):
|
|
72
|
+
s = line.strip()
|
|
73
|
+
if not s.startswith("|"):
|
|
74
|
+
return []
|
|
75
|
+
return [c.strip() for c in s.strip("|").split("|")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def merged_row(text, cr_id):
|
|
79
|
+
"""The Change History row whose CR cell IS this change, or "" when there is none.
|
|
80
|
+
|
|
81
|
+
The cell may carry the bare id or the id with a link or a note beside it, so the test is
|
|
82
|
+
"the cell contains this id as a whole token" — not "the row mentions it", which is what
|
|
83
|
+
made a cited id in prose read as a merge.
|
|
84
|
+
"""
|
|
85
|
+
if not text or not cr_id:
|
|
86
|
+
return ""
|
|
87
|
+
for line in text.split("\n"):
|
|
88
|
+
cells = _cells(line)
|
|
89
|
+
if len(cells) < 2 or set("".join(cells)) <= set("-: "):
|
|
90
|
+
continue
|
|
91
|
+
for cell in cells[1:]: # never the leading Version cell
|
|
92
|
+
if cr_id in CR_RX.findall(cell):
|
|
93
|
+
return line.strip()
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merged_in(paths, cr_id):
|
|
98
|
+
"""(row, document) for the first document whose Change History records this change."""
|
|
99
|
+
for p in paths:
|
|
100
|
+
row = merged_row(read(p), cr_id)
|
|
101
|
+
if row:
|
|
102
|
+
return row, p
|
|
103
|
+
return "", ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def aggregates(text):
|
|
107
|
+
"""The `**Aggregates touched**:` header — the list finalize-delta opens sections for."""
|
|
108
|
+
m = re.search(r"^[ \t]*(?:[-*][ \t]+)?\*\*Aggregates touched(?:\*\*:|:\*\*)[ \t]*(.*)$",
|
|
109
|
+
text or "", re.M)
|
|
110
|
+
if not m:
|
|
111
|
+
return set()
|
|
112
|
+
value = re.sub(r"<!--.*?(?:-->|$)", "", m.group(1)).strip()
|
|
113
|
+
names = TICK_RX.findall(value)
|
|
114
|
+
if not names:
|
|
115
|
+
parts = [p.strip() for p in re.split(r"[,;/&]|\band\b", value) if p.strip()]
|
|
116
|
+
names = [re.match(r"[A-Za-z_][A-Za-z0-9_]*", n).group(0)
|
|
117
|
+
for n in parts if re.match(r"[A-Za-z_][A-Za-z0-9_]*", n)]
|
|
118
|
+
return {n for n in names if not n.startswith(("<", "[")) and not re.match(r"(?i)none\b", n)}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def model_delta_of(ctx, cr_id):
|
|
122
|
+
"""A change's model half: beside the master while open, in its archive once closed."""
|
|
123
|
+
p = os.path.join(ctx, "domain-model.%s.delta.md" % cr_id)
|
|
124
|
+
if os.path.isfile(p):
|
|
125
|
+
return read(p)
|
|
126
|
+
for feat in sorted(os.listdir(ctx)) if os.path.isdir(ctx) else []:
|
|
127
|
+
a = os.path.join(ctx, feat, "changes", "archive", cr_id, "domain-model.delta.md")
|
|
128
|
+
if os.path.isfile(a):
|
|
129
|
+
return read(a)
|
|
130
|
+
return ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def open_changes(ctx):
|
|
134
|
+
"""{cr_id: feature} for every change OPEN in the context — the directory is the
|
|
135
|
+
authority, and a model delta with no change directory still counts."""
|
|
136
|
+
found = {}
|
|
137
|
+
if not os.path.isdir(ctx):
|
|
138
|
+
return found
|
|
139
|
+
for feat in sorted(os.listdir(ctx)):
|
|
140
|
+
ch = os.path.join(ctx, feat, "changes")
|
|
141
|
+
if not os.path.isdir(ch):
|
|
142
|
+
continue
|
|
143
|
+
for name in sorted(os.listdir(ch)):
|
|
144
|
+
if name != "archive" and os.path.isdir(os.path.join(ch, name)):
|
|
145
|
+
found.setdefault(name, feat)
|
|
146
|
+
for f in sorted(os.listdir(ctx)):
|
|
147
|
+
m = re.match(r"^domain-model\.(.+)\.delta\.md$", f)
|
|
148
|
+
if m:
|
|
149
|
+
found.setdefault(m.group(1), "")
|
|
150
|
+
return found
|
|
151
|
+
|
|
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
|
+
|
|
238
|
+
def merged_open(ctx, feature_dir, cr_id):
|
|
239
|
+
"""Changes that are MERGED into a master and still OPEN — the state in which the masters
|
|
240
|
+
carry a change the version does not yet reflect.
|
|
241
|
+
|
|
242
|
+
A merge in THIS feature moved this delta's own spec, so it blocks whatever it touched.
|
|
243
|
+
A SIBLING feature's merge moved the shared context model, so it blocks only where it
|
|
244
|
+
touched an aggregate this delta also names; `shared` carries that answer and a reader
|
|
245
|
+
that cannot read a delta's aggregates is told so (`unknown`) rather than cleared.
|
|
246
|
+
"""
|
|
247
|
+
this_feat = os.path.basename(os.path.normpath(feature_dir))
|
|
248
|
+
mine = aggregates(model_delta_of(ctx, cr_id))
|
|
249
|
+
docs = {}
|
|
250
|
+
model_master = os.path.join(ctx, "domain-model.md")
|
|
251
|
+
if os.path.isfile(model_master):
|
|
252
|
+
docs[model_master] = ""
|
|
253
|
+
for spec in sorted(glob.glob(os.path.join(glob.escape(ctx), "*", "spec.md"))):
|
|
254
|
+
docs[spec] = os.path.basename(os.path.dirname(spec))
|
|
255
|
+
|
|
256
|
+
out = []
|
|
257
|
+
for other, feat in sorted(open_changes(ctx).items()):
|
|
258
|
+
if other == cr_id:
|
|
259
|
+
continue
|
|
260
|
+
row, doc = merged_in(list(docs), other)
|
|
261
|
+
if not row:
|
|
262
|
+
continue
|
|
263
|
+
theirs = aggregates(model_delta_of(ctx, other))
|
|
264
|
+
same_feature = (feat or docs.get(doc, "")) == this_feat
|
|
265
|
+
shared = sorted(mine & theirs)
|
|
266
|
+
out.append({
|
|
267
|
+
"cr_id": other, "feature": feat or docs.get(doc, ""),
|
|
268
|
+
"document": os.path.relpath(doc, ctx), "evidence": row,
|
|
269
|
+
"aggregates": sorted(theirs), "shared": shared,
|
|
270
|
+
"unknown": not theirs, "same_feature": same_feature,
|
|
271
|
+
# A sibling's merge blocks only where it moved something this delta also names.
|
|
272
|
+
"blocking": same_feature or bool(shared) or not theirs,
|
|
273
|
+
})
|
|
274
|
+
return out
|
|
275
|
+
|
|
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
|
+
|
|
284
|
+
def main():
|
|
285
|
+
ap = argparse.ArgumentParser(description="one definition of a merged change")
|
|
286
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
287
|
+
m = sub.add_parser("merged")
|
|
288
|
+
m.add_argument("--cr", required=True)
|
|
289
|
+
m.add_argument("--doc", action="append", default=[])
|
|
290
|
+
o = sub.add_parser("merged-open")
|
|
291
|
+
o.add_argument("--cr", required=True)
|
|
292
|
+
o.add_argument("--feature-dir", required=True)
|
|
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")
|
|
300
|
+
args = ap.parse_args()
|
|
301
|
+
|
|
302
|
+
if args.cmd == "merged":
|
|
303
|
+
row, doc = merged_in(args.doc, args.cr)
|
|
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)
|
|
308
|
+
else:
|
|
309
|
+
rows = merged_open(args.context_dir, args.feature_dir, args.cr)
|
|
310
|
+
blocking = [r for r in rows if r["blocking"]]
|
|
311
|
+
out = {"merged_open": rows, "blocking": blocking,
|
|
312
|
+
"blocking_count": len(blocking),
|
|
313
|
+
"blocking_ids": ", ".join(r["cr_id"] for r in blocking),
|
|
314
|
+
"blocking_text": merged_open_text(blocking)}
|
|
315
|
+
sys.stdout.write(json.dumps(out, separators=(",", ":")) + "\n")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-foundation — the part of scaffolding that depends on the LANE MODEL.
|
|
4
|
+
|
|
5
|
+
A foundation pack is a tree of files and an unpacker that writes it under one output
|
|
6
|
+
directory. That output directory is now a LANE'S SOURCE PATH, so everything a pack
|
|
7
|
+
carries lands inside the subtree its lane owns, and nothing a scaffold produces can
|
|
8
|
+
appear outside a declared lane.
|
|
9
|
+
|
|
10
|
+
A lane owns TWO subtrees, though — its source path and its test path — and for most
|
|
11
|
+
backend stacks they are siblings rather than nested: .NET keeps `tests/` beside `src/`,
|
|
12
|
+
not inside it. A pack that carries its own test project therefore has one directory
|
|
13
|
+
that does NOT belong under the source path, and unpacking it verbatim would bury the
|
|
14
|
+
tests at `src/tests/`, where the lane does not own them, `dotnet test` does not look for
|
|
15
|
+
them, and every later phase that resolves the lane's test path finds nothing.
|
|
16
|
+
|
|
17
|
+
So the test tree is LIFTED: whatever the pack placed at `<source path>/<test dir>` is
|
|
18
|
+
merged into the lane's declared test path. The directory name comes from the lane's own
|
|
19
|
+
test path rather than from a list here, which is what makes a lane with a custom
|
|
20
|
+
`test_root` work and a stack whose tests live inside its source a no-op — the two paths
|
|
21
|
+
are then the same subtree and there is nothing to lift.
|
|
22
|
+
|
|
23
|
+
Called as a CLI by both the bash and the PowerShell actions, so the two answer from one
|
|
24
|
+
implementation rather than from two that have to be kept in step.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
aw-foundation.py redirect-tests --out <lane source path> --test-path <lane test path>
|
|
28
|
+
[--repo <root>] [--force]
|
|
29
|
+
-> {"moved": n, "conflicts": [...], "from": "...", "to": "...", "message": "..."}
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def norm(path):
|
|
40
|
+
"""A repo-relative directory, '/'-separated and '/'-terminated; "" when absent.
|
|
41
|
+
|
|
42
|
+
Mirrors aw-stack's `norm_root` for the shapes this reads, so the paths compared here
|
|
43
|
+
are the same strings the lane model stores. "./" — the repo root as a source path —
|
|
44
|
+
normalises to "" so that joining it yields the repo itself.
|
|
45
|
+
"""
|
|
46
|
+
p = (path or "").replace("\\", "/").strip()
|
|
47
|
+
while p.startswith("./"):
|
|
48
|
+
p = p[2:]
|
|
49
|
+
p = p.lstrip("/")
|
|
50
|
+
if p in ("", "."):
|
|
51
|
+
return ""
|
|
52
|
+
return p if p.endswith("/") else p + "/"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def emit(payload):
|
|
56
|
+
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def merge_move(src, dest, force):
|
|
60
|
+
"""Move every file under `src` to the matching place under `dest`, keeping what is
|
|
61
|
+
already there unless `force`. Returns (moved, conflicts) as repo-relative-ish names.
|
|
62
|
+
|
|
63
|
+
A merge rather than a rename because the destination legitimately already exists: a
|
|
64
|
+
second foundation unpacked into the same lane, or a reference pack following the
|
|
65
|
+
primary one, both add to a test tree rather than replace it.
|
|
66
|
+
"""
|
|
67
|
+
moved, conflicts = [], []
|
|
68
|
+
for dirpath, _dirnames, filenames in os.walk(src):
|
|
69
|
+
for name in sorted(filenames):
|
|
70
|
+
s = os.path.join(dirpath, name)
|
|
71
|
+
rel = os.path.relpath(s, src).replace("\\", "/")
|
|
72
|
+
d = os.path.join(dest, rel)
|
|
73
|
+
if os.path.exists(d):
|
|
74
|
+
if not force:
|
|
75
|
+
conflicts.append(rel)
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
os.remove(d)
|
|
79
|
+
except OSError:
|
|
80
|
+
conflicts.append(rel)
|
|
81
|
+
continue
|
|
82
|
+
os.makedirs(os.path.dirname(d) or ".", exist_ok=True)
|
|
83
|
+
shutil.move(s, d)
|
|
84
|
+
moved.append(rel)
|
|
85
|
+
# Leave nothing behind but the directories a conflict kept a file in.
|
|
86
|
+
for dirpath, _dirnames, _filenames in sorted(os.walk(src), reverse=True):
|
|
87
|
+
try:
|
|
88
|
+
os.rmdir(dirpath)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass
|
|
91
|
+
return moved, conflicts
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def redirect_tests(repo, out, test_path, force=False):
|
|
95
|
+
out_rel, test_rel = norm(out), norm(test_path)
|
|
96
|
+
|
|
97
|
+
def skip(why):
|
|
98
|
+
return {"moved": 0, "conflicts": [], "from": "", "to": test_rel,
|
|
99
|
+
"redirected": False, "message": why}
|
|
100
|
+
|
|
101
|
+
if not test_rel:
|
|
102
|
+
return skip("the lane declares no test path, so nothing is lifted")
|
|
103
|
+
# Nested or identical paths mean the pack's own layout is already the lane's: the
|
|
104
|
+
# tests it carries are inside the subtree they belong in, and moving them would
|
|
105
|
+
# take them out of it.
|
|
106
|
+
if out_rel and (test_rel == out_rel or test_rel.startswith(out_rel)):
|
|
107
|
+
return skip("this lane keeps its tests inside its source path (%s), "
|
|
108
|
+
"so the pack's layout is already right" % out_rel)
|
|
109
|
+
|
|
110
|
+
# WHAT the pack would have called its test tree, had it placed one: the last segment
|
|
111
|
+
# of the path the lane keeps tests in. Reading it from the lane rather than from a
|
|
112
|
+
# fixed list is what makes a custom `test_root` work.
|
|
113
|
+
seg = test_rel.rstrip("/").rsplit("/", 1)[-1]
|
|
114
|
+
src = os.path.join(repo, out_rel, seg) if out_rel else os.path.join(repo, seg)
|
|
115
|
+
dest = os.path.join(repo, test_rel)
|
|
116
|
+
|
|
117
|
+
if os.path.abspath(src) == os.path.abspath(dest):
|
|
118
|
+
return skip("the pack already writes its tests to %s" % test_rel)
|
|
119
|
+
if not os.path.isdir(src):
|
|
120
|
+
return skip("this pack carries no %s/ directory, so there are no tests to lift" % seg)
|
|
121
|
+
|
|
122
|
+
os.makedirs(dest, exist_ok=True)
|
|
123
|
+
moved, conflicts = merge_move(src, dest, force)
|
|
124
|
+
src_rel = "%s%s/" % (out_rel, seg)
|
|
125
|
+
if conflicts:
|
|
126
|
+
msg = ("lifted %d test file(s) from %s to the lane's test path %s; %d left in place "
|
|
127
|
+
"because %s already holds them" % (len(moved), src_rel, test_rel,
|
|
128
|
+
len(conflicts), test_rel))
|
|
129
|
+
else:
|
|
130
|
+
msg = ("lifted %d test file(s) from %s to the lane's test path %s"
|
|
131
|
+
% (len(moved), src_rel, test_rel))
|
|
132
|
+
return {"moved": len(moved), "conflicts": conflicts, "from": src_rel, "to": test_rel,
|
|
133
|
+
"redirected": bool(moved), "message": msg}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
ap = argparse.ArgumentParser(description="lane-aware scaffolding helpers")
|
|
138
|
+
ap.add_argument("--repo", default=os.getcwd())
|
|
139
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
140
|
+
r = sub.add_parser("redirect-tests")
|
|
141
|
+
r.add_argument("--out", required=True, help="the lane's source path, where the pack was unpacked")
|
|
142
|
+
r.add_argument("--test-path", default="", help="the lane's declared test path")
|
|
143
|
+
r.add_argument("--force", action="store_true")
|
|
144
|
+
args = ap.parse_args()
|
|
145
|
+
|
|
146
|
+
if args.cmd == "redirect-tests":
|
|
147
|
+
emit(redirect_tests(args.repo, args.out, args.test_path, args.force))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
@@ -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()
|
|
@@ -53,11 +53,30 @@ 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. (plan-gate-stamp / plan-gate-check are no longer beside it:
|
|
58
|
+
# their rule moved into python/aw-plangate.py, which both shells invoke.)
|
|
59
|
+
"cmd_plan_gaps",
|
|
56
60
|
# Header stamping and wave discovery are a few lines of regex over a markdown header.
|
|
57
61
|
# PowerShell does that natively at a fraction of the cost of spawning python, and both
|
|
58
62
|
# sides are short enough to review as a pair — which is the bar for this list.
|
|
59
63
|
"_stamp_header",
|
|
60
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",
|
|
61
80
|
"cmd_checklist_read",
|
|
62
81
|
"cmd_checklist_apply",
|
|
63
82
|
"cmd_compose_constitution",
|