aw-spec-cli 0.1.14 → 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/STACK-PROFILES.md +1 -1
- package/assets/.appweaver/orchestrator/modules/spec-state-gate.orch.yaml +87 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +5 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +10 -3
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +38 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +2 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +672 -124
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +700 -72
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +531 -135
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +573 -145
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +78 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan-ui.orch.yaml +600 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +721 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +119 -32
- package/assets/.appweaver/orchestrator/workflows/aw-spec-story-plan.orch.yaml +1147 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-sync-stories.orch.yaml +1121 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2564 -38
- package/assets/.appweaver/scripts/bash/aw-xref.sh +2 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2458 -22
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +2 -1
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +158 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +431 -48
- package/assets/.appweaver/templates/design-template.md +88 -54
- package/assets/.appweaver/templates/spec-template.md +12 -10
- package/assets/.appweaver/templates/story-template.md +83 -12
- package/assets/.appweaver/templates/ui-design-template.md +71 -39
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +19 -7
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +35 -26
- package/package.json +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +0 -386
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +0 -382
|
@@ -39,14 +39,19 @@ summary to STDERR so it never corrupts the JSON contract):
|
|
|
39
39
|
list --aggregate Y [--root DIR] [--bundle knowledge]
|
|
40
40
|
-> Y's blast radius (who uses Y). {aggregate, concept, users[], count, root}
|
|
41
41
|
|
|
42
|
-
concept-upsert --aggregate X [--source-files a,b,c] [--
|
|
42
|
+
concept-upsert --aggregate X [--source-files a,b,c] [--lane backend|frontend] [--merge]
|
|
43
|
+
[--title T] [--description D]
|
|
43
44
|
[--origin greenfield|reverse-engineered] [--root DIR] [--bundle knowledge]
|
|
44
|
-
-> create/update X's OWN concept (frontmatter type/title/
|
|
45
|
-
its body — never clobbers the Cross-References other slices wrote. --
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
-> create/update X's OWN concept (frontmatter type/title/file lists), PRESERVING
|
|
46
|
+
its body — never clobbers the Cross-References other slices wrote. --lane picks
|
|
47
|
+
WHICH list this run owns: backend -> `source_files`, frontend -> `ui_source_files`.
|
|
48
|
+
Both keys are always present (an absent one claims "never implemented", which is
|
|
49
|
+
not the same as empty). --merge UNIONs that lane's list instead of replacing it —
|
|
50
|
+
what one story's slice, or a CR delta, must do so a second run does not drop the
|
|
51
|
+
first's files. Each list is written FLAT, path-grouped by the code's OWN folders
|
|
52
|
+
via YAML comment headers (adaptive — see _group_source_files), so the listing
|
|
53
|
+
mirrors the real architecture.
|
|
54
|
+
{aggregate, concept, created, lane, key, source_file_count, root}
|
|
50
55
|
|
|
51
56
|
concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR] [--bundle knowledge]
|
|
52
57
|
-> splice model-authored prose into X's `## Overview` ONLY (the one section a
|
|
@@ -63,6 +68,21 @@ summary to STDERR so it never corrupts the JSON contract):
|
|
|
63
68
|
{aggregate, concept, found, paths[], count, csv, declared[], declared_count,
|
|
64
69
|
missing[], missing_count, root}
|
|
65
70
|
|
|
71
|
+
concept-coverage --aggregate X | --feature-dir <dir> [--ids CSV] [--max-list N] [--root DIR] [--bundle knowledge]
|
|
72
|
+
-> READ-ONLY: what CODE EXISTS for one feature, split by LANE — `source_files`
|
|
73
|
+
(service) and `ui_source_files` (UI) as separate lists, plus the concept's
|
|
74
|
+
forward edges (Depends-On) and reverse edges (Cross-References, the blast
|
|
75
|
+
radius). The answer a STORY DESIGN needs, where concept-paths' one flat
|
|
76
|
+
backend list would hand a UI pass the service tree. `--ids` splits the
|
|
77
|
+
story's requirements into anchored/unanchored against the feature's
|
|
78
|
+
source-map.md where one exists; with no map every id is unanchored, which
|
|
79
|
+
is reported as "no index", never as "no code". `digest` is the rendered
|
|
80
|
+
block a design pass reads. Never stubs a concept.
|
|
81
|
+
{aggregate, concept, found, origin, backend_paths[]/_csv/_count,
|
|
82
|
+
frontend_paths[]/_csv/_count, has_backend_code, has_frontend_code,
|
|
83
|
+
backend_missing[], frontend_missing[], missing_count, depends_on[]/_csv,
|
|
84
|
+
used_by[]/_csv, source_map, anchored_csv, unanchored_csv, digest, root}
|
|
85
|
+
|
|
66
86
|
validate [--prune] [--root DIR] [--bundle knowledge]
|
|
67
87
|
-> drift check beyond reconcile-on-write. reverse-orphan = a Cross-References row with
|
|
68
88
|
no backing Depends-On (stale; --prune removes it); forward-orphan = a Depends-On
|
|
@@ -77,12 +97,15 @@ Only the standard library is used. Usage: aw-xref.py <sub> [options]
|
|
|
77
97
|
"""
|
|
78
98
|
|
|
79
99
|
import argparse
|
|
100
|
+
import contextlib
|
|
80
101
|
import datetime
|
|
102
|
+
import errno
|
|
81
103
|
import json
|
|
82
104
|
import os
|
|
83
105
|
import re
|
|
84
106
|
import subprocess
|
|
85
107
|
import sys
|
|
108
|
+
import time
|
|
86
109
|
|
|
87
110
|
# ───────────────────────────── canonical shapes ────────────────────────────
|
|
88
111
|
OVERVIEW_HEADING = "## Overview"
|
|
@@ -100,6 +123,17 @@ XREF_COMMENT = (
|
|
|
100
123
|
"Each row written by the using slice's implement/adopt (reconcile-on-write). "
|
|
101
124
|
"Reviewed at CR time. Do not hand-edit. -->"
|
|
102
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
|
+
)
|
|
103
137
|
RESERVED = {"index.md", "log.md"}
|
|
104
138
|
|
|
105
139
|
# A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
|
|
@@ -229,10 +263,82 @@ def _read(path):
|
|
|
229
263
|
return ""
|
|
230
264
|
|
|
231
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
|
+
|
|
232
335
|
def _write(path, text):
|
|
233
|
-
os.
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
236
342
|
|
|
237
343
|
|
|
238
344
|
def _today(arg_date):
|
|
@@ -478,13 +584,19 @@ def _resolve_concept(name, bundle_abs):
|
|
|
478
584
|
return best
|
|
479
585
|
|
|
480
586
|
|
|
481
|
-
def _skeleton_concept(name, origin, source_files, description):
|
|
587
|
+
def _skeleton_concept(name, origin, source_files, description, lane="backend"):
|
|
588
|
+
"""A new concept always carries BOTH lane keys — `source_files` for the service
|
|
589
|
+
tree and `ui_source_files` for the UI one — with the run's files in the lane it
|
|
590
|
+
generated and an empty list in the other. Whichever lane implements first, the
|
|
591
|
+
other's key is there for it to fill; the template's rule is to leave it empty on a
|
|
592
|
+
single-lane slice, never to delete it."""
|
|
482
593
|
fm = {
|
|
483
594
|
"type": "aggregate",
|
|
484
595
|
"title": name,
|
|
485
596
|
"description": description or f"{name} aggregate.",
|
|
486
597
|
"origin": origin or "greenfield",
|
|
487
|
-
"source_files": list(source_files or []),
|
|
598
|
+
"source_files": list(source_files or []) if lane == "backend" else [],
|
|
599
|
+
"ui_source_files": list(source_files or []) if lane == "frontend" else [],
|
|
488
600
|
}
|
|
489
601
|
overview = f"[overview of the {name} aggregate]"
|
|
490
602
|
body = (
|
|
@@ -653,15 +765,41 @@ def _git_head_short(root):
|
|
|
653
765
|
|
|
654
766
|
|
|
655
767
|
def _write_xref_rows(path, rows):
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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))
|
|
659
775
|
|
|
660
776
|
|
|
661
777
|
def _write_deps_rows(path, rows):
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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)
|
|
665
803
|
|
|
666
804
|
|
|
667
805
|
def _all_concepts(bundle_abs):
|
|
@@ -788,49 +926,89 @@ def cmd_concept_upsert(args):
|
|
|
788
926
|
_emit({"error": "aggregate-required", "root": root})
|
|
789
927
|
return 2
|
|
790
928
|
source_files = _parse_source_files(args.source_files, root)
|
|
929
|
+
# WHICH LANE'S FILE LIST THIS RUN OWNS. A feature has ONE concept across both lanes,
|
|
930
|
+
# and the two trees live under different keys: `source_files` is the service lane's,
|
|
931
|
+
# `ui_source_files` is the UI lane's. Writing UI files into the service key would
|
|
932
|
+
# collapse the concept resolver's module_dir to the repo root and hand every later
|
|
933
|
+
# service reader a tree of components — which is exactly what concept-coverage's
|
|
934
|
+
# lane split exists to prevent.
|
|
935
|
+
lane = (args.lane or "backend").strip().lower()
|
|
936
|
+
if lane not in ("backend", "frontend"):
|
|
937
|
+
print("[xref] concept-upsert: --lane must be backend or frontend, not %r" % lane, file=sys.stderr)
|
|
938
|
+
_emit({"error": "bad-lane", "lane": lane, "root": root})
|
|
939
|
+
return 2
|
|
940
|
+
key = "source_files" if lane == "backend" else "ui_source_files"
|
|
791
941
|
|
|
792
942
|
path = _resolve_concept(aggregate, bundle_abs)
|
|
793
943
|
created = path is None
|
|
794
944
|
if created:
|
|
795
945
|
_ensure_bundle(bundle_abs)
|
|
796
946
|
path = _canonical_concept_path(bundle_abs, aggregate)
|
|
797
|
-
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
|
|
947
|
+
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or "", lane))
|
|
798
948
|
_ensure_dir_index(path, aggregate)
|
|
799
949
|
else:
|
|
800
950
|
# Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
|
|
801
|
-
# which holds the Cross-References other slices wrote.
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
827
986
|
_ensure_bundle(bundle_abs)
|
|
828
987
|
_ensure_dir_index(path, aggregate)
|
|
829
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
|
+
|
|
830
1003
|
print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
|
|
831
|
-
f"({len(source_files)} source file(s)
|
|
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)}",
|
|
1007
|
+
file=sys.stderr)
|
|
832
1008
|
_emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
|
|
833
|
-
"
|
|
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})
|
|
834
1012
|
return 0
|
|
835
1013
|
|
|
836
1014
|
|
|
@@ -935,6 +1113,197 @@ def cmd_concept_paths(args):
|
|
|
935
1113
|
return 0
|
|
936
1114
|
|
|
937
1115
|
|
|
1116
|
+
def cmd_concept_coverage(args):
|
|
1117
|
+
"""CODE COVERAGE for one feature, read from its OKF concept — what code EXISTS
|
|
1118
|
+
today for the aggregate a story is about to design against, SPLIT BY LANE.
|
|
1119
|
+
|
|
1120
|
+
concept-paths answers "which files is this slice made of" for the executable
|
|
1121
|
+
phases, as one flat backend list. A story design needs the same facts shaped
|
|
1122
|
+
differently: the service design reads `source_files`, the frontend design reads
|
|
1123
|
+
`ui_source_files`, and neither should be handed the other's tree — a UI pass
|
|
1124
|
+
told to read twelve C# files designs around them. It also needs the two things
|
|
1125
|
+
a path list drops: the aggregate's forward edges (Depends-On), and its REVERSE
|
|
1126
|
+
edges (Cross-References) — the blast radius a slice inherits the moment it
|
|
1127
|
+
changes a published surface.
|
|
1128
|
+
|
|
1129
|
+
COVERAGE, HONESTLY. A greenfield concept holds no requirement→file index: code
|
|
1130
|
+
carries no FR ids. A feature that was ADOPTED does — the source map's `Spec ids`
|
|
1131
|
+
column — so the ids given in --ids come back split into ANCHORED (some artefact
|
|
1132
|
+
claims the id) and UNANCHORED. With no source map every id is unanchored, which
|
|
1133
|
+
is the true answer, and it is NEVER reported as uncovered: absent evidence is not
|
|
1134
|
+
evidence of absence, and a design pass told "FR-003 has no code" when the index
|
|
1135
|
+
simply does not exist would re-plan work that already ships.
|
|
1136
|
+
|
|
1137
|
+
STRICTLY READ-ONLY, the same contract as concept-paths: a query never stubs a
|
|
1138
|
+
concept, and a feature with no concept at all — greenfield, before the first
|
|
1139
|
+
implement — is a legitimate EMPTY answer with `found: false`, not an error."""
|
|
1140
|
+
root = _root(args.root)
|
|
1141
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
1142
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
1143
|
+
if not aggregate:
|
|
1144
|
+
print("[xref] concept-coverage: --aggregate or --feature-dir required", file=sys.stderr)
|
|
1145
|
+
_emit({"error": "aggregate-required", "root": root})
|
|
1146
|
+
return 2
|
|
1147
|
+
|
|
1148
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
1149
|
+
fm = {}
|
|
1150
|
+
if path:
|
|
1151
|
+
fm, _ = _read_fm(_read(path))
|
|
1152
|
+
|
|
1153
|
+
def lane_files(key):
|
|
1154
|
+
"""One frontmatter key's paths, split into on-disk and declared-but-missing.
|
|
1155
|
+
_read_fm gives a list for a block list and "" for an empty scalar; the
|
|
1156
|
+
`# Layer` group headers are comments and are already skipped by it."""
|
|
1157
|
+
raw = fm.get(key)
|
|
1158
|
+
items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
|
|
1159
|
+
seen, existing, missing = set(), [], []
|
|
1160
|
+
for s in items:
|
|
1161
|
+
s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
|
|
1162
|
+
if not s or s in seen:
|
|
1163
|
+
continue
|
|
1164
|
+
seen.add(s)
|
|
1165
|
+
target = s if os.path.isabs(s) else os.path.join(root, s)
|
|
1166
|
+
(existing if os.path.isfile(target) else missing).append(s)
|
|
1167
|
+
return existing, missing
|
|
1168
|
+
|
|
1169
|
+
be, be_missing = lane_files("source_files")
|
|
1170
|
+
fe, fe_missing = lane_files("ui_source_files")
|
|
1171
|
+
|
|
1172
|
+
# ---- requirement anchors, where an index exists ---------------------------
|
|
1173
|
+
ids = [i for i in re.split(r'[,\s]+', (args.ids or "").strip()) if i]
|
|
1174
|
+
smap = ""
|
|
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)
|
|
1194
|
+
if args.feature_dir:
|
|
1195
|
+
cand = os.path.join(args.feature_dir, "source-map.md")
|
|
1196
|
+
if os.path.isfile(cand):
|
|
1197
|
+
smap = _rel(cand, root)
|
|
1198
|
+
text = _read(cand)
|
|
1199
|
+
lines = text.splitlines()
|
|
1200
|
+
start = next((i for i, ln in enumerate(lines) if ln.startswith("## Artefact")), None)
|
|
1201
|
+
if start is not None:
|
|
1202
|
+
end = next((j for j in range(start + 1, len(lines))
|
|
1203
|
+
if lines[j].startswith("## ")), len(lines))
|
|
1204
|
+
for r in _table_data_rows("\n".join(lines[start:end])):
|
|
1205
|
+
# | # | Legacy artefact | Kind | Spec ids | Design element | ... |
|
|
1206
|
+
if len(r) < 4:
|
|
1207
|
+
continue
|
|
1208
|
+
artefact = r[1].strip().strip("`")
|
|
1209
|
+
for rid in re.findall(r'\b[A-Z]{2}-\d+', r[3]):
|
|
1210
|
+
anchors.setdefault(rid, [])
|
|
1211
|
+
if artefact and artefact not in anchors[rid]:
|
|
1212
|
+
anchors[rid].append(artefact)
|
|
1213
|
+
anchored = [i for i in ids if anchors.get(i)]
|
|
1214
|
+
unanchored = [i for i in ids if not anchors.get(i)]
|
|
1215
|
+
|
|
1216
|
+
deps = [r[0].strip() for r in (_read_deps_rows(path) if path else [])
|
|
1217
|
+
if r and _IDENT_RE.match((r[0] or "").strip())]
|
|
1218
|
+
users = sorted(set(_xref_users(aggregate, bundle_abs))) if path else []
|
|
1219
|
+
|
|
1220
|
+
# ---- the digest — the block a design pass reads --------------------------
|
|
1221
|
+
cap = args.max_list if args.max_list and args.max_list > 0 else 60
|
|
1222
|
+
|
|
1223
|
+
def block(label, files, missing, empty_note):
|
|
1224
|
+
if not files:
|
|
1225
|
+
return ["%s: %s" % (label, empty_note)]
|
|
1226
|
+
out = ["%s: %d file(s) on disk" % (label, len(files))]
|
|
1227
|
+
shown = 0
|
|
1228
|
+
for group, items in _group_source_files(files):
|
|
1229
|
+
if shown >= cap:
|
|
1230
|
+
break
|
|
1231
|
+
out.append(" %s" % group)
|
|
1232
|
+
for f in items:
|
|
1233
|
+
if shown >= cap:
|
|
1234
|
+
break
|
|
1235
|
+
out.append(" %s" % f)
|
|
1236
|
+
shown += 1
|
|
1237
|
+
if len(files) > shown:
|
|
1238
|
+
out.append(" … and %d more (the full list is in the lane's csv)" % (len(files) - shown))
|
|
1239
|
+
if missing:
|
|
1240
|
+
out.append(" DRIFT — declared in the concept, not on disk (%d): %s"
|
|
1241
|
+
% (len(missing), ", ".join(missing[:12])
|
|
1242
|
+
+ (" …" if len(missing) > 12 else "")))
|
|
1243
|
+
return out
|
|
1244
|
+
|
|
1245
|
+
lines = []
|
|
1246
|
+
if not path:
|
|
1247
|
+
lines.append("No OKF concept for %s — nothing has been implemented for this feature yet. "
|
|
1248
|
+
"Every lane below is GREENFIELD: design from a clean slate." % aggregate)
|
|
1249
|
+
lines.append("")
|
|
1250
|
+
else:
|
|
1251
|
+
lines.append("%s — code coverage from the OKF concept (%s, origin: %s)"
|
|
1252
|
+
% (aggregate, _rel(path, root), (fm.get("origin") or "unknown")))
|
|
1253
|
+
lines.append("")
|
|
1254
|
+
lines += block("BACKEND", be, be_missing,
|
|
1255
|
+
"no service code recorded for this aggregate — greenfield on this lane")
|
|
1256
|
+
lines.append("")
|
|
1257
|
+
lines += block("FRONTEND", fe, fe_missing,
|
|
1258
|
+
"no UI code recorded for this aggregate — greenfield on this lane")
|
|
1259
|
+
lines.append("")
|
|
1260
|
+
lines.append("DEPENDS ON (this aggregate reaches into): %s" % (", ".join(deps) or "nothing"))
|
|
1261
|
+
lines.append("USED BY (change this aggregate's surface and these break): %s"
|
|
1262
|
+
% (", ".join(users) or "nothing recorded"))
|
|
1263
|
+
if ids:
|
|
1264
|
+
lines.append("")
|
|
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.")
|
|
1270
|
+
else:
|
|
1271
|
+
src = " + ".join(filter(None, [smap, "the concept's Requirement Anchors"
|
|
1272
|
+
if concept_anchored else ""]))
|
|
1273
|
+
lines.append("REQUIREMENT ANCHORS (%s):" % src)
|
|
1274
|
+
for i in anchored:
|
|
1275
|
+
lines.append(" %-10s %s" % (i, ", ".join(anchors[i][:6])
|
|
1276
|
+
+ (" …" if len(anchors[i]) > 6 else "")))
|
|
1277
|
+
if unanchored:
|
|
1278
|
+
lines.append(" not anchored to any artefact: %s — the map claims no code for "
|
|
1279
|
+
"them, so design them as new work unless the files above say otherwise."
|
|
1280
|
+
% ", ".join(unanchored))
|
|
1281
|
+
digest = "\n".join(lines)
|
|
1282
|
+
|
|
1283
|
+
print("[xref] concept-coverage %s: backend %d, frontend %d, drift %d, used-by %d"
|
|
1284
|
+
% (aggregate, len(be), len(fe), len(be_missing) + len(fe_missing), len(users)),
|
|
1285
|
+
file=sys.stderr)
|
|
1286
|
+
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
1287
|
+
"found": bool(path), "origin": (fm.get("origin") or ""),
|
|
1288
|
+
"backend_paths": be, "backend_csv": ",".join(be), "backend_count": len(be),
|
|
1289
|
+
"frontend_paths": fe, "frontend_csv": ",".join(fe), "frontend_count": len(fe),
|
|
1290
|
+
"has_backend_code": bool(be), "has_frontend_code": bool(fe),
|
|
1291
|
+
"backend_missing": be_missing, "frontend_missing": fe_missing,
|
|
1292
|
+
"missing_count": len(be_missing) + len(fe_missing),
|
|
1293
|
+
# THE UNION, for a reader whose question spans both lanes. A gate that traces a
|
|
1294
|
+
# requirement to code has to see the UI half of it: an FR- with a user-facing
|
|
1295
|
+
# surface is realised in two trees, and `source_files` alone is the service one.
|
|
1296
|
+
"all_paths": be + fe, "all_csv": ",".join(be + fe), "all_count": len(be) + len(fe),
|
|
1297
|
+
"all_missing": be_missing + fe_missing,
|
|
1298
|
+
"all_missing_csv": ", ".join(be_missing + fe_missing),
|
|
1299
|
+
"depends_on": deps, "depends_on_csv": ", ".join(deps),
|
|
1300
|
+
"used_by": users, "used_by_csv": ", ".join(users),
|
|
1301
|
+
"source_map": smap, "anchored_csv": ", ".join(anchored),
|
|
1302
|
+
"unanchored_csv": ", ".join(unanchored),
|
|
1303
|
+
"digest": digest, "root": root})
|
|
1304
|
+
return 0
|
|
1305
|
+
|
|
1306
|
+
|
|
938
1307
|
# ─────────────────────────────── validate ──────────────────────────────────
|
|
939
1308
|
def cmd_validate(args):
|
|
940
1309
|
"""Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
|
|
@@ -1273,7 +1642,11 @@ def main(argv):
|
|
|
1273
1642
|
c.add_argument("--aggregate", default="")
|
|
1274
1643
|
c.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1275
1644
|
c.add_argument("--source-files", default="")
|
|
1276
|
-
c.add_argument("--merge", action="store_true", help="union
|
|
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")
|
|
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="")
|
|
1277
1650
|
c.add_argument("--title", default="")
|
|
1278
1651
|
c.add_argument("--description", default="")
|
|
1279
1652
|
c.add_argument("--origin", default="greenfield")
|
|
@@ -1297,6 +1670,16 @@ def main(argv):
|
|
|
1297
1670
|
cp.add_argument("--bundle", default="knowledge")
|
|
1298
1671
|
cp.set_defaults(func=cmd_concept_paths)
|
|
1299
1672
|
|
|
1673
|
+
cc = sub.add_parser("concept-coverage", help="read-only: lane-split code coverage for one feature (story design)")
|
|
1674
|
+
cc.add_argument("--aggregate", default="")
|
|
1675
|
+
cc.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1676
|
+
cc.add_argument("--ids", default="", help="requirement ids to report anchors for (csv)")
|
|
1677
|
+
cc.add_argument("--max-list", dest="max_list", type=int, default=60,
|
|
1678
|
+
help="cap on files listed per lane in the digest (the csv stays complete)")
|
|
1679
|
+
cc.add_argument("--root", default="")
|
|
1680
|
+
cc.add_argument("--bundle", default="knowledge")
|
|
1681
|
+
cc.set_defaults(func=cmd_concept_coverage)
|
|
1682
|
+
|
|
1300
1683
|
vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
|
|
1301
1684
|
vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
|
|
1302
1685
|
vv.add_argument("--root", default="")
|