aw-spec-cli 0.1.15 → 0.1.16
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/orchestrator/modules/spec-state-gate.orch.yaml +87 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +83 -19
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +88 -19
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +131 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +126 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +28 -25
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +48 -15
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +97 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +216 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +108 -27
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +515 -30
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +504 -28
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +158 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +205 -48
- package/assets/.appweaver/templates/story-template.md +37 -6
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""aw-payload-parity.py — prove the two shells run the SAME python.
|
|
3
|
+
|
|
4
|
+
Most of aw-spec-actions is one python program embedded twice: as a `<<'PY'` heredoc in
|
|
5
|
+
aw-spec-actions.sh and as a `$PyName = @'…'@` here-string in aw-spec-actions.ps1. The
|
|
6
|
+
contract is that each pair is BYTE-IDENTICAL, and nothing checked it — so a fix applied to
|
|
7
|
+
one shell and forgotten in the other is a gate that behaves differently on Windows, in
|
|
8
|
+
silence. Three of the ops with no twin at all are on the forward chain (resolve-hooks, with
|
|
9
|
+
17 call sites across the nine workflows, and checklist-read / checklist-apply, which produce
|
|
10
|
+
analyze-design's and analyze-slice's LIFT-FAILURE findings), and every one of them was read
|
|
11
|
+
in bash when the workflows were reviewed.
|
|
12
|
+
|
|
13
|
+
WHAT IT CHECKS
|
|
14
|
+
1. Every bash payload has a byte-identical PowerShell twin.
|
|
15
|
+
2. Every PowerShell payload has a byte-identical bash twin.
|
|
16
|
+
Payloads with no twin are listed, not counted against a hard-coded number: the split is
|
|
17
|
+
whatever the files say today, and pinning it would need editing on every legitimate change.
|
|
18
|
+
|
|
19
|
+
DUAL-AUTHORED BY DECISION. The ops in ACCEPTED_BASH_ONLY are bash-only on purpose (their
|
|
20
|
+
PowerShell path is written natively rather than shared) and are reported separately, so the
|
|
21
|
+
exit code reacts only to a NEW divergence. Moving one out of that list means it gained a
|
|
22
|
+
shared payload; adding one means the decision was taken to dual-author it — and either way
|
|
23
|
+
the reviewer has to say so in the same commit.
|
|
24
|
+
|
|
25
|
+
exit 0 every payload is paired, or unpaired only by accepted decision
|
|
26
|
+
exit 1 a new unpaired payload, or a pair that has drifted apart
|
|
27
|
+
|
|
28
|
+
Usage: aw-payload-parity.py [--root DIR] [--verbose]
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import hashlib
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
SH_REL = ".appweaver/scripts/bash/aw-spec-actions.sh"
|
|
38
|
+
PS_REL = ".appweaver/scripts/powershell/aw-spec-actions.ps1"
|
|
39
|
+
|
|
40
|
+
# Bash payloads with no shared PowerShell twin BY DECISION, not by oversight. Each is
|
|
41
|
+
# implemented natively on the PowerShell side and the two must be reviewed in pairs.
|
|
42
|
+
ACCEPTED_BASH_ONLY = {
|
|
43
|
+
"cmd_resolve_hooks",
|
|
44
|
+
"cmd_list_features",
|
|
45
|
+
"cmd_checklist_read",
|
|
46
|
+
"cmd_checklist_apply",
|
|
47
|
+
"cmd_compose_constitution",
|
|
48
|
+
"cmd_constitution_verify",
|
|
49
|
+
"cmd_component_verify",
|
|
50
|
+
"cmd_cr_archive",
|
|
51
|
+
"cmd_version_stamp",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def bash_payloads(text):
|
|
56
|
+
"""[(owning function, payload, first line, last line)] in file order."""
|
|
57
|
+
out, owner, lines, i = [], "(top level)", text.split("\n"), 0
|
|
58
|
+
while i < len(lines):
|
|
59
|
+
m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\(\)\s*\{", lines[i])
|
|
60
|
+
if m:
|
|
61
|
+
owner = m.group(1)
|
|
62
|
+
if re.search(r"<<'PY'\s*$", lines[i]):
|
|
63
|
+
j, body = i + 1, []
|
|
64
|
+
while j < len(lines) and lines[j] != "PY":
|
|
65
|
+
body.append(lines[j])
|
|
66
|
+
j += 1
|
|
67
|
+
out.append((owner, "\n".join(body), i + 2, j))
|
|
68
|
+
i = j
|
|
69
|
+
i += 1
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ps_payloads(text):
|
|
74
|
+
"""[(variable name, payload, first line, last line)] in file order."""
|
|
75
|
+
out, lines, i = [], text.split("\n"), 0
|
|
76
|
+
while i < len(lines):
|
|
77
|
+
m = re.match(r"^\$(Py[A-Za-z0-9_]*)\s*=\s*@'\s*$", lines[i])
|
|
78
|
+
if m:
|
|
79
|
+
j, body = i + 1, []
|
|
80
|
+
while j < len(lines) and lines[j] != "'@":
|
|
81
|
+
body.append(lines[j])
|
|
82
|
+
j += 1
|
|
83
|
+
out.append((m.group(1), "\n".join(body), i + 2, j))
|
|
84
|
+
i = j
|
|
85
|
+
i += 1
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def digest(s):
|
|
90
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
ap = argparse.ArgumentParser(description="check bash/PowerShell python-payload parity")
|
|
95
|
+
ap.add_argument("--root", default="", help="kit root (default: this script's kit)")
|
|
96
|
+
ap.add_argument("--verbose", action="store_true", help="list every matched pair too")
|
|
97
|
+
args = ap.parse_args()
|
|
98
|
+
|
|
99
|
+
root = args.root or os.path.normpath(
|
|
100
|
+
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
|
|
101
|
+
sh_path, ps_path = os.path.join(root, SH_REL), os.path.join(root, PS_REL)
|
|
102
|
+
for p in (sh_path, ps_path):
|
|
103
|
+
if not os.path.isfile(p):
|
|
104
|
+
print("aw-payload-parity: not found: %s" % p, file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
sh = bash_payloads(open(sh_path, encoding="utf-8").read())
|
|
108
|
+
ps = ps_payloads(open(ps_path, encoding="utf-8").read())
|
|
109
|
+
|
|
110
|
+
ps_by_hash = {}
|
|
111
|
+
for name, body, a, b in ps:
|
|
112
|
+
ps_by_hash.setdefault(digest(body), []).append((name, a, b))
|
|
113
|
+
sh_by_hash = {}
|
|
114
|
+
for owner, body, a, b in sh:
|
|
115
|
+
sh_by_hash.setdefault(digest(body), []).append((owner, a, b))
|
|
116
|
+
|
|
117
|
+
paired, accepted, unpaired_sh, unpaired_ps = [], [], [], []
|
|
118
|
+
for owner, body, a, b in sh:
|
|
119
|
+
twin = ps_by_hash.get(digest(body))
|
|
120
|
+
if twin:
|
|
121
|
+
paired.append((owner, twin[0][0]))
|
|
122
|
+
elif owner in ACCEPTED_BASH_ONLY:
|
|
123
|
+
accepted.append((owner, a, b))
|
|
124
|
+
else:
|
|
125
|
+
unpaired_sh.append((owner, a, b))
|
|
126
|
+
for name, body, a, b in ps:
|
|
127
|
+
if not sh_by_hash.get(digest(body)):
|
|
128
|
+
unpaired_ps.append((name, a, b))
|
|
129
|
+
|
|
130
|
+
print("payload parity: %d bash, %d powershell, %d byte-identical pair(s)"
|
|
131
|
+
% (len(sh), len(ps), len(paired)))
|
|
132
|
+
if args.verbose:
|
|
133
|
+
for owner, name in paired:
|
|
134
|
+
print(" ok %-34s == $%s" % (owner, name))
|
|
135
|
+
if accepted:
|
|
136
|
+
print("\ndual-authored by decision (%d) — PowerShell implements these natively, so a "
|
|
137
|
+
"change to one must be mirrored by hand:" % len(accepted))
|
|
138
|
+
for owner, a, b in accepted:
|
|
139
|
+
print(" -- %-34s %s:%d-%d" % (owner, os.path.basename(sh_path), a, b))
|
|
140
|
+
|
|
141
|
+
failed = bool(unpaired_sh or unpaired_ps)
|
|
142
|
+
if unpaired_sh:
|
|
143
|
+
print("\nFAIL — bash payloads with no byte-identical PowerShell twin:")
|
|
144
|
+
for owner, a, b in unpaired_sh:
|
|
145
|
+
print(" !! %-34s %s:%d-%d" % (owner, os.path.basename(sh_path), a, b))
|
|
146
|
+
if unpaired_ps:
|
|
147
|
+
print("\nFAIL — PowerShell payloads with no byte-identical bash twin:")
|
|
148
|
+
for name, a, b in unpaired_ps:
|
|
149
|
+
print(" !! $%-33s %s:%d-%d" % (name, os.path.basename(ps_path), a, b))
|
|
150
|
+
if failed:
|
|
151
|
+
print("\nA payload changed in one shell and not the other, or a new op shipped to one "
|
|
152
|
+
"shell only. Copy the payload verbatim into its twin — they are byte-identical "
|
|
153
|
+
"by contract — or, if the op is deliberately dual-authored, add its function to "
|
|
154
|
+
"ACCEPTED_BASH_ONLY in this file and say why in the commit.")
|
|
155
|
+
return 1 if failed else 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
sys.exit(main())
|
|
@@ -97,12 +97,15 @@ Only the standard library is used. Usage: aw-xref.py <sub> [options]
|
|
|
97
97
|
"""
|
|
98
98
|
|
|
99
99
|
import argparse
|
|
100
|
+
import contextlib
|
|
100
101
|
import datetime
|
|
102
|
+
import errno
|
|
101
103
|
import json
|
|
102
104
|
import os
|
|
103
105
|
import re
|
|
104
106
|
import subprocess
|
|
105
107
|
import sys
|
|
108
|
+
import time
|
|
106
109
|
|
|
107
110
|
# ───────────────────────────── canonical shapes ────────────────────────────
|
|
108
111
|
OVERVIEW_HEADING = "## Overview"
|
|
@@ -120,6 +123,17 @@ XREF_COMMENT = (
|
|
|
120
123
|
"Each row written by the using slice's implement/adopt (reconcile-on-write). "
|
|
121
124
|
"Reviewed at CR time. Do not hand-edit. -->"
|
|
122
125
|
)
|
|
126
|
+
ANCHOR_HEADING = "## Requirement Anchors"
|
|
127
|
+
ANCHOR_HEADER = ["Requirement", "Slice", "Lane", "Artefacts", "Recorded"]
|
|
128
|
+
ANCHOR_COMMENT = (
|
|
129
|
+
"<!-- WHICH CODE REALISES WHICH REQUIREMENT — written by implement / implement-ui as "
|
|
130
|
+
"each story generates, from the ids that story exercises and the files that run wrote. "
|
|
131
|
+
"The greenfield counterpart of a brownfield source-map.md: without it every id on a "
|
|
132
|
+
"greenfield feature comes back unanchored, and the trace pass has to read every file to "
|
|
133
|
+
"find out what implements what. Reconcile-on-write, keyed by slice + lane, so "
|
|
134
|
+
"re-implementing a story replaces its rows rather than doubling them. Do not "
|
|
135
|
+
"hand-edit. -->"
|
|
136
|
+
)
|
|
123
137
|
RESERVED = {"index.md", "log.md"}
|
|
124
138
|
|
|
125
139
|
# A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
|
|
@@ -249,10 +263,82 @@ def _read(path):
|
|
|
249
263
|
return ""
|
|
250
264
|
|
|
251
265
|
|
|
266
|
+
# ──────────────────────────── write serialisation ──────────────────────────
|
|
267
|
+
# EVERY CONCEPT WRITE IS A READ-MODIFY-WRITE: read the whole file, splice one section,
|
|
268
|
+
# write it back truncating. None of them was serialised, and the wave model exists so that
|
|
269
|
+
# stories sharing a wave are implemented in parallel — so two implements, or implement and
|
|
270
|
+
# implement-ui (which share a feature's ONE concept), reading before either writes is the
|
|
271
|
+
# ordinary case, and the second write drops the first's rows with nothing to say so. Git
|
|
272
|
+
# catches that across branches and never inside a single working tree.
|
|
273
|
+
#
|
|
274
|
+
# The lock is named for the FILE — <path>.aw-lock, the same convention aw-spec-actions'
|
|
275
|
+
# log-append uses for interfaces.md and adr.md — so every writer of a file contends on one
|
|
276
|
+
# lock whichever process, op or shell it came from. Re-entrant within a process, so an
|
|
277
|
+
# outer read-modify-write can hold it while the _write inside re-acquires.
|
|
278
|
+
_LOCK_TIMEOUT = 30.0
|
|
279
|
+
_HELD = {}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@contextlib.contextmanager
|
|
283
|
+
def _lock(path):
|
|
284
|
+
key = os.path.abspath(path)
|
|
285
|
+
if _HELD.get(key):
|
|
286
|
+
_HELD[key] += 1
|
|
287
|
+
try:
|
|
288
|
+
yield
|
|
289
|
+
finally:
|
|
290
|
+
_HELD[key] -= 1
|
|
291
|
+
return
|
|
292
|
+
parent = os.path.dirname(key)
|
|
293
|
+
if parent:
|
|
294
|
+
os.makedirs(parent, exist_ok=True)
|
|
295
|
+
lp = key + ".aw-lock"
|
|
296
|
+
start = time.time()
|
|
297
|
+
while True:
|
|
298
|
+
try:
|
|
299
|
+
h = os.open(lp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
300
|
+
os.write(h, ("%d\n" % os.getpid()).encode("utf-8"))
|
|
301
|
+
os.close(h)
|
|
302
|
+
break
|
|
303
|
+
except OSError as exc:
|
|
304
|
+
if exc.errno != errno.EEXIST:
|
|
305
|
+
raise
|
|
306
|
+
try:
|
|
307
|
+
age = time.time() - os.path.getmtime(lp)
|
|
308
|
+
except OSError:
|
|
309
|
+
age = 0.0
|
|
310
|
+
# A lock older than the timeout belonged to a run that died holding it.
|
|
311
|
+
# Breaking it is right: the alternative is a concept nobody can write again.
|
|
312
|
+
if age > _LOCK_TIMEOUT:
|
|
313
|
+
try:
|
|
314
|
+
os.unlink(lp)
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
continue
|
|
318
|
+
if time.time() - start > _LOCK_TIMEOUT:
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
"aw-xref: %s has been locked by another run for over %ds. If no other "
|
|
321
|
+
"/appweaver command is running, delete %s and re-run."
|
|
322
|
+
% (os.path.basename(key), int(_LOCK_TIMEOUT), lp))
|
|
323
|
+
time.sleep(0.05)
|
|
324
|
+
_HELD[key] = 1
|
|
325
|
+
try:
|
|
326
|
+
yield
|
|
327
|
+
finally:
|
|
328
|
+
_HELD.pop(key, None)
|
|
329
|
+
try:
|
|
330
|
+
os.unlink(lp)
|
|
331
|
+
except OSError:
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
252
335
|
def _write(path, text):
|
|
253
|
-
os.
|
|
254
|
-
|
|
255
|
-
|
|
336
|
+
parent = os.path.dirname(path)
|
|
337
|
+
if parent:
|
|
338
|
+
os.makedirs(parent, exist_ok=True)
|
|
339
|
+
with _lock(path):
|
|
340
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
341
|
+
fh.write(text)
|
|
256
342
|
|
|
257
343
|
|
|
258
344
|
def _today(arg_date):
|
|
@@ -679,15 +765,41 @@ def _git_head_short(root):
|
|
|
679
765
|
|
|
680
766
|
|
|
681
767
|
def _write_xref_rows(path, rows):
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
768
|
+
# The read is INSIDE the lock: splicing a section into a file another process is
|
|
769
|
+
# rewriting is what loses rows, and taking the lock only for the write would still
|
|
770
|
+
# splice into a copy that had gone stale between the read and the acquisition.
|
|
771
|
+
with _lock(path):
|
|
772
|
+
text = _read(path)
|
|
773
|
+
body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
|
|
774
|
+
_write(path, _replace_section(text, XREF_HEADING, body))
|
|
685
775
|
|
|
686
776
|
|
|
687
777
|
def _write_deps_rows(path, rows):
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
778
|
+
with _lock(path):
|
|
779
|
+
text = _read(path)
|
|
780
|
+
body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
|
|
781
|
+
_write(path, _replace_section(text, DEPS_HEADING, body))
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _read_anchor_rows(path):
|
|
785
|
+
return _table_data_rows(_section_text(_read(path), ANCHOR_HEADING))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _write_anchor_rows(path, slice_id, lane, ids, artefacts, recorded):
|
|
789
|
+
"""Requirement → artefact rows for ONE slice, reconciled on write. Every row this
|
|
790
|
+
slice+lane wrote before is dropped and its current ones re-added, so a story that
|
|
791
|
+
re-implements against a changed design replaces its claims instead of accumulating
|
|
792
|
+
them — the same rule the Cross-References rows follow, for the same reason."""
|
|
793
|
+
with _lock(path):
|
|
794
|
+
keep = [r for r in _read_anchor_rows(path)
|
|
795
|
+
if not (len(r) >= 3 and r[1].strip() == slice_id and r[2].strip() == lane)]
|
|
796
|
+
# Ordered by requirement so a person reading the section can find an id, and two
|
|
797
|
+
# runs writing the same rows produce the same file.
|
|
798
|
+
fresh = [[rid, slice_id, lane, artefacts, recorded] for rid in ids]
|
|
799
|
+
rows = sorted(keep + fresh, key=lambda r: (r[0], r[1] if len(r) > 1 else ""))
|
|
800
|
+
body = _render_section(ANCHOR_HEADING, ANCHOR_COMMENT, ANCHOR_HEADER, rows)
|
|
801
|
+
_write(path, _replace_section(_read(path), ANCHOR_HEADING, body))
|
|
802
|
+
return len(fresh)
|
|
691
803
|
|
|
692
804
|
|
|
693
805
|
def _all_concepts(bundle_abs):
|
|
@@ -836,45 +948,67 @@ def cmd_concept_upsert(args):
|
|
|
836
948
|
_ensure_dir_index(path, aggregate)
|
|
837
949
|
else:
|
|
838
950
|
# Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
|
|
839
|
-
# which holds the Cross-References other slices wrote.
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
951
|
+
# which holds the Cross-References other slices wrote. Read and write under one
|
|
952
|
+
# lock: --merge unions this run's files with what is already recorded, so two
|
|
953
|
+
# parallel stories reading before either writes both union against the same
|
|
954
|
+
# starting list and the second write loses the first's files.
|
|
955
|
+
with _lock(path):
|
|
956
|
+
fm, body = _read_fm(_read(path))
|
|
957
|
+
fm.setdefault("type", "aggregate")
|
|
958
|
+
fm["title"] = fm.get("title") or aggregate
|
|
959
|
+
if source_files:
|
|
960
|
+
if args.merge:
|
|
961
|
+
# INCREMENTAL mode: {{files}} is only what THIS run wrote — one story's
|
|
962
|
+
# slice, or a delta's changed files — so UNION with what the lane already
|
|
963
|
+
# records, never replace. Replacing would drop every file the feature's
|
|
964
|
+
# earlier stories generated the moment a second story implements.
|
|
965
|
+
existing = fm.get(key) if isinstance(fm.get(key), list) else []
|
|
966
|
+
merged = list(existing)
|
|
967
|
+
for s in source_files:
|
|
968
|
+
if s not in merged:
|
|
969
|
+
merged.append(s)
|
|
970
|
+
fm[key] = merged
|
|
971
|
+
else:
|
|
972
|
+
fm[key] = source_files
|
|
973
|
+
# Both keys always present: an absent one reads as "this lane was never
|
|
974
|
+
# implemented" to every consumer, which is a different claim from "empty".
|
|
975
|
+
for k in ("source_files", "ui_source_files"):
|
|
976
|
+
if k not in fm:
|
|
977
|
+
fm[k] = []
|
|
978
|
+
if args.description:
|
|
979
|
+
fm["description"] = args.description
|
|
980
|
+
elif "description" not in fm:
|
|
981
|
+
fm["description"] = f"{aggregate} aggregate."
|
|
982
|
+
fm.setdefault("origin", origin)
|
|
983
|
+
rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
|
|
984
|
+
else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
|
|
985
|
+
_write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
|
|
870
986
|
_ensure_bundle(bundle_abs)
|
|
871
987
|
_ensure_dir_index(path, aggregate)
|
|
872
988
|
|
|
989
|
+
# WHICH REQUIREMENT THIS SLICE'S FILES REALISE. The two halves have always been in this
|
|
990
|
+
# call and were thrown away: the ids the story exercises, and the files the run just
|
|
991
|
+
# generated. Persisted here so a greenfield feature has the anchor index only a
|
|
992
|
+
# brownfield source-map.md carried — without it every id comes back unanchored and the
|
|
993
|
+
# trace pass has to read the whole tree to find out what implements what.
|
|
994
|
+
anchor_ids = sorted({i for i in re.findall(r'\b[A-Z]{2}-\d+', args.ids or "")})
|
|
995
|
+
anchors_written = 0
|
|
996
|
+
if anchor_ids:
|
|
997
|
+
slice_id = (args.slice_id or "").strip() or "(unnamed slice)"
|
|
998
|
+
# The cell is table content: a path holding a pipe would silently split it.
|
|
999
|
+
cell = ", ".join(s.replace("|", r"\|") for s in source_files) or "(no files recorded)"
|
|
1000
|
+
anchors_written = _write_anchor_rows(path, slice_id, lane, anchor_ids, cell,
|
|
1001
|
+
_today(args.date))
|
|
1002
|
+
|
|
873
1003
|
print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
|
|
874
|
-
f"({len(source_files)} {lane} source file(s) into {key}
|
|
1004
|
+
f"({len(source_files)} {lane} source file(s) into {key}"
|
|
1005
|
+
+ (f", {anchors_written} requirement anchor(s)" if anchors_written else "")
|
|
1006
|
+
+ f") — {_rel(path, root)}",
|
|
875
1007
|
file=sys.stderr)
|
|
876
1008
|
_emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
|
|
877
|
-
"lane": lane, "key": key, "source_file_count": len(source_files),
|
|
1009
|
+
"lane": lane, "key": key, "source_file_count": len(source_files),
|
|
1010
|
+
"anchor_ids": anchor_ids, "anchor_ids_csv": ", ".join(anchor_ids),
|
|
1011
|
+
"anchors_written": anchors_written, "root": root})
|
|
878
1012
|
return 0
|
|
879
1013
|
|
|
880
1014
|
|
|
@@ -1039,6 +1173,24 @@ def cmd_concept_coverage(args):
|
|
|
1039
1173
|
ids = [i for i in re.split(r'[,\s]+', (args.ids or "").strip()) if i]
|
|
1040
1174
|
smap = ""
|
|
1041
1175
|
anchors = {}
|
|
1176
|
+
# TWO SOURCES, ONE INDEX. A brownfield feature's anchors come from adopt's
|
|
1177
|
+
# source-map.md; a greenfield feature's come from the concept's own Requirement Anchors
|
|
1178
|
+
# rows, which implement / implement-ui write as each story generates. Reading only the
|
|
1179
|
+
# first meant every id on a greenfield feature came back "not anchored to any artefact"
|
|
1180
|
+
# — advice to design it as new work, on a requirement that already had code.
|
|
1181
|
+
if path:
|
|
1182
|
+
for r in _read_anchor_rows(path):
|
|
1183
|
+
if len(r) < 4:
|
|
1184
|
+
continue
|
|
1185
|
+
rid = r[0].strip()
|
|
1186
|
+
if not re.match(r'^[A-Z]{2}-\d+$', rid):
|
|
1187
|
+
continue
|
|
1188
|
+
for art in (a.strip().replace(r"\|", "|") for a in r[3].split(",")):
|
|
1189
|
+
if art and art != "(no files recorded)":
|
|
1190
|
+
anchors.setdefault(rid, [])
|
|
1191
|
+
if art not in anchors[rid]:
|
|
1192
|
+
anchors[rid].append(art)
|
|
1193
|
+
concept_anchored = bool(anchors)
|
|
1042
1194
|
if args.feature_dir:
|
|
1043
1195
|
cand = os.path.join(args.feature_dir, "source-map.md")
|
|
1044
1196
|
if os.path.isfile(cand):
|
|
@@ -1110,13 +1262,15 @@ def cmd_concept_coverage(args):
|
|
|
1110
1262
|
% (", ".join(users) or "nothing recorded"))
|
|
1111
1263
|
if ids:
|
|
1112
1264
|
lines.append("")
|
|
1113
|
-
if not smap:
|
|
1114
|
-
lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md
|
|
1115
|
-
"
|
|
1116
|
-
"
|
|
1117
|
-
"that no code implements it.")
|
|
1265
|
+
if not smap and not concept_anchored:
|
|
1266
|
+
lines.append("REQUIREMENT ANCHORS: none available — this feature has no source-map.md "
|
|
1267
|
+
"and no slice has recorded one in the concept, so no id can be traced to "
|
|
1268
|
+
"a file. Treat the files above as the whole of what exists and read them; "
|
|
1269
|
+
"do NOT read an id's absence here as proof that no code implements it.")
|
|
1118
1270
|
else:
|
|
1119
|
-
|
|
1271
|
+
src = " + ".join(filter(None, [smap, "the concept's Requirement Anchors"
|
|
1272
|
+
if concept_anchored else ""]))
|
|
1273
|
+
lines.append("REQUIREMENT ANCHORS (%s):" % src)
|
|
1120
1274
|
for i in anchored:
|
|
1121
1275
|
lines.append(" %-10s %s" % (i, ", ".join(anchors[i][:6])
|
|
1122
1276
|
+ (" …" if len(anchors[i]) > 6 else "")))
|
|
@@ -1490,6 +1644,9 @@ def main(argv):
|
|
|
1490
1644
|
c.add_argument("--source-files", default="")
|
|
1491
1645
|
c.add_argument("--merge", action="store_true", help="union the lane's file list with existing (one story's slice, a CR delta) instead of replacing")
|
|
1492
1646
|
c.add_argument("--lane", default="backend", help="backend -> source_files, frontend -> ui_source_files")
|
|
1647
|
+
c.add_argument("--ids", default="", help="requirement ids this slice realises; records a Requirement Anchors row per id against the files just written")
|
|
1648
|
+
c.add_argument("--slice", dest="slice_id", default="", help="the story id the anchor rows are keyed by (with --ids)")
|
|
1649
|
+
c.add_argument("--date", default="")
|
|
1493
1650
|
c.add_argument("--title", default="")
|
|
1494
1651
|
c.add_argument("--description", default="")
|
|
1495
1652
|
c.add_argument("--origin", default="greenfield")
|
|
@@ -4,10 +4,13 @@ type: functional
|
|
|
4
4
|
status: open
|
|
5
5
|
title: [one line — the outcome this story delivers]
|
|
6
6
|
exercises: []
|
|
7
|
+
constraints: []
|
|
7
8
|
spec_version: [vN.N]
|
|
8
9
|
depends_on: []
|
|
9
10
|
wave: 1
|
|
10
11
|
follows:
|
|
12
|
+
designed_lanes: []
|
|
13
|
+
dropped_lanes: []
|
|
11
14
|
work_item:
|
|
12
15
|
created: [DATE]
|
|
13
16
|
---
|
|
@@ -23,7 +26,8 @@ created: [DATE]
|
|
|
23
26
|
|
|
24
27
|
A story is a slice of work over a spec that is already written. It is NEVER a proposal
|
|
25
28
|
to change the spec — nothing here merges back into spec.md. When a story genuinely
|
|
26
|
-
needs the contract to change,
|
|
29
|
+
needs the contract to change, sync-spec (⌛ not built yet — the hand route is
|
|
30
|
+
.appweaver/SPEC-EDIT-PROTOCOL.md) folds it back through specify's
|
|
27
31
|
checklist -> clarify -> validate loop, and the spec's Change History gains a row.
|
|
28
32
|
|
|
29
33
|
ONE STORY, ONE FEATURE. A story whose design touches a second feature is failed by
|
|
@@ -33,7 +37,7 @@ created: [DATE]
|
|
|
33
37
|
directory scan rather than a markdown parse.
|
|
34
38
|
|
|
35
39
|
id Minted by the kit: the feature name plus the next free number in
|
|
36
|
-
stories/.
|
|
40
|
+
stories/. The writer refuses to overwrite an existing file, so a
|
|
37
41
|
collision is an add/add git conflict, never a silent overwrite.
|
|
38
42
|
change-story-id corrects it while it is still legal — see below.
|
|
39
43
|
|
|
@@ -57,9 +61,19 @@ created: [DATE]
|
|
|
57
61
|
id. A requirement is DELIVERED when it has NO OPEN STORIES — not when a
|
|
58
62
|
particular story closed, because a follow-on may carry the remainder.
|
|
59
63
|
|
|
64
|
+
constraints The spec-level bounds this slice must satisfy: the CN-/DC-/IC-/UX- ids,
|
|
65
|
+
lifted from spec.md by the kit and never typed by hand. Spec constraints
|
|
66
|
+
are declared aggregate-wide, so the feature's WHOLE set binds every slice
|
|
67
|
+
of it and there is nothing to choose. This field is the gate surface —
|
|
68
|
+
implement, implement-ui and analyze-slice read it — and the "## Constraints"
|
|
69
|
+
section below is where each id gets the line saying what THIS slice does
|
|
70
|
+
about it. Stamped by sync-stories; a story written before this field
|
|
71
|
+
existed has no key at all, and its readers fall back to the section.
|
|
72
|
+
|
|
60
73
|
spec_version The spec version this story last synced at. A row above this version
|
|
61
|
-
naming an id in exercises means the story is stale;
|
|
62
|
-
reports it and sync-stories acts on it. Restamped on
|
|
74
|
+
naming an id in exercises OR constraints means the story is stale;
|
|
75
|
+
check-stories reports it and sync-stories acts on it. Restamped on
|
|
76
|
+
every sync.
|
|
63
77
|
|
|
64
78
|
depends_on Story ids that must land first, e.g. [Payment-004]. About CODE ordering,
|
|
65
79
|
not spec: implement warns on an open dependency, which is where calling
|
|
@@ -83,6 +97,16 @@ created: [DATE]
|
|
|
83
97
|
its own dependencies are done, whatever else shares its wave. A story
|
|
84
98
|
raised by hand floors at 1 — nothing is KNOWN to come first.
|
|
85
99
|
|
|
100
|
+
designed_lanes Which lanes story-plan actually designed, e.g. [backend, frontend].
|
|
101
|
+
dropped_lanes Which lanes it DROPPED, and why the pair exists: a story with no UI
|
|
102
|
+
design either has no user-facing surface or had its UI lane dropped when
|
|
103
|
+
it was designed — an undeclared frontend lane, or a frontend-plan.md
|
|
104
|
+
missing or behind the spec — and one field cannot say which. Written by
|
|
105
|
+
story-plan (never by sync-stories: stories are not designed there);
|
|
106
|
+
designed accumulates across passes, dropped holds what has been dropped
|
|
107
|
+
and not since designed. Both absent on a story that predates them, which
|
|
108
|
+
implement-ui reports as exactly that rather than guessing.
|
|
109
|
+
|
|
86
110
|
follows On a follow-on only: the story id this one carries the remainder of.
|
|
87
111
|
When the spec moves under a story that already has a story plan,
|
|
88
112
|
sync-stories leaves that story untouched and mints a follow-on
|
|
@@ -126,10 +150,17 @@ As a [role], I want [capability], so that [outcome].
|
|
|
126
150
|
a standard the product does not hold, and they are the half most often lost: they
|
|
127
151
|
reach no UI phase except through the story.
|
|
128
152
|
|
|
153
|
+
THE IDS ARE STAMPED FOR YOU into `constraints:` above — the kit lifts every one the
|
|
154
|
+
spec declares, because spec constraints bind the aggregate rather than a single
|
|
155
|
+
operation. This section is where each gets its LINE. Write one per id saying what
|
|
156
|
+
this slice has to do about it, and where one genuinely does not apply, say so in its
|
|
157
|
+
line rather than dropping it: an omission used to be indistinguishable from a bound
|
|
158
|
+
nobody noticed, which is how a slice gets built without one.
|
|
159
|
+
|
|
129
160
|
These are NOT in `exercises:`. That field is FR-/BR- only, because it is the
|
|
130
161
|
delivery relation — what this story DELIVERS — and a constraint is not delivered,
|
|
131
162
|
it is met. story-plan carries these ids into its design pass; analyze-slice
|
|
132
|
-
traces it.
|
|
163
|
+
traces it. -->
|
|
133
164
|
|
|
134
165
|
- **[UX-00n]** — [what this slice must satisfy of it]
|
|
135
166
|
|
|
@@ -144,4 +175,4 @@ As a [role], I want [capability], so that [outcome].
|
|
|
144
175
|
## Notes
|
|
145
176
|
|
|
146
177
|
<!-- Optional: sequencing, risks, what is deliberately out of this slice. Anything that
|
|
147
|
-
belongs to the CONTRACT belongs in spec.md via
|
|
178
|
+
belongs to the CONTRACT belongs in spec.md via sync-spec, not here. -->
|
package/package.json
CHANGED