aw-spec-cli 0.1.19 → 0.1.21
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/DESIGN-FORMAT.md +17 -9
- package/assets/.appweaver/ORCH-DIALECT.md +8 -3
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +26 -3
- package/assets/.appweaver/orchestrator/orchestrator.py +65 -3
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +4 -2
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +121 -42
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +145 -52
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +18 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +306 -28
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +114 -30
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +140 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +385 -61
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +217 -174
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +407 -107
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +49 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +29 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +47 -23
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +19 -11
- package/assets/.appweaver/scripts/bash/aw-inventory.sh +15 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +683 -92
- package/assets/.appweaver/scripts/bash/aw-xref.sh +3 -0
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +15 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +625 -64
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +3 -0
- package/assets/.appweaver/scripts/python/aw-crstate.py +194 -0
- package/assets/.appweaver/scripts/python/aw-foundation.py +151 -0
- package/assets/.appweaver/scripts/python/aw-inventory.py +1829 -63
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +3 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +423 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +251 -28
- package/assets/.appweaver/scripts/python/aw-testlog.py +148 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +500 -81
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +1 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +1 -0
- package/assets/.appweaver/stack-profiles/node-express.json +1 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +1 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +17 -2
- package/assets/.appweaver/templates/delta-design-template.md +1 -1
- package/assets/.appweaver/templates/delta-domain-model-template.md +1 -1
- package/assets/.appweaver/templates/delta-spec-template.md +4 -3
- package/assets/.appweaver/templates/design-template.md +16 -11
- package/assets/.appweaver/templates/okf-concept-template.md +11 -1
- package/assets/.appweaver/templates/proposed-inventory-template.md +127 -0
- package/assets/.appweaver/templates/source-map-template.md +5 -3
- package/assets/.appweaver/templates/spec-template.md +17 -17
- package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
- package/assets/.appweaver/templates/ui-design-template.md +4 -4
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +4489 -361
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2274 -125
- 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 +70 -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
|
@@ -14,8 +14,8 @@ Edges are DERIVED deterministically from the using aggregate's design.md — the
|
|
|
14
14
|
domain/data-model section's "References (by id, not owned)" (+ `id<Other>` attribute
|
|
15
15
|
types) for entity-ref edges, and the External Integrations table (located by heading
|
|
16
16
|
keyword, so it works for both the forward and brownfield design layouts) for contract
|
|
17
|
-
edges — resolved against the project's KNOWN aggregates (a
|
|
18
|
-
existing bundle concept). A
|
|
17
|
+
edges — resolved against the project's KNOWN aggregates (one a feature's spec says it
|
|
18
|
+
acts on, or an existing bundle concept). A
|
|
19
19
|
target that is not a known aggregate (an external system, an owned child entity)
|
|
20
20
|
is never written. Writers are greenfield `implement` and brownfield `adopt`; the
|
|
21
21
|
reader is the CR loop (finalize-delta gates on the Cross-References blast radius).
|
|
@@ -224,6 +224,14 @@ def _slug(s):
|
|
|
224
224
|
return re.sub(r"-{2,}", "-", s).strip("-")
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def _key(s):
|
|
228
|
+
"""A name with case and every separator dropped. The bundle holds one name in three
|
|
229
|
+
spellings — a PascalCase id (MedicinalProduct), an OKF kebab file name
|
|
230
|
+
(medicinal-product.md) and a human title (Medicinal Product) — and a slug only
|
|
231
|
+
matches the last two to each other, so a lookup by id finds neither."""
|
|
232
|
+
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
|
|
233
|
+
|
|
234
|
+
|
|
227
235
|
def _read(path):
|
|
228
236
|
try:
|
|
229
237
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
@@ -332,12 +340,24 @@ def _today(arg_date):
|
|
|
332
340
|
return datetime.date.today().isoformat()
|
|
333
341
|
|
|
334
342
|
|
|
335
|
-
def
|
|
336
|
-
"""The
|
|
337
|
-
|
|
343
|
+
def _feature_from_dir(fd):
|
|
344
|
+
"""The feature name = the feature directory's basename. The workflow passes
|
|
345
|
+
{{feature_dir}} (absolute); feature-paths carries no name field."""
|
|
338
346
|
return os.path.basename(os.path.normpath(fd)) if fd else ""
|
|
339
347
|
|
|
340
348
|
|
|
349
|
+
def _agg_from_feature_dir(fd, root=None):
|
|
350
|
+
"""The CONCEPT a feature directory's OKF operations belong to: the aggregate the
|
|
351
|
+
feature answers for, not the feature. The bundle is the project's knowledge and a
|
|
352
|
+
concept is an aggregate, so a feature that is one capability over a shared aggregate
|
|
353
|
+
(PatientRegistration over Patient) reads and writes Patient's concept and never gets
|
|
354
|
+
one of its own. A 1:1 feature resolves to its own name — what every caller keyed on
|
|
355
|
+
before features and aggregates could differ."""
|
|
356
|
+
if not fd:
|
|
357
|
+
return ""
|
|
358
|
+
return _resolve_feature(fd, root or os.getcwd())["primary"]
|
|
359
|
+
|
|
360
|
+
|
|
341
361
|
def _parse_source_files(raw, root):
|
|
342
362
|
"""Accept the files a generate step owns, as EITHER a JSON array (how the engine
|
|
343
363
|
renders a list var: `["a.cs","b.cs"]`) OR a comma-separated string. Relativise any
|
|
@@ -430,7 +450,8 @@ def _read_fm(text):
|
|
|
430
450
|
|
|
431
451
|
|
|
432
452
|
def _render_fm(fm):
|
|
433
|
-
order = ["type", "title", "description", "origin", "
|
|
453
|
+
order = ["type", "title", "description", "origin", "bounded_context", "features",
|
|
454
|
+
"source_files", "ui_source_files"]
|
|
434
455
|
keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
|
|
435
456
|
out = ["---"]
|
|
436
457
|
for k in keys:
|
|
@@ -545,9 +566,10 @@ def _canonical_concept_path(bundle_abs, name):
|
|
|
545
566
|
|
|
546
567
|
|
|
547
568
|
def _resolve_concept(name, bundle_abs):
|
|
548
|
-
"""Find name's concept (mirrors aw-inventory.py concept): match title
|
|
549
|
-
prefer a type containing 'aggregate'. Return
|
|
550
|
-
|
|
569
|
+
"""Find name's concept (mirrors aw-inventory.py concept): match title or file name
|
|
570
|
+
with case and separators ignored, prefer a type containing 'aggregate'. Return
|
|
571
|
+
abspath or None."""
|
|
572
|
+
want = _key(name)
|
|
551
573
|
best, best_score = None, -1
|
|
552
574
|
if not os.path.isdir(bundle_abs):
|
|
553
575
|
return None
|
|
@@ -560,21 +582,22 @@ def _resolve_concept(name, bundle_abs):
|
|
|
560
582
|
fm, _ = _read_fm(_read(path))
|
|
561
583
|
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
562
584
|
base = os.path.splitext(fn)[0]
|
|
563
|
-
if want not in (
|
|
585
|
+
if want not in (_key(title), _key(base)):
|
|
564
586
|
continue
|
|
565
587
|
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
566
|
-
score = (2 if "aggregate" in typ.lower() else 0) + (1 if
|
|
588
|
+
score = (2 if "aggregate" in typ.lower() else 0) + (1 if _key(base) == want else 0)
|
|
567
589
|
if score > best_score:
|
|
568
590
|
best, best_score = path, score
|
|
569
591
|
return best
|
|
570
592
|
|
|
571
593
|
|
|
572
|
-
def _skeleton_concept(name, origin, source_files, description):
|
|
594
|
+
def _skeleton_concept(name, origin, source_files, description, features=None):
|
|
573
595
|
fm = {
|
|
574
596
|
"type": "aggregate",
|
|
575
597
|
"title": name,
|
|
576
598
|
"description": description or f"{name} aggregate.",
|
|
577
599
|
"origin": origin or "greenfield",
|
|
600
|
+
"features": list(features or []),
|
|
578
601
|
"source_files": list(source_files or []),
|
|
579
602
|
}
|
|
580
603
|
overview = f"[overview of the {name} aggregate]"
|
|
@@ -595,14 +618,15 @@ def _ensure_dir_index(concept_path, name):
|
|
|
595
618
|
_write(idx, f"# {name}\n\n- [{name}](./{base}.md)\n") # NO frontmatter (OKF E3)
|
|
596
619
|
|
|
597
620
|
|
|
598
|
-
def _get_or_stub_concept(name, bundle_abs, origin, stubbed):
|
|
599
|
-
"""Return the concept path for name, creating a stub (+ dir/root index) if missing.
|
|
621
|
+
def _get_or_stub_concept(name, bundle_abs, origin, stubbed, features=None):
|
|
622
|
+
"""Return the concept path for name, creating a stub (+ dir/root index) if missing.
|
|
623
|
+
A stub starts out listing the features already known to act on the aggregate."""
|
|
600
624
|
path = _resolve_concept(name, bundle_abs)
|
|
601
625
|
if path:
|
|
602
626
|
return path
|
|
603
627
|
_ensure_bundle(bundle_abs)
|
|
604
628
|
path = _canonical_concept_path(bundle_abs, name)
|
|
605
|
-
_write(path, _skeleton_concept(name, origin, [], ""))
|
|
629
|
+
_write(path, _skeleton_concept(name, origin, [], "", features))
|
|
606
630
|
_ensure_dir_index(path, name)
|
|
607
631
|
stubbed.append(name)
|
|
608
632
|
return path
|
|
@@ -623,14 +647,14 @@ def _section(text, start_pat, end_pat):
|
|
|
623
647
|
|
|
624
648
|
|
|
625
649
|
def _known_aggregates(root, specs, bundle_abs):
|
|
626
|
-
"""The project's aggregate registry:
|
|
627
|
-
|
|
650
|
+
"""The project's aggregate registry: the aggregates every feature acts on (its spec's
|
|
651
|
+
Acts-on line, the boundary map, or its name when neither says) ∪ bundle concept
|
|
652
|
+
titles. A specs/ directory name is a feature or a bounded context, never an
|
|
653
|
+
aggregate by itself. Returns {slug: display_name}."""
|
|
628
654
|
known = {}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
if os.path.isdir(os.path.join(specs_dir, name)):
|
|
633
|
-
known.setdefault(_slug(name), name)
|
|
655
|
+
for v in _feature_index(root, specs).values():
|
|
656
|
+
for a in v["aggregates"]:
|
|
657
|
+
known.setdefault(_slug(a), a)
|
|
634
658
|
if os.path.isdir(bundle_abs):
|
|
635
659
|
for dirpath, dirnames, filenames in os.walk(bundle_abs):
|
|
636
660
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
@@ -772,17 +796,53 @@ def _all_concepts(bundle_abs):
|
|
|
772
796
|
return out
|
|
773
797
|
|
|
774
798
|
|
|
799
|
+
def _plan_path(fd):
|
|
800
|
+
"""A feature's backend plan, under its current name or the pre-lane-split one."""
|
|
801
|
+
for n in ("backend-plan.md", "design.md"):
|
|
802
|
+
p = os.path.join(fd, n)
|
|
803
|
+
if os.path.isfile(p):
|
|
804
|
+
return p
|
|
805
|
+
return ""
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _sibling_feature_dirs(root, specs, aggregate, exclude_fd):
|
|
809
|
+
"""The OTHER feature directories that answer for `aggregate` and have a plan to
|
|
810
|
+
read — specs/<Feature>/ and specs/<Context>/<Feature>/ alike. Ownership is decided by
|
|
811
|
+
the same rule feature-aggregates applies, so a sibling is exactly what the resolver
|
|
812
|
+
says owns that aggregate."""
|
|
813
|
+
skip = ""
|
|
814
|
+
if exclude_fd:
|
|
815
|
+
skip = os.path.realpath(exclude_fd if os.path.isabs(exclude_fd)
|
|
816
|
+
else os.path.join(root, exclude_fd))
|
|
817
|
+
want = _key(aggregate)
|
|
818
|
+
return [d for d in _feature_dirs(root, specs)
|
|
819
|
+
if os.path.realpath(d) != skip and _plan_path(d)
|
|
820
|
+
and want in {_key(x) for x in _resolve_feature(d, root)["owned"]}]
|
|
821
|
+
|
|
822
|
+
|
|
775
823
|
# ─────────────────────────────── commands ──────────────────────────────────
|
|
776
824
|
def cmd_record(args):
|
|
777
825
|
root = _root(args.root)
|
|
778
826
|
bundle_abs = os.path.join(root, args.bundle)
|
|
779
827
|
date = _today(args.date)
|
|
780
|
-
|
|
781
|
-
|
|
828
|
+
# The concept a slice's edges belong to is the AGGREGATE it answers for, never the
|
|
829
|
+
# feature. A feature that answers for several aggregates records under each of them,
|
|
830
|
+
# and a feature that shares an aggregate records beside every other feature that
|
|
831
|
+
# answers for it — which is why each aggregate's edge set is the union over all of
|
|
832
|
+
# them rather than this feature's alone. An explicit --using names the aggregate itself.
|
|
833
|
+
one_to_one, own_keys = True, set()
|
|
834
|
+
if args.using:
|
|
835
|
+
usings = [args.using]
|
|
836
|
+
elif args.feature_dir:
|
|
837
|
+
r = _resolve_feature(args.feature_dir, root)
|
|
838
|
+
usings, one_to_one = list(r["owned"]), r["one_to_one"] == "true"
|
|
839
|
+
own_keys = {_key(a) for a in r["owned"]}
|
|
840
|
+
else:
|
|
841
|
+
usings = []
|
|
842
|
+
if not usings:
|
|
782
843
|
print("[xref] record: --using or --feature-dir required", file=sys.stderr)
|
|
783
844
|
_emit({"error": "using-required", "root": root})
|
|
784
845
|
return 2
|
|
785
|
-
using_slug = _slug(using)
|
|
786
846
|
|
|
787
847
|
design_arg = args.design or (os.path.join(args.feature_dir, "design.md") if args.feature_dir else "")
|
|
788
848
|
if not design_arg:
|
|
@@ -795,9 +855,61 @@ def cmd_record(args):
|
|
|
795
855
|
if origin == "auto":
|
|
796
856
|
origin = _detect_origin(design_path)
|
|
797
857
|
|
|
798
|
-
|
|
858
|
+
known_all = _known_aggregates(root, args.specs, bundle_abs)
|
|
859
|
+
feature = _feature_from_dir(args.feature_dir) if args.feature_dir else ""
|
|
860
|
+
results = [_record_one(root, bundle_abs, args, u, design_path, feature, own_keys,
|
|
861
|
+
one_to_one, known_all, origin, date) for u in usings]
|
|
862
|
+
|
|
863
|
+
edges, wrote, stubbed, owners = [], [], [], []
|
|
864
|
+
for res in results:
|
|
865
|
+
edges.extend(dict(e, using=res["using"]) for e in res["edges"])
|
|
866
|
+
wrote.extend(res["wrote"])
|
|
867
|
+
stubbed.extend(s for s in res["stubbed"] if s not in stubbed)
|
|
868
|
+
owners.extend(o for o in res["owners"] if o not in owners)
|
|
869
|
+
pruned = sum(res["pruned"] for res in results)
|
|
870
|
+
print(f"[xref] recorded {', '.join(usings)} → {len(edges)} edge(s); "
|
|
871
|
+
f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
|
|
872
|
+
_emit({"using": usings[0], "usings": usings, "origin": origin, "edges": edges,
|
|
873
|
+
"edge_count": len(edges), "wrote": wrote, "stubbed": stubbed, "pruned": pruned,
|
|
874
|
+
"owners": owners, "one_to_one": "true" if one_to_one else "false",
|
|
875
|
+
"concept": results[0]["concept"], "root": root})
|
|
876
|
+
return 0
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _record_one(root, bundle_abs, args, using, design_path, feature, own_keys,
|
|
880
|
+
one_to_one, known_all, origin, date):
|
|
881
|
+
"""Reconcile ONE aggregate's edges: this feature's plan plus, where the aggregate is
|
|
882
|
+
shared, every other feature's plan that answers for it."""
|
|
883
|
+
using_slug = _slug(using)
|
|
884
|
+
known = dict(known_all)
|
|
799
885
|
known.pop(using_slug, None)
|
|
800
|
-
|
|
886
|
+
|
|
887
|
+
# An aggregate a feature itself answers for is never a dependency of another one it
|
|
888
|
+
# answers for: the reference between them is that one feature's code, not a boundary
|
|
889
|
+
# the model draws. Recorded, it would make a feature over Order + Inventory put each
|
|
890
|
+
# aggregate on the other's blast radius — a cycle no wave ordering can break.
|
|
891
|
+
def plan_edges(text, exclude):
|
|
892
|
+
return [e for e in _design_edges(text, using, known) if _key(e["target"]) not in exclude]
|
|
893
|
+
|
|
894
|
+
edges = plan_edges(_read(design_path), own_keys)
|
|
895
|
+
|
|
896
|
+
# A SHARED AGGREGATE'S EDGES ARE EVERY FEATURE'S. The delete phase below strips every
|
|
897
|
+
# row recorded under this aggregate and the add phase writes back only what it is
|
|
898
|
+
# handed, so recording one feature from its own plan would erase the edges the others
|
|
899
|
+
# recorded. Each other feature's CURRENT plan is read instead and the rows written are
|
|
900
|
+
# the union, which keeps reconcile-on-write self-healing for all of them. A feature
|
|
901
|
+
# that alone answers for the aggregate has no one to union with.
|
|
902
|
+
owners = [feature or using]
|
|
903
|
+
if not one_to_one:
|
|
904
|
+
seen_e = {(e["target"], e["via"], e["kind"]) for e in edges}
|
|
905
|
+
for sd in _sibling_feature_dirs(root, args.specs, using, args.feature_dir):
|
|
906
|
+
sr = _resolve_feature(sd, root)
|
|
907
|
+
owners.append(sr["feature"])
|
|
908
|
+
for e in plan_edges(_read(_plan_path(sd)), {_key(a) for a in sr["owned"]}):
|
|
909
|
+
k3 = (e["target"], e["via"], e["kind"])
|
|
910
|
+
if k3 not in seen_e:
|
|
911
|
+
seen_e.add(k3)
|
|
912
|
+
edges.append(e)
|
|
801
913
|
|
|
802
914
|
# RECONCILE-ON-WRITE, delete phase: strip every row this slice authored, anywhere.
|
|
803
915
|
# The ROWS are what this rewrites, so the lock has to span reading them and writing
|
|
@@ -818,7 +930,8 @@ def cmd_record(args):
|
|
|
818
930
|
# same lock, spanning the read and the write.
|
|
819
931
|
wrote, stubbed = [], []
|
|
820
932
|
for e in edges:
|
|
821
|
-
ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed
|
|
933
|
+
ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed,
|
|
934
|
+
_features_on(root, e["target"], specs=args.specs))
|
|
822
935
|
with _lock(ypath):
|
|
823
936
|
rows = _read_xref_rows(ypath)
|
|
824
937
|
rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
|
|
@@ -827,40 +940,49 @@ def cmd_record(args):
|
|
|
827
940
|
wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
|
|
828
941
|
|
|
829
942
|
# Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
|
|
830
|
-
xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed
|
|
943
|
+
xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed,
|
|
944
|
+
_features_on(root, using, specs=args.specs))
|
|
831
945
|
_write_deps_rows(xpath, [[e["target"], e["via"], e["kind"]] for e in edges])
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
|
|
835
|
-
_emit({"using": using, "origin": origin, "edges": edges, "edge_count": len(edges),
|
|
836
|
-
"wrote": wrote, "stubbed": stubbed, "pruned": pruned,
|
|
837
|
-
"concept": _rel(xpath, root), "root": root})
|
|
838
|
-
return 0
|
|
946
|
+
return {"using": using, "edges": edges, "wrote": wrote, "stubbed": stubbed,
|
|
947
|
+
"pruned": pruned, "owners": owners, "concept": _rel(xpath, root)}
|
|
839
948
|
|
|
840
949
|
|
|
841
950
|
def cmd_list(args):
|
|
842
951
|
root = _root(args.root)
|
|
843
952
|
bundle_abs = os.path.join(root, args.bundle)
|
|
844
|
-
|
|
845
|
-
|
|
953
|
+
# A feature's blast radius is that of EVERY aggregate it answers for: a change to a
|
|
954
|
+
# feature over Order and Inventory can ripple into whatever uses either of them.
|
|
955
|
+
if args.aggregate:
|
|
956
|
+
aggregates = [args.aggregate]
|
|
957
|
+
elif args.feature_dir:
|
|
958
|
+
aggregates = list(_resolve_feature(args.feature_dir, root)["owned"])
|
|
959
|
+
else:
|
|
960
|
+
aggregates = []
|
|
961
|
+
if not aggregates:
|
|
846
962
|
print("[xref] list: --aggregate or --feature-dir required", file=sys.stderr)
|
|
847
963
|
_emit({"error": "aggregate-required", "root": root})
|
|
848
964
|
return 2
|
|
965
|
+
aggregate = aggregates[0]
|
|
849
966
|
path = _resolve_concept(aggregate, bundle_abs)
|
|
850
|
-
users = []
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
967
|
+
users, seen_u = [], set()
|
|
968
|
+
for agg in aggregates:
|
|
969
|
+
apath = _resolve_concept(agg, bundle_abs)
|
|
970
|
+
for r in (_read_xref_rows(apath) if apath else []):
|
|
971
|
+
u = {
|
|
854
972
|
"using": r[0],
|
|
855
973
|
"via": r[1] if len(r) > 1 else "",
|
|
856
974
|
"kind": r[2] if len(r) > 2 else "",
|
|
857
975
|
"origin": r[3] if len(r) > 3 else "",
|
|
858
976
|
"recorded": r[4] if len(r) > 4 else "",
|
|
859
|
-
}
|
|
977
|
+
}
|
|
978
|
+
k3 = (u["using"], u["via"], u["kind"])
|
|
979
|
+
if k3 not in seen_u:
|
|
980
|
+
seen_u.add(k3)
|
|
981
|
+
users.append(u)
|
|
860
982
|
out_rel = ""
|
|
861
983
|
if args.out:
|
|
862
984
|
out_path = args.out if os.path.isabs(args.out) else os.path.join(root, args.out)
|
|
863
|
-
_write(out_path, _render_impact_md(
|
|
985
|
+
_write(out_path, _render_impact_md(", ".join(aggregates), users))
|
|
864
986
|
out_rel = _rel(out_path, root)
|
|
865
987
|
# Direct referrers (identifier-clean, de-duped) + the transitive closure — the
|
|
866
988
|
# integrity blast radius a caller runs tests over. count/users stay as-is for
|
|
@@ -870,10 +992,13 @@ def cmd_list(args):
|
|
|
870
992
|
name = (u["using"] or "").strip()
|
|
871
993
|
if _IDENT_RE.match(name) and name not in seen_d:
|
|
872
994
|
seen_d.add(name); direct.append(name)
|
|
873
|
-
closure =
|
|
874
|
-
|
|
995
|
+
closure = []
|
|
996
|
+
for agg in aggregates:
|
|
997
|
+
closure.extend(c for c in _referrer_closure(agg, bundle_abs) if c not in closure)
|
|
998
|
+
print(f"[xref] {', '.join(aggregates)}: {len(users)} downstream user(s) "
|
|
875
999
|
f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}", file=sys.stderr)
|
|
876
|
-
_emit({"aggregate": aggregate, "
|
|
1000
|
+
_emit({"aggregate": aggregate, "aggregates": aggregates,
|
|
1001
|
+
"concept": _rel(path, root) if path else "",
|
|
877
1002
|
"users": users, "count": len(users), "impact_report": out_rel, "root": root,
|
|
878
1003
|
"referrers": direct, "referrers_csv": ",".join(direct),
|
|
879
1004
|
"referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
|
|
@@ -886,19 +1011,29 @@ def cmd_concept_upsert(args):
|
|
|
886
1011
|
root = _root(args.root)
|
|
887
1012
|
bundle_abs = os.path.join(root, args.bundle)
|
|
888
1013
|
origin = args.origin or "greenfield"
|
|
889
|
-
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
1014
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
|
|
890
1015
|
if not aggregate:
|
|
891
1016
|
print("[xref] concept-upsert: --aggregate or --feature-dir required", file=sys.stderr)
|
|
892
1017
|
_emit({"error": "aggregate-required", "root": root})
|
|
893
1018
|
return 2
|
|
894
1019
|
source_files = _parse_source_files(args.source_files, root)
|
|
1020
|
+
# THE CONCEPT KNOWS ITS FEATURES. A concept is an aggregate, and several features may
|
|
1021
|
+
# act on one — so each concept lists, under `features:`, every feature known to act on
|
|
1022
|
+
# it. The files land on ONE concept (the aggregate this feature answers for first, so
|
|
1023
|
+
# a file has one home); the feature is linked on every aggregate it acts on.
|
|
1024
|
+
feature, acted = "", []
|
|
1025
|
+
if args.feature_dir and not args.aggregate:
|
|
1026
|
+
fr = _resolve_feature(args.feature_dir, root)
|
|
1027
|
+
feature, acted = fr["feature"], fr["aggregates"]
|
|
895
1028
|
|
|
896
1029
|
path = _resolve_concept(aggregate, bundle_abs)
|
|
897
1030
|
created = path is None
|
|
898
1031
|
if created:
|
|
899
1032
|
_ensure_bundle(bundle_abs)
|
|
900
1033
|
path = _canonical_concept_path(bundle_abs, aggregate)
|
|
901
|
-
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""
|
|
1034
|
+
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or "",
|
|
1035
|
+
_merge_features(_features_on(root, aggregate),
|
|
1036
|
+
[feature])))
|
|
902
1037
|
_ensure_dir_index(path, aggregate)
|
|
903
1038
|
else:
|
|
904
1039
|
# Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
|
|
@@ -929,19 +1064,61 @@ def cmd_concept_upsert(args):
|
|
|
929
1064
|
elif "description" not in fm:
|
|
930
1065
|
fm["description"] = f"{aggregate} aggregate."
|
|
931
1066
|
fm.setdefault("origin", origin)
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
_write(path,
|
|
1067
|
+
if feature:
|
|
1068
|
+
fm["features"] = _merge_features(fm.get("features"), [feature])
|
|
1069
|
+
_write(path, _rebuild_concept(fm, body))
|
|
935
1070
|
_ensure_bundle(bundle_abs)
|
|
936
1071
|
_ensure_dir_index(path, aggregate)
|
|
937
1072
|
|
|
1073
|
+
# Every OTHER aggregate this feature acts on lists it too, where that aggregate has a
|
|
1074
|
+
# concept already. One with no concept yet picks the feature up when it is created:
|
|
1075
|
+
# creation seeds the list from every feature known to act on the aggregate.
|
|
1076
|
+
linked = [aggregate] if feature else []
|
|
1077
|
+
for a in acted:
|
|
1078
|
+
apath = _resolve_concept(a, bundle_abs) if _key(a) != _key(aggregate) else None
|
|
1079
|
+
if apath:
|
|
1080
|
+
_link_feature(apath, feature)
|
|
1081
|
+
linked.append(a)
|
|
1082
|
+
|
|
938
1083
|
print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
|
|
939
|
-
f"({len(source_files)} source file(s)) — {_rel(path, root)}"
|
|
1084
|
+
f"({len(source_files)} source file(s)) — {_rel(path, root)}"
|
|
1085
|
+
+ (f"; {feature} listed on {', '.join(linked)}" if feature else ""), file=sys.stderr)
|
|
940
1086
|
_emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
|
|
941
|
-
"source_file_count": len(source_files), "
|
|
1087
|
+
"source_file_count": len(source_files), "feature": feature,
|
|
1088
|
+
"features_linked": linked, "root": root})
|
|
942
1089
|
return 0
|
|
943
1090
|
|
|
944
1091
|
|
|
1092
|
+
def _merge_features(existing, names):
|
|
1093
|
+
"""`existing` plus each of `names` not already listed, compared case- and
|
|
1094
|
+
punctuation-insensitively. Never drops an entry: a concept's feature list only grows,
|
|
1095
|
+
the same as adopt extends a concept additively."""
|
|
1096
|
+
out = [x for x in (existing if isinstance(existing, list) else []) if x]
|
|
1097
|
+
keys = {_key(x) for x in out}
|
|
1098
|
+
for n in names:
|
|
1099
|
+
if n and _key(n) not in keys:
|
|
1100
|
+
keys.add(_key(n))
|
|
1101
|
+
out.append(n)
|
|
1102
|
+
return out
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def _rebuild_concept(fm, body):
|
|
1106
|
+
rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
|
|
1107
|
+
else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
|
|
1108
|
+
return rebuilt.rstrip("\n") + "\n" # _read_fm's split drops the final newline
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _link_feature(path, feature):
|
|
1112
|
+
"""Add `feature` to a concept's `features:` list, under the concept's lock, touching
|
|
1113
|
+
nothing else in the file."""
|
|
1114
|
+
with _lock(path):
|
|
1115
|
+
fm, body = _read_fm(_read(path))
|
|
1116
|
+
merged = _merge_features(fm.get("features"), [feature])
|
|
1117
|
+
if merged != fm.get("features"):
|
|
1118
|
+
fm["features"] = merged
|
|
1119
|
+
_write(path, _rebuild_concept(fm, body))
|
|
1120
|
+
|
|
1121
|
+
|
|
945
1122
|
def cmd_concept_overview(args):
|
|
946
1123
|
"""Splice model-authored PROSE into the concept's `## Overview` — the one section a
|
|
947
1124
|
deterministic script cannot write. Section-scoped on purpose: the model never owns
|
|
@@ -949,7 +1126,7 @@ def cmd_concept_overview(args):
|
|
|
949
1126
|
Runs AFTER concept-upsert (which owns creation + frontmatter)."""
|
|
950
1127
|
root = _root(args.root)
|
|
951
1128
|
bundle_abs = os.path.join(root, args.bundle)
|
|
952
|
-
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
1129
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
|
|
953
1130
|
if not aggregate:
|
|
954
1131
|
print("[xref] concept-overview: --aggregate or --feature-dir required", file=sys.stderr)
|
|
955
1132
|
_emit({"error": "aggregate-required", "root": root})
|
|
@@ -1011,7 +1188,7 @@ def cmd_concept_paths(args):
|
|
|
1011
1188
|
than swallowed."""
|
|
1012
1189
|
root = _root(args.root)
|
|
1013
1190
|
bundle_abs = os.path.join(root, args.bundle)
|
|
1014
|
-
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
1191
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir, root)
|
|
1015
1192
|
if not aggregate:
|
|
1016
1193
|
print("[xref] concept-paths: --aggregate or --feature-dir required", file=sys.stderr)
|
|
1017
1194
|
_emit({"error": "aggregate-required", "root": root})
|
|
@@ -1035,18 +1212,255 @@ def cmd_concept_paths(args):
|
|
|
1035
1212
|
target = s if os.path.isabs(s) else os.path.join(root, s)
|
|
1036
1213
|
(existing if os.path.isfile(target) else missing).append(s)
|
|
1037
1214
|
|
|
1215
|
+
# A feature that is one capability over a shared aggregate is made of ITS files, not
|
|
1216
|
+
# the aggregate's: the concept lists every capability's code, and a fix loop handed a
|
|
1217
|
+
# sibling's files will edit them. The source-map adopt writes is the per-feature
|
|
1218
|
+
# list. Without one — a capability adopt has not reached yet — the aggregate's list
|
|
1219
|
+
# stands: over-inclusive, but never missing a file this feature does own.
|
|
1220
|
+
scope = "aggregate"
|
|
1221
|
+
if args.feature_dir and not args.aggregate:
|
|
1222
|
+
r = _resolve_feature(args.feature_dir, root)
|
|
1223
|
+
if r["one_to_one"] == "false":
|
|
1224
|
+
mapped = _source_map_files(os.path.join(r["feature_dir"], "source-map.md"))
|
|
1225
|
+
if mapped:
|
|
1226
|
+
declared = mapped
|
|
1227
|
+
existing = [s for s in mapped
|
|
1228
|
+
if os.path.isfile(s if os.path.isabs(s) else os.path.join(root, s))]
|
|
1229
|
+
missing = [s for s in mapped if s not in existing]
|
|
1230
|
+
scope = "feature"
|
|
1231
|
+
|
|
1038
1232
|
note = "" if path else " — no concept recorded yet"
|
|
1039
1233
|
drift = f", {len(missing)} declared-but-missing" if missing else ""
|
|
1040
|
-
print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}"
|
|
1041
|
-
file=sys.stderr)
|
|
1234
|
+
print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}"
|
|
1235
|
+
f" (scope: {scope})", file=sys.stderr)
|
|
1042
1236
|
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
1043
|
-
"found": bool(path), "paths": existing, "count": len(existing),
|
|
1237
|
+
"found": bool(path), "scope": scope, "paths": existing, "count": len(existing),
|
|
1044
1238
|
"csv": ",".join(existing), "declared": declared,
|
|
1045
1239
|
"declared_count": len(declared), "missing": missing,
|
|
1046
1240
|
"missing_count": len(missing), "root": root})
|
|
1047
1241
|
return 0
|
|
1048
1242
|
|
|
1049
1243
|
|
|
1244
|
+
# A source-map's Artefact → spec/design table row: `| SM-nnn | `<artefact>` | …`.
|
|
1245
|
+
_SM_ROW_RE = re.compile(r"^\|\s*SM-\d+\s*\|\s*`([^`]+)`\s*\|", re.M)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _source_map_files(path):
|
|
1249
|
+
"""The legacy FILES a feature's source-map maps — the per-feature file list. Only
|
|
1250
|
+
the Artefact → spec/design table counts, and only rows naming a path: tables,
|
|
1251
|
+
endpoints and jobs share that table and are not files. The Unmapped / open section
|
|
1252
|
+
is exactly what the feature does NOT own, so it is never read."""
|
|
1253
|
+
out = []
|
|
1254
|
+
for p in _SM_ROW_RE.findall(_read(path) or ""):
|
|
1255
|
+
p = p.strip().replace(os.sep, "/")
|
|
1256
|
+
if ("/" in p or re.search(r"\.[A-Za-z0-9]{1,6}$", p)) and p not in out:
|
|
1257
|
+
out.append(p)
|
|
1258
|
+
return out
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
# ──────────────────── feature-aggregates (read-only) ───────────────────────
|
|
1262
|
+
# The spec template's Domain Model Reference line. Only what precedes the "— see …"
|
|
1263
|
+
# pointer names aggregates; the pointer itself names the model file.
|
|
1264
|
+
_ACTS_ON_RE = re.compile(r"^\s*[-*]?\s*\*\*Acts on aggregate\(s\)\*\*\s*:\s*(.*)$", re.I | re.M)
|
|
1265
|
+
_AGG_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def _acts_on_from_spec(spec_path):
|
|
1269
|
+
"""The aggregate names a spec's `**Acts on aggregate(s)**:` line gives, in order.
|
|
1270
|
+
Backticked names are read first; a line written without backticks falls back to
|
|
1271
|
+
comma-separated bare names. A bracketed template token such as `[Root]` is not a
|
|
1272
|
+
name — a scaffolded spec that nobody has filled yet answers nothing here."""
|
|
1273
|
+
m = _ACTS_ON_RE.search(_read(spec_path) or "")
|
|
1274
|
+
if not m:
|
|
1275
|
+
return []
|
|
1276
|
+
head = re.split(r"\s+[—–]\s+see\b|\s+-\s+see\b", m.group(1), maxsplit=1)[0]
|
|
1277
|
+
names = re.findall(r"`([^`]+)`", head) or re.split(r"\s*[,;]\s*|\s+and\s+", head)
|
|
1278
|
+
out = []
|
|
1279
|
+
for n in names:
|
|
1280
|
+
n = (n or "").strip().strip("*").strip()
|
|
1281
|
+
if n and "[" not in n and _AGG_NAME_RE.match(n) and n not in out:
|
|
1282
|
+
out.append(n)
|
|
1283
|
+
return out
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _aggregates_from_map(root, feature):
|
|
1287
|
+
"""The aggregate(s) the confirmed boundary map assigns to a feature. Matched with
|
|
1288
|
+
case and punctuation ignored, because the map carries the name as the operator
|
|
1289
|
+
typed it ("care plan") and the directory carries it normalised (CarePlan)."""
|
|
1290
|
+
try:
|
|
1291
|
+
with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
|
|
1292
|
+
encoding="utf-8") as fh:
|
|
1293
|
+
m = json.load(fh)
|
|
1294
|
+
except (OSError, ValueError):
|
|
1295
|
+
return []
|
|
1296
|
+
key = lambda s: re.sub(r"[^a-z0-9]", "", (s or "").lower())
|
|
1297
|
+
for f in m.get("features", []) or []:
|
|
1298
|
+
if key(f.get("feature")) == key(feature):
|
|
1299
|
+
aggs = f.get("aggregates") or ([f.get("aggregate")] if f.get("aggregate") else [])
|
|
1300
|
+
return [a for a in dict.fromkeys(aggs) if a]
|
|
1301
|
+
return []
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _raw_aggregates(fd_abs, root, feature):
|
|
1305
|
+
"""(aggregates, source) for one feature directory — the spec's Acts-on line, then
|
|
1306
|
+
the boundary map, then the feature name. No ownership is decided here."""
|
|
1307
|
+
aggs, source = _acts_on_from_spec(os.path.join(fd_abs, "spec.md")), "spec"
|
|
1308
|
+
if not aggs:
|
|
1309
|
+
aggs, source = _aggregates_from_map(root, feature), "feature-map"
|
|
1310
|
+
if not aggs:
|
|
1311
|
+
aggs, source = [feature], "feature-name"
|
|
1312
|
+
return aggs, source
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _home(feature, aggs):
|
|
1316
|
+
"""The one aggregate a feature is ABOUT, when it has one: the aggregate it is named
|
|
1317
|
+
after, or the only aggregate it acts on. Every other aggregate a feature with a home
|
|
1318
|
+
names is one it merely touches. A feature over several aggregates named after none
|
|
1319
|
+
of them has no home — it is a capability across all of them."""
|
|
1320
|
+
mine = [a for a in aggs if _key(a) == _key(feature)]
|
|
1321
|
+
return mine[:1] or (list(aggs[:1]) if len(aggs) == 1 else [])
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _pascal_name(s):
|
|
1325
|
+
return "".join(w[:1].upper() + w[1:] for w in re.split(r"[^A-Za-z0-9]+", s or "") if w)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _feature_dirs(root, specs="specs"):
|
|
1329
|
+
"""Every feature directory in the specs tree: specs/<Feature>/ in a flat layout and
|
|
1330
|
+
specs/<Context>/<Feature>/ inside a bounded context. A top-level directory with no
|
|
1331
|
+
spec.md of its own that holds a domain-model.md or sub-directories is a context, and
|
|
1332
|
+
every child of a context is a feature, specified or not."""
|
|
1333
|
+
base = os.path.join(root, specs)
|
|
1334
|
+
out = []
|
|
1335
|
+
if not os.path.isdir(base):
|
|
1336
|
+
return out
|
|
1337
|
+
for a in sorted(os.listdir(base)):
|
|
1338
|
+
d = os.path.join(base, a)
|
|
1339
|
+
if a.startswith(".") or not os.path.isdir(d):
|
|
1340
|
+
continue
|
|
1341
|
+
subs = [b for b in sorted(os.listdir(d))
|
|
1342
|
+
if not b.startswith(".") and os.path.isdir(os.path.join(d, b))]
|
|
1343
|
+
if not os.path.isfile(os.path.join(d, "spec.md")) and (
|
|
1344
|
+
os.path.isfile(os.path.join(d, "domain-model.md")) or subs):
|
|
1345
|
+
out.extend(os.path.join(d, b) for b in subs)
|
|
1346
|
+
else:
|
|
1347
|
+
out.append(d)
|
|
1348
|
+
return out
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
_INDEX_CACHE = {}
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _feature_index(root, specs="specs"):
|
|
1355
|
+
"""Every feature the project defines — the specs tree ∪ the confirmed boundary map —
|
|
1356
|
+
with the aggregates it acts on and the ones it CLAIMS (answers for). A feature with a
|
|
1357
|
+
home claims only its home; a capability claims every aggregate it acts on. Keyed by
|
|
1358
|
+
the normalised feature name. Read once per process: nothing here writes the tree."""
|
|
1359
|
+
ck = (os.path.realpath(root), specs)
|
|
1360
|
+
if ck in _INDEX_CACHE:
|
|
1361
|
+
return _INDEX_CACHE[ck]
|
|
1362
|
+
idx = {}
|
|
1363
|
+
for d in _feature_dirs(root, specs):
|
|
1364
|
+
name = _feature_from_dir(d)
|
|
1365
|
+
aggs, _ = _raw_aggregates(d, root, name)
|
|
1366
|
+
idx[_key(name)] = {"feature": name, "dir": d, "aggregates": aggs,
|
|
1367
|
+
"claims": _home(name, aggs) or aggs}
|
|
1368
|
+
try:
|
|
1369
|
+
with open(os.path.join(root, ".appweaver", "inventory", "feature-map.json"),
|
|
1370
|
+
encoding="utf-8") as fh:
|
|
1371
|
+
mapped = json.load(fh).get("features") or []
|
|
1372
|
+
except (OSError, ValueError):
|
|
1373
|
+
mapped = []
|
|
1374
|
+
for f in mapped:
|
|
1375
|
+
name = _pascal_name(f.get("feature", ""))
|
|
1376
|
+
if not name or _key(name) in idx:
|
|
1377
|
+
continue
|
|
1378
|
+
aggs = [a for a in dict.fromkeys(f.get("aggregates") or [f.get("aggregate")]) if a]
|
|
1379
|
+
if aggs:
|
|
1380
|
+
idx[_key(name)] = {"feature": name, "dir": "", "aggregates": aggs,
|
|
1381
|
+
"claims": _home(name, aggs) or aggs}
|
|
1382
|
+
_INDEX_CACHE[ck] = idx
|
|
1383
|
+
return idx
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _features_on(root, aggregate, field="aggregates", specs="specs"):
|
|
1387
|
+
"""The features that act on (field="aggregates") or answer for (field="claims") an
|
|
1388
|
+
aggregate, in name order."""
|
|
1389
|
+
k = _key(aggregate)
|
|
1390
|
+
return sorted((v["feature"] for v in _feature_index(root, specs).values()
|
|
1391
|
+
if k in {_key(a) for a in v[field]}), key=str.lower)
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
def _resolve_feature(fd, root):
|
|
1395
|
+
"""What feature-aggregates reports, as a dict — shared with every concept operation
|
|
1396
|
+
that is handed a feature directory, so all of them agree on which concept it is."""
|
|
1397
|
+
feature = _feature_from_dir(fd)
|
|
1398
|
+
fd_abs = fd if os.path.isabs(fd) else os.path.join(root, fd)
|
|
1399
|
+
aggs, source = _raw_aggregates(fd_abs, root, feature)
|
|
1400
|
+
# OWNERSHIP IS DECIDED BY WHO ELSE ANSWERS FOR THE AGGREGATE, NEVER BY THE NAME. A
|
|
1401
|
+
# feature answers outright for its home only while no other feature claims that
|
|
1402
|
+
# aggregate too; the moment a second one does, both are capabilities over a shared
|
|
1403
|
+
# aggregate. A name that matches the aggregate is the developer's naming choice,
|
|
1404
|
+
# not a claim that no other feature will ever act on it.
|
|
1405
|
+
home = _home(feature, aggs)
|
|
1406
|
+
owned = home or aggs
|
|
1407
|
+
own_keys = {_key(a) for a in owned}
|
|
1408
|
+
shared_with = [v["feature"] for v in _feature_index(root).values()
|
|
1409
|
+
if _key(v["feature"]) != _key(feature)
|
|
1410
|
+
and own_keys & {_key(a) for a in v["claims"]}]
|
|
1411
|
+
shared_with = sorted(dict.fromkeys(shared_with), key=str.lower)
|
|
1412
|
+
one_to_one = bool(home) and not shared_with
|
|
1413
|
+
fmt = lambda xs: ", ".join(f"`## Aggregate: {a}`" for a in xs)
|
|
1414
|
+
return {"feature": feature, "feature_dir": fd_abs, "source": source,
|
|
1415
|
+
"aggregates": aggs, "aggregates_csv": ", ".join(aggs), "sections": fmt(aggs),
|
|
1416
|
+
"owned": owned, "owned_csv": ", ".join(owned), "owned_sections": fmt(owned),
|
|
1417
|
+
"primary": owned[0], "one_to_one": "true" if one_to_one else "false",
|
|
1418
|
+
"shared_with": shared_with, "shared_with_csv": ", ".join(shared_with)}
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def cmd_feature_aggregates(args):
|
|
1422
|
+
"""WHICH AGGREGATE(S) A FEATURE ACTS ON — the name every command that reads the
|
|
1423
|
+
bounded context's domain-model.md needs, because the model is organised by
|
|
1424
|
+
aggregate (`## Aggregate: <Name>`) and the specs tree by feature.
|
|
1425
|
+
|
|
1426
|
+
For most features the two names are the same, and every caller used the feature
|
|
1427
|
+
name as the section heading. That stops being true the moment one aggregate
|
|
1428
|
+
carries two capabilities — PatientRegistration and PatientMerge over Patient —
|
|
1429
|
+
and a lookup by feature name then finds no section. The failure is silent in the
|
|
1430
|
+
worst place: a conformance gate scoring "the BR-nnn under this feature's section"
|
|
1431
|
+
finds none and passes.
|
|
1432
|
+
|
|
1433
|
+
Three sources, most authoritative first:
|
|
1434
|
+
1. spec — the spec's own `**Acts on aggregate(s)**:` line. The document-level
|
|
1435
|
+
answer once a spec exists, and the one a change updates.
|
|
1436
|
+
2. map — the confirmed boundary map (.appweaver/inventory/feature-map.json),
|
|
1437
|
+
which /appweaver inventory writes and the operator edits. Covers a
|
|
1438
|
+
brownfield feature before its spec exists, which is exactly when
|
|
1439
|
+
adopt needs to know.
|
|
1440
|
+
3. feature — the feature name itself: the 1:1 default, and the only answer a
|
|
1441
|
+
greenfield feature with no spec yet can have. Every caller behaves
|
|
1442
|
+
exactly as it did before this resolver existed.
|
|
1443
|
+
|
|
1444
|
+
Two answers come back. `aggregates` is everything the feature acts on. `owned` is
|
|
1445
|
+
the part of the model it answers for: its own section when it is itself one of
|
|
1446
|
+
those aggregates — the 1:1 case, even where the spec also names an aggregate it
|
|
1447
|
+
merely touches — and otherwise every aggregate it names.
|
|
1448
|
+
|
|
1449
|
+
STRICTLY READ-ONLY and never empty: source 3 always answers, so a caller never has
|
|
1450
|
+
to branch on a missing result."""
|
|
1451
|
+
root = _root(args.root)
|
|
1452
|
+
if not _feature_from_dir(args.feature_dir):
|
|
1453
|
+
print("[xref] feature-aggregates: --feature-dir required", file=sys.stderr)
|
|
1454
|
+
_emit({"error": "feature-dir-required", "root": root})
|
|
1455
|
+
return 2
|
|
1456
|
+
r = _resolve_feature(args.feature_dir, root)
|
|
1457
|
+
print(f"[xref] feature-aggregates {r['feature']}: acts on {r['aggregates_csv']} "
|
|
1458
|
+
f"(from {r['source']}); owns {r['owned_csv']}", file=sys.stderr)
|
|
1459
|
+
r["root"] = root
|
|
1460
|
+
_emit(r)
|
|
1461
|
+
return 0
|
|
1462
|
+
|
|
1463
|
+
|
|
1050
1464
|
# ─────────────────────────────── validate ──────────────────────────────────
|
|
1051
1465
|
def cmd_validate(args):
|
|
1052
1466
|
"""Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
|
|
@@ -1196,28 +1610,28 @@ def cmd_tree(args):
|
|
|
1196
1610
|
"xrefs": [r[0] for r in xref_rows if len(r) >= 1]
|
|
1197
1611
|
}
|
|
1198
1612
|
|
|
1199
|
-
# 2.
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1613
|
+
# 2. Aggregates the specs tree knows that have no concept yet. A specs/ directory is a
|
|
1614
|
+
# feature or a bounded context, never an aggregate: each feature contributes the
|
|
1615
|
+
# aggregates it answers for, with the edges its plan declares.
|
|
1616
|
+
for fd in _feature_dirs(root, args.specs):
|
|
1617
|
+
spec_p, plan_p = os.path.join(fd, "spec.md"), _plan_path(fd)
|
|
1618
|
+
if not plan_p and not os.path.isfile(spec_p):
|
|
1619
|
+
continue
|
|
1620
|
+
for agg in _resolve_feature(fd, root)["owned"]:
|
|
1621
|
+
slug = _slug(agg)
|
|
1622
|
+
if slug in aggregates:
|
|
1623
|
+
continue
|
|
1624
|
+
dep_targets = []
|
|
1625
|
+
if plan_p:
|
|
1626
|
+
known_map = {s: s for s in aggregates}
|
|
1627
|
+
dep_targets = [e["target"] for e in _design_edges(_read(plan_p), agg, known_map)]
|
|
1628
|
+
aggregates[slug] = {
|
|
1629
|
+
"name": agg,
|
|
1630
|
+
"slug": slug,
|
|
1631
|
+
"concept_path": _rel(spec_p if os.path.isfile(spec_p) else fd, root),
|
|
1632
|
+
"deps": list(set(dep_targets)),
|
|
1633
|
+
"xrefs": []
|
|
1634
|
+
}
|
|
1221
1635
|
|
|
1222
1636
|
# 3. Fallback scan src/ directory if no aggregates in bundle/specs
|
|
1223
1637
|
if not aggregates:
|
|
@@ -1416,6 +1830,11 @@ def main(argv):
|
|
|
1416
1830
|
cp.add_argument("--bundle", default="knowledge")
|
|
1417
1831
|
cp.set_defaults(func=cmd_concept_paths)
|
|
1418
1832
|
|
|
1833
|
+
fa = sub.add_parser("feature-aggregates", help="read-only: the aggregate(s) a feature acts on — spec, then the boundary map, then the feature name")
|
|
1834
|
+
fa.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1835
|
+
fa.add_argument("--root", default="")
|
|
1836
|
+
fa.set_defaults(func=cmd_feature_aggregates)
|
|
1837
|
+
|
|
1419
1838
|
vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
|
|
1420
1839
|
vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
|
|
1421
1840
|
vv.add_argument("--root", default="")
|