aw-spec-cli 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/STACK-PROFILES.md +87 -8
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +30 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +47 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +41 -8
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +62 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +18 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +4 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +115 -27
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +5 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +225 -27
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +4 -15
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +39 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-recall-delta.orch.yaml +28 -7
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +3 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +104 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +159 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-workflow-model.orch.yaml +153 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +203 -212
- package/assets/.appweaver/scripts/bash/aw-xref.sh +4 -1
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +191 -224
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +4 -1
- package/assets/.appweaver/scripts/python/aw-crstate.py +127 -2
- package/assets/.appweaver/scripts/python/aw-gaps.py +119 -0
- package/assets/.appweaver/scripts/python/aw-payload-parity.py +17 -1
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +84 -4
- package/assets/.appweaver/scripts/python/aw-plangate.py +216 -0
- package/assets/.appweaver/scripts/python/aw-principles.py +108 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +6 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +141 -4
- package/assets/.appweaver/stack-profiles/angular.json +26 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +38 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +38 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +19 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +26 -0
- package/assets/.appweaver/stack-profiles/node-express.json +26 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +15 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +26 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +26 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +26 -0
- package/assets/.appweaver/templates/ui-model-template.md +67 -0
- package/assets/.appweaver/templates/workflow-model-template.md +62 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +3 -3
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +5 -123
- package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +784 -0
- package/assets/foundations/java/Hexagonal/pack-foundation.sh +357 -0
- package/assets/foundations/java/Hexagonal/unpack-foundation.sh +399 -0
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +44 -0
- package/package.json +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-plangate — the analyze-plan receipt, written and read in one place.
|
|
4
|
+
|
|
5
|
+
THE RECEIPT. `analyze-plan` records which bytes it checked against each other — spec.md,
|
|
6
|
+
the context's domain-model.md and every lane plan on disk — and the verdict it reached.
|
|
7
|
+
`implement` generates from the plans and deliberately does NOT read the spec, on the
|
|
8
|
+
strength of that receipt, so the pair of operations that write and read it is the hinge the
|
|
9
|
+
whole bootstrap path turns on.
|
|
10
|
+
|
|
11
|
+
WHY IT LIVES HERE. Both halves were embedded twice, as a bash heredoc and as a natively
|
|
12
|
+
re-written PowerShell function, and the rule they implement is a set of sha comparisons
|
|
13
|
+
with three deliberate asymmetries in it — an absent spec sha does not block, an absent
|
|
14
|
+
model sha does, and an unrecognised verdict records as `gaps`. That rule changed twice
|
|
15
|
+
(MF-19, then MF-20) and each time it had to be re-derived by hand on the PowerShell side
|
|
16
|
+
and kept in step by eye. One module both shells invoke removes the question: there is
|
|
17
|
+
nothing left to keep in step. Same reason aw-crstate.py owns "has this change merged".
|
|
18
|
+
|
|
19
|
+
NO BASELINE IS NOT "NOTHING MOVED". `spec_moved` / `model_moved` report "" where there is
|
|
20
|
+
nothing to compare against — no receipt, or a receipt that hashed nothing for that
|
|
21
|
+
document. "" is falsy in Python, in PowerShell and to the engine's require_true, so every
|
|
22
|
+
flag computed from them reads exactly as a false did; what it buys is that a reader can
|
|
23
|
+
tell "unchanged" from "unknown", which a bare false cannot express.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
aw-plangate.py stamp --feature-dir <p> --context-dir <p> --root <p>
|
|
27
|
+
[--spec-version <v>] [--mode <text>]
|
|
28
|
+
[--verdict conformant|routed-gaps|gaps] [--plan-gaps <n>]
|
|
29
|
+
[--routed <json list>]
|
|
30
|
+
-> {file, gated, spec_version, verdict, plan_gaps, routed_gaps, hashed[], count}
|
|
31
|
+
aw-plangate.py check --feature-dir <p> --context-dir <p> --root <p>
|
|
32
|
+
-> {file, tracked, gate_fresh, verdict, may_generate, plan_gaps, routed_gaps[],
|
|
33
|
+
routed_csv, spec_moved, model_moved, plans_changed[], plans_changed_csv,
|
|
34
|
+
gated, spec_version, reason}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import datetime
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import sys
|
|
43
|
+
|
|
44
|
+
RECEIPT = ".aw-plan-gate.json"
|
|
45
|
+
VERDICTS = ("conformant", "routed-gaps", "gaps")
|
|
46
|
+
|
|
47
|
+
# Every document the gate READ. A lane that is not declared has no plan on disk and is
|
|
48
|
+
# simply absent from the map — the check treats a name it does not carry as not gated,
|
|
49
|
+
# never as changed, so declaring a second lane later does not read as tampering.
|
|
50
|
+
#
|
|
51
|
+
# THE LEGACY NAMES ARE HERE FOR THE SAME REASON version-stamp CARRIES THEM. feature-paths
|
|
52
|
+
# resolves design.md / design.ui.md under the backend_plan / frontend_plan keys, so a tree
|
|
53
|
+
# that has not been through `plan` since the rename — every brownfield feature `adopt`
|
|
54
|
+
# produces — is GATED on those documents. Hashing only the new names would leave such a
|
|
55
|
+
# feature with an empty plan map: a receipt that looks tracked and fresh while the document
|
|
56
|
+
# implement actually generates from is unwatched.
|
|
57
|
+
PLAN_NAMES = ("backend-plan.md", "frontend-plan.md", "design.md", "design.ui.md")
|
|
58
|
+
|
|
59
|
+
NOTE = ("Written by every master-mode analyze-plan run. It records the bytes that were "
|
|
60
|
+
"checked against each other and the verdict on them, not a version — the plans' "
|
|
61
|
+
"**Version**: headers are stamped in lockstep by close-delta and cannot answer "
|
|
62
|
+
"this. implement reads it through plan-gate-check: moved bytes or verdict `gaps` "
|
|
63
|
+
"refuse; `routed-gaps` lists what /appweaver specify still has to lift.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sha(path):
|
|
67
|
+
try:
|
|
68
|
+
with open(path, "rb") as fh:
|
|
69
|
+
return hashlib.sha256(fh.read()).hexdigest()
|
|
70
|
+
except Exception:
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def stamp(fd, ctx, root, spec_version="", mode="master", verdict="conformant",
|
|
75
|
+
plan_gaps=0, routed=None):
|
|
76
|
+
plans = {}
|
|
77
|
+
for name in PLAN_NAMES:
|
|
78
|
+
h = sha(os.path.join(fd, name))
|
|
79
|
+
if h:
|
|
80
|
+
plans[name] = h
|
|
81
|
+
# An unrecognised verdict is recorded as `gaps`, the one that refuses.
|
|
82
|
+
verdict = (verdict or "").strip()
|
|
83
|
+
if verdict not in VERDICTS:
|
|
84
|
+
verdict = "gaps"
|
|
85
|
+
try:
|
|
86
|
+
plan_gaps = int(plan_gaps or 0)
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
plan_gaps = 0
|
|
89
|
+
routed = [str(x) for x in (routed or [])]
|
|
90
|
+
rec = {
|
|
91
|
+
"gated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
92
|
+
"gate_mode": mode or "master",
|
|
93
|
+
"spec_version": (spec_version or "").strip(),
|
|
94
|
+
"verdict": verdict,
|
|
95
|
+
"plan_gaps": plan_gaps,
|
|
96
|
+
"routed_gaps": routed,
|
|
97
|
+
"spec_sha": sha(os.path.join(fd, "spec.md")),
|
|
98
|
+
"model_sha": sha(os.path.join(ctx, "domain-model.md")),
|
|
99
|
+
"plans": plans,
|
|
100
|
+
"note": NOTE,
|
|
101
|
+
}
|
|
102
|
+
path = os.path.join(fd, RECEIPT)
|
|
103
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
104
|
+
fh.write(json.dumps(rec, indent=2) + "\n")
|
|
105
|
+
return {"file": os.path.relpath(path, root), "gated": rec["gated"],
|
|
106
|
+
"spec_version": rec["spec_version"], "verdict": verdict,
|
|
107
|
+
"plan_gaps": plan_gaps, "routed_gaps": len(routed),
|
|
108
|
+
"hashed": sorted(plans.keys()), "count": len(plans), "ok": True}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check(fd, ctx, root):
|
|
112
|
+
path = os.path.join(fd, RECEIPT)
|
|
113
|
+
try:
|
|
114
|
+
rec = json.load(open(path, encoding="utf-8"))
|
|
115
|
+
except Exception:
|
|
116
|
+
rec = None
|
|
117
|
+
out = {"file": os.path.relpath(path, root), "tracked": bool(rec), "gate_fresh": False,
|
|
118
|
+
"verdict": "", "may_generate": True, "plan_gaps": 0, "routed_gaps": [],
|
|
119
|
+
"routed_csv": "", "spec_moved": "", "model_moved": "", "plans_changed": [],
|
|
120
|
+
"plans_changed_csv": "", "gated": "", "spec_version": "", "reason": "", "ok": True}
|
|
121
|
+
if not rec:
|
|
122
|
+
# Never gated. Not a failure and not a stale plan — an unknown, and the only honest
|
|
123
|
+
# thing to report about a tree that has not run analyze-plan since this op existed.
|
|
124
|
+
out["reason"] = ("no .aw-plan-gate.json — this feature has not been through "
|
|
125
|
+
"analyze-plan in master mode since the gate began leaving a receipt")
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
out["gated"] = rec.get("gated", "")
|
|
129
|
+
out["spec_version"] = rec.get("spec_version", "")
|
|
130
|
+
# A receipt from before the verdict existed was only ever written on a clean pass.
|
|
131
|
+
out["verdict"] = rec.get("verdict") or "conformant"
|
|
132
|
+
out["plan_gaps"] = rec.get("plan_gaps") or 0
|
|
133
|
+
out["routed_gaps"] = [str(x) for x in (rec.get("routed_gaps") or [])]
|
|
134
|
+
out["routed_csv"] = "; ".join(out["routed_gaps"])
|
|
135
|
+
|
|
136
|
+
# A receipt that hashed nothing for a document has no baseline for it either, so the
|
|
137
|
+
# question is unanswerable for that one document and says so.
|
|
138
|
+
spec_base = rec.get("spec_sha") or ""
|
|
139
|
+
out["spec_moved"] = (spec_base != sha(os.path.join(fd, "spec.md"))) if spec_base else ""
|
|
140
|
+
# A model recorded as absent that is still absent has not moved. A context that GAINED a
|
|
141
|
+
# domain model since the gate has moved: check 4 never ran against it — that stays a
|
|
142
|
+
# move, including for a receipt predating model_sha, because a model nothing has been
|
|
143
|
+
# checked against is the condition this field exists to catch. Only a receipt carrying
|
|
144
|
+
# no model_sha with no model on disk either leaves nothing to compare.
|
|
145
|
+
model_base = rec.get("model_sha")
|
|
146
|
+
model_now = sha(os.path.join(ctx, "domain-model.md"))
|
|
147
|
+
out["model_moved"] = ((model_base or "") != model_now
|
|
148
|
+
if (model_base is not None or model_now) else "")
|
|
149
|
+
|
|
150
|
+
changed = []
|
|
151
|
+
for name, was in sorted((rec.get("plans") or {}).items()):
|
|
152
|
+
if sha(os.path.join(fd, name)) != was:
|
|
153
|
+
changed.append(name)
|
|
154
|
+
out["plans_changed"] = changed
|
|
155
|
+
out["plans_changed_csv"] = ", ".join(changed)
|
|
156
|
+
out["gate_fresh"] = not (out["spec_moved"] or out["model_moved"] or changed)
|
|
157
|
+
# Moved bytes refuse whatever the verdict was; unmoved bytes refuse only on `gaps`.
|
|
158
|
+
out["may_generate"] = out["gate_fresh"] and out["verdict"] != "gaps"
|
|
159
|
+
|
|
160
|
+
if not out["gate_fresh"]:
|
|
161
|
+
bits = []
|
|
162
|
+
if out["spec_moved"]:
|
|
163
|
+
bits.append("spec.md has changed")
|
|
164
|
+
if out["model_moved"]:
|
|
165
|
+
bits.append("domain-model.md has changed")
|
|
166
|
+
if changed:
|
|
167
|
+
bits.append("%s changed (a re-derive by `plan main`, or a hand edit)"
|
|
168
|
+
% ", ".join(changed))
|
|
169
|
+
out["reason"] = "since analyze-plan checked them on %s: %s" % (out["gated"],
|
|
170
|
+
"; ".join(bits))
|
|
171
|
+
elif out["verdict"] == "gaps":
|
|
172
|
+
out["reason"] = ("analyze-plan on %s left %s gap(s) a plan rewrite closes, unrepaired"
|
|
173
|
+
% (out["gated"], out["plan_gaps"] or "some"))
|
|
174
|
+
elif out["verdict"] == "routed-gaps":
|
|
175
|
+
out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
|
|
176
|
+
"analyze-plan checked on %s; it left %d gap(s) only /appweaver specify "
|
|
177
|
+
"can close: %s" % (out["gated"], len(out["routed_gaps"]),
|
|
178
|
+
out["routed_csv"]))
|
|
179
|
+
else:
|
|
180
|
+
out["reason"] = ("the spec, the model and every gated plan are byte-identical to what "
|
|
181
|
+
"analyze-plan passed on %s" % out["gated"])
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main():
|
|
186
|
+
ap = argparse.ArgumentParser(description="the analyze-plan receipt, written and read")
|
|
187
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
188
|
+
for name in ("stamp", "check"):
|
|
189
|
+
p = sub.add_parser(name)
|
|
190
|
+
p.add_argument("--feature-dir", required=True)
|
|
191
|
+
p.add_argument("--context-dir", required=True)
|
|
192
|
+
p.add_argument("--root", required=True)
|
|
193
|
+
if name == "stamp":
|
|
194
|
+
p.add_argument("--spec-version", default="")
|
|
195
|
+
p.add_argument("--mode", default="master")
|
|
196
|
+
p.add_argument("--verdict", default="conformant")
|
|
197
|
+
p.add_argument("--plan-gaps", default="0")
|
|
198
|
+
p.add_argument("--routed", default="[]")
|
|
199
|
+
args = ap.parse_args()
|
|
200
|
+
|
|
201
|
+
if args.cmd == "stamp":
|
|
202
|
+
try:
|
|
203
|
+
routed = json.loads(args.routed or "[]")
|
|
204
|
+
except Exception:
|
|
205
|
+
routed = []
|
|
206
|
+
if not isinstance(routed, list):
|
|
207
|
+
routed = []
|
|
208
|
+
out = stamp(args.feature_dir, args.context_dir, args.root, args.spec_version,
|
|
209
|
+
args.mode, args.verdict, args.plan_gaps, routed)
|
|
210
|
+
else:
|
|
211
|
+
out = check(args.feature_dir, args.context_dir, args.root)
|
|
212
|
+
sys.stdout.write(json.dumps(out) + "\n")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
main()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-principles — enumerate the ratified NON-NEGOTIABLE principles.
|
|
4
|
+
|
|
5
|
+
WHY A SCRIPT AND NOT A JUDGEMENT. `specify` settles [NEEDS CLARIFICATION] markers by
|
|
6
|
+
asking, and a non-interactive run answers with the FIRST option offered — so an agent's
|
|
7
|
+
option ordering becomes project policy unattended. The gate that checks those answers
|
|
8
|
+
against the constitution has to name the principle it is invoking, and an agent asked to
|
|
9
|
+
both find the principle and judge the contradiction can invent the principle it then
|
|
10
|
+
objects to. Splitting it means the factual half is checkable: this module reads the
|
|
11
|
+
headings and nothing else, the agent judges only whether a settled answer departs from
|
|
12
|
+
one of THESE, and a citation outside the list is visibly wrong.
|
|
13
|
+
|
|
14
|
+
It also answers "are there any?" deterministically. A project whose constitution ratifies
|
|
15
|
+
no NON-NEGOTIABLE principle has nothing for the gate to check, and `count` is what lets
|
|
16
|
+
the workflow skip it rather than asking an agent to confirm an empty set.
|
|
17
|
+
|
|
18
|
+
WHAT COUNTS. A principle heading of the form `### <id>. <title> (NON-NEGOTIABLE)`, which
|
|
19
|
+
is what all three constitution templates emit. The id is whatever precedes the first
|
|
20
|
+
period — roman numerals in the domain and technical halves, `UX-`-prefixed in the UI half
|
|
21
|
+
— so a project that renumbers or re-prefixes its series needs no change here.
|
|
22
|
+
|
|
23
|
+
HTML comments are stripped first. The Sync Impact Report at the top of every constitution
|
|
24
|
+
quotes principle headings while recording what a ratification changed, and counting those
|
|
25
|
+
would report principles that are not in force.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
aw-principles.py list --root <p> [--file <p> ...]
|
|
29
|
+
-> {files[], missing[], principles[{file, id, title, line}], count, titles_csv, ok}
|
|
30
|
+
|
|
31
|
+
Default files are the two halves `specify` loads — constitution.domain.md and
|
|
32
|
+
constitution.ui.md. The technical half is NOT a default: specify never loads it (a
|
|
33
|
+
spec is WHAT, not HOW), so a gate there could cite a principle the authoring agent was
|
|
34
|
+
never shown. A caller that loads it passes --file.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
DEFAULT_FILES = (".appweaver/memory/constitution.domain.md",
|
|
44
|
+
".appweaver/memory/constitution.ui.md")
|
|
45
|
+
|
|
46
|
+
# `### XXIII. Clinical Meaning is Bound, Never Free Text (NON-NEGOTIABLE)`
|
|
47
|
+
# `### UX-I. Accessibility Floor (NON-NEGOTIABLE)`
|
|
48
|
+
HEADING = re.compile(r"^###\s+([^.]+)\.\s+(.+?)\s*\(NON-NEGOTIABLE\)\s*$")
|
|
49
|
+
COMMENT = re.compile(r"<!--.*?-->", re.S)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read(path):
|
|
53
|
+
try:
|
|
54
|
+
with open(path, encoding="utf-8") as fh:
|
|
55
|
+
return fh.read()
|
|
56
|
+
except Exception:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def principles_in(path, rel):
|
|
61
|
+
"""[{file, id, title, line}] for one constitution half, in document order."""
|
|
62
|
+
text = read(path)
|
|
63
|
+
if text is None:
|
|
64
|
+
return None
|
|
65
|
+
# Blank the comments rather than deleting them, so reported line numbers still
|
|
66
|
+
# point at the heading in the file a reader opens.
|
|
67
|
+
text = COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), text)
|
|
68
|
+
out = []
|
|
69
|
+
for n, line in enumerate(text.split("\n"), 1):
|
|
70
|
+
m = HEADING.match(line)
|
|
71
|
+
if m:
|
|
72
|
+
out.append({"file": rel, "id": m.group(1).strip(),
|
|
73
|
+
"title": m.group(2).strip(), "line": n})
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def listing(root, files):
|
|
78
|
+
seen, missing, found = [], [], []
|
|
79
|
+
for rel in files:
|
|
80
|
+
path = rel if os.path.isabs(rel) else os.path.join(root, rel)
|
|
81
|
+
got = principles_in(path, rel)
|
|
82
|
+
if got is None:
|
|
83
|
+
missing.append(rel)
|
|
84
|
+
continue
|
|
85
|
+
seen.append(rel)
|
|
86
|
+
found.extend(got)
|
|
87
|
+
return {"files": seen, "missing": missing, "principles": found,
|
|
88
|
+
"count": len(found),
|
|
89
|
+
"titles_csv": "; ".join("%s (%s %s)" % (p["title"], os.path.basename(p["file"]),
|
|
90
|
+
p["id"]) for p in found),
|
|
91
|
+
"ok": True}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
ap = argparse.ArgumentParser(description="enumerate ratified NON-NEGOTIABLE principles")
|
|
96
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
97
|
+
p = sub.add_parser("list")
|
|
98
|
+
p.add_argument("--root", required=True)
|
|
99
|
+
p.add_argument("--file", action="append", default=[],
|
|
100
|
+
help="a constitution half to scan (repeatable; defaults to domain + ui)")
|
|
101
|
+
args = ap.parse_args()
|
|
102
|
+
|
|
103
|
+
out = listing(args.root, args.file or list(DEFAULT_FILES))
|
|
104
|
+
sys.stdout.write(json.dumps(out) + "\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|
|
@@ -917,6 +917,12 @@ def kind_block(repo, lanes, kind):
|
|
|
917
917
|
kind + "_language": prof.get("language", ""),
|
|
918
918
|
kind + "_ecosystem": prof.get("ecosystem", ""),
|
|
919
919
|
kind + "_title": prof.get("title", "") or lane.get("stack", ""),
|
|
920
|
+
# Names this stack's AMBIENT scope already defines — C#'s implicit usings, the
|
|
921
|
+
# browser and TS globals, java.lang, typing. A domain noun that matches one is
|
|
922
|
+
# not a naming mistake (the plan's names are the ubiquitous language) but it does
|
|
923
|
+
# have to be written qualified, or the compiler cannot tell the two apart.
|
|
924
|
+
kind + "_reserved_names": prof.get("reserved_names", []),
|
|
925
|
+
kind + "_reserved_csv": ", ".join(prof.get("reserved_names", [])),
|
|
920
926
|
kind + "_root": root,
|
|
921
927
|
kind + "_source_path": root,
|
|
922
928
|
kind + "_root_exists": root_on_disk(repo, root),
|
|
@@ -749,6 +749,29 @@ def _referrer_closure(aggregate, bundle_abs):
|
|
|
749
749
|
return sorted(seen)
|
|
750
750
|
|
|
751
751
|
|
|
752
|
+
def _has_code(name, bundle_abs):
|
|
753
|
+
"""Does this aggregate have code on disk yet?
|
|
754
|
+
|
|
755
|
+
"Depends on me" and "has code that could break" are different questions, and the
|
|
756
|
+
closure alone answers only the first. Edges come from the PLAN — a feature that has
|
|
757
|
+
been planned and not implemented is a genuine dependant with nothing to re-test, and
|
|
758
|
+
`record` stubs a used sibling's concept when it has none, so its name enters the
|
|
759
|
+
closure either way. `source_files` is the difference: a stub is written with it empty
|
|
760
|
+
and implement's concept-upsert fills it from what it generated.
|
|
761
|
+
"""
|
|
762
|
+
path = _resolve_concept(name, bundle_abs)
|
|
763
|
+
if not path:
|
|
764
|
+
return False
|
|
765
|
+
fm, _ = _read_fm(_read(path))
|
|
766
|
+
files = fm.get("source_files")
|
|
767
|
+
if isinstance(files, list):
|
|
768
|
+
return any(str(f).strip() for f in files)
|
|
769
|
+
# The front-matter reader returns a BLOCK list as a list and anything else as the raw
|
|
770
|
+
# scalar, so the `source_files: []` a stub carries arrives as the STRING "[]" — which
|
|
771
|
+
# is empty, and truthy to anyone who does not look inside it.
|
|
772
|
+
return bool(str(files or "").strip(" \t[]"))
|
|
773
|
+
|
|
774
|
+
|
|
752
775
|
def _concept_title(path):
|
|
753
776
|
fm, _ = _read_fm(_read(path))
|
|
754
777
|
t = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
@@ -995,15 +1018,30 @@ def cmd_list(args):
|
|
|
995
1018
|
closure = []
|
|
996
1019
|
for agg in aggregates:
|
|
997
1020
|
closure.extend(c for c in _referrer_closure(agg, bundle_abs) if c not in closure)
|
|
1021
|
+
# THE CLOSURE ANSWERS TWO QUESTIONS AND A CALLER USUALLY WANTS ONE. Edges are read off
|
|
1022
|
+
# the PLAN, so a dependant can be fully planned and hold no code at all. A caller that
|
|
1023
|
+
# re-runs TESTS over the blast radius wants `built`: an unbuilt dependant has nothing
|
|
1024
|
+
# to run, and selecting it turns a fence into a failure over an empty test set. A
|
|
1025
|
+
# caller reporting what this change reaches wants the whole closure, with `unbuilt`
|
|
1026
|
+
# named so nobody reads its absence from a test run as "unaffected".
|
|
1027
|
+
built = [c for c in closure if _has_code(c, bundle_abs)]
|
|
1028
|
+
unbuilt = [c for c in closure if c not in built]
|
|
998
1029
|
print(f"[xref] {', '.join(aggregates)}: {len(users)} downstream user(s) "
|
|
999
|
-
f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}"
|
|
1030
|
+
f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}"
|
|
1031
|
+
+ (f"; {len(unbuilt)} planned but not implemented — {', '.join(unbuilt)}"
|
|
1032
|
+
if unbuilt else ""), file=sys.stderr)
|
|
1000
1033
|
_emit({"aggregate": aggregate, "aggregates": aggregates,
|
|
1001
1034
|
"concept": _rel(path, root) if path else "",
|
|
1002
1035
|
"users": users, "count": len(users), "impact_report": out_rel, "root": root,
|
|
1003
1036
|
"referrers": direct, "referrers_csv": ",".join(direct),
|
|
1004
1037
|
"referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
|
|
1005
1038
|
"referrer_count": len(direct), "referrer_closure_count": len(closure),
|
|
1006
|
-
"indirect_count": max(0, len(closure) - len(direct))
|
|
1039
|
+
"indirect_count": max(0, len(closure) - len(direct)),
|
|
1040
|
+
# The closure split by whether the dependant has code yet.
|
|
1041
|
+
"built_closure": built, "built_closure_csv": ",".join(built),
|
|
1042
|
+
"built_count": len(built),
|
|
1043
|
+
"unbuilt_closure": unbuilt, "unbuilt_closure_csv": ",".join(unbuilt),
|
|
1044
|
+
"unbuilt_count": len(unbuilt)})
|
|
1007
1045
|
return 0
|
|
1008
1046
|
|
|
1009
1047
|
|
|
@@ -1229,15 +1267,39 @@ def cmd_concept_paths(args):
|
|
|
1229
1267
|
missing = [s for s in mapped if s not in existing]
|
|
1230
1268
|
scope = "feature"
|
|
1231
1269
|
|
|
1270
|
+
# NARROWED TO ONE LANE'S SUBTREES. A concept spans every lane that realises the
|
|
1271
|
+
# aggregate — on a fullstack feature that is the service files AND the web files in
|
|
1272
|
+
# one list. A caller generating one lane must not be handed the other's: `implement`
|
|
1273
|
+
# tells each lane to read the code it is about to extend, and a backend pass shown the
|
|
1274
|
+
# UI's files is being invited to touch a subtree it is forbidden to write. Absent, the
|
|
1275
|
+
# whole list stands, which is what every existing caller wants.
|
|
1276
|
+
under = [u.strip().replace(os.sep, "/").lstrip("./") for u in (args.under or []) if u and u.strip()]
|
|
1277
|
+
under = [u if u.endswith("/") else u + "/" for u in under]
|
|
1278
|
+
scoped_out = 0
|
|
1279
|
+
if under:
|
|
1280
|
+
def inside(p):
|
|
1281
|
+
q = p.replace(os.sep, "/").lstrip("./")
|
|
1282
|
+
return any(q.startswith(u) for u in under)
|
|
1283
|
+
before = len(existing)
|
|
1284
|
+
existing = [p for p in existing if inside(p)]
|
|
1285
|
+
declared = [p for p in declared if inside(p)]
|
|
1286
|
+
missing = [p for p in missing if inside(p)]
|
|
1287
|
+
scoped_out = before - len(existing)
|
|
1288
|
+
|
|
1232
1289
|
note = "" if path else " — no concept recorded yet"
|
|
1233
1290
|
drift = f", {len(missing)} declared-but-missing" if missing else ""
|
|
1234
|
-
|
|
1291
|
+
narrowed = f", {scoped_out} outside {' '.join(under)}" if scoped_out else ""
|
|
1292
|
+
print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{narrowed}{note}"
|
|
1235
1293
|
f" (scope: {scope})", file=sys.stderr)
|
|
1236
1294
|
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
1237
1295
|
"found": bool(path), "scope": scope, "paths": existing, "count": len(existing),
|
|
1238
1296
|
"csv": ",".join(existing), "declared": declared,
|
|
1239
1297
|
"declared_count": len(declared), "missing": missing,
|
|
1240
|
-
"missing_count": len(missing), "
|
|
1298
|
+
"missing_count": len(missing), "under": under, "excluded_count": scoped_out,
|
|
1299
|
+
# One block per line, for a prompt that lists what the lane already has.
|
|
1300
|
+
"paths_text": "\n".join(" " + p for p in existing) if existing else
|
|
1301
|
+
" (none — nothing of this aggregate is on disk in this lane yet)",
|
|
1302
|
+
"root": root})
|
|
1241
1303
|
return 0
|
|
1242
1304
|
|
|
1243
1305
|
|
|
@@ -1418,6 +1480,70 @@ def _resolve_feature(fd, root):
|
|
|
1418
1480
|
"shared_with": shared_with, "shared_with_csv": ", ".join(shared_with)}
|
|
1419
1481
|
|
|
1420
1482
|
|
|
1483
|
+
def _crstate():
|
|
1484
|
+
"""aw-crstate, loaded by path — it owns the readers for a change's own documents
|
|
1485
|
+
(`**Aggregates touched**`, the model delta beside the master or in its archive) and
|
|
1486
|
+
duplicating them here is how two answers to one question start to drift."""
|
|
1487
|
+
import importlib.util
|
|
1488
|
+
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-crstate.py")
|
|
1489
|
+
spec = importlib.util.spec_from_file_location("aw_crstate", p)
|
|
1490
|
+
if not spec or not spec.loader:
|
|
1491
|
+
return None
|
|
1492
|
+
mod = importlib.util.module_from_spec(spec)
|
|
1493
|
+
try:
|
|
1494
|
+
spec.loader.exec_module(mod)
|
|
1495
|
+
except Exception:
|
|
1496
|
+
return None
|
|
1497
|
+
return mod
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def cmd_co_owners(args):
|
|
1501
|
+
"""THE FEATURES THAT SHARE THIS CHANGE'S AGGREGATES — the half of a merge's blast
|
|
1502
|
+
radius that referrer edges cannot see.
|
|
1503
|
+
|
|
1504
|
+
`list` answers "who DEPENDS on this aggregate": features that reach it through a
|
|
1505
|
+
Cross-Reference edge, transitively. That is a real blast radius and it is not the whole
|
|
1506
|
+
one. A feature that CO-OWNS the aggregate holds no edge to it at all — it is not a user
|
|
1507
|
+
of `Encounter`, it is one of the capabilities Encounter is made of, and its code lives
|
|
1508
|
+
in the very files this merge rewrites. `Encounter.cs` is extended by every feature that
|
|
1509
|
+
claims Encounter, so a change to it can break a co-owner without touching one referrer.
|
|
1510
|
+
|
|
1511
|
+
SCOPED TO WHAT THE CHANGE TOUCHES, not to everything the feature owns. The delta's own
|
|
1512
|
+
`**Aggregates touched**` header is the authority: a feature owning Encounter and
|
|
1513
|
+
CareTeam whose change touches only Encounter must not drag CareTeam's co-owners through
|
|
1514
|
+
a regression fence they cannot have been affected by. Where that header cannot be read
|
|
1515
|
+
the feature's owned aggregates stand in — wider than needed, never narrower, and
|
|
1516
|
+
`source` says which answer this is so a caller can report it honestly.
|
|
1517
|
+
"""
|
|
1518
|
+
root = _root(args.root)
|
|
1519
|
+
fd = args.feature_dir
|
|
1520
|
+
fd_abs = fd if os.path.isabs(fd) else os.path.join(root, fd)
|
|
1521
|
+
feature = _feature_from_dir(fd_abs)
|
|
1522
|
+
ctx = args.context_dir or os.path.dirname(os.path.normpath(fd_abs))
|
|
1523
|
+
|
|
1524
|
+
touched, source = [], "delta"
|
|
1525
|
+
cs = _crstate() if args.cr else None
|
|
1526
|
+
if cs:
|
|
1527
|
+
touched = sorted(cs.aggregates(cs.model_delta_of(ctx, args.cr)))
|
|
1528
|
+
if not touched:
|
|
1529
|
+
# No readable model delta: a spec-only change, or a header this cannot parse.
|
|
1530
|
+
touched = _resolve_feature(fd_abs, root)["owned"]
|
|
1531
|
+
source = "feature-owned"
|
|
1532
|
+
|
|
1533
|
+
own = {_key(a) for a in touched}
|
|
1534
|
+
features = sorted({v["feature"] for v in _feature_index(root).values()
|
|
1535
|
+
if _key(v["feature"]) != _key(feature)
|
|
1536
|
+
and own & {_key(a) for a in v["claims"]}}, key=str.lower)
|
|
1537
|
+
|
|
1538
|
+
print(f"[xref] co-owners {feature}: {len(features)} feature(s) share "
|
|
1539
|
+
f"{', '.join(touched) or '(no aggregate)'} (source: {source})", file=sys.stderr)
|
|
1540
|
+
_emit({"feature": feature, "cr_id": args.cr, "source": source,
|
|
1541
|
+
"aggregates": touched, "aggregates_csv": ", ".join(touched),
|
|
1542
|
+
"features": features, "features_csv": ", ".join(features),
|
|
1543
|
+
"count": len(features), "root": root})
|
|
1544
|
+
return 0
|
|
1545
|
+
|
|
1546
|
+
|
|
1421
1547
|
def cmd_feature_aggregates(args):
|
|
1422
1548
|
"""WHICH AGGREGATE(S) A FEATURE ACTS ON — the name every command that reads the
|
|
1423
1549
|
bounded context's domain-model.md needs, because the model is organised by
|
|
@@ -1826,10 +1952,21 @@ def main(argv):
|
|
|
1826
1952
|
cp = sub.add_parser("concept-paths", help="read-only: the slice's source_files (replaces design-paths)")
|
|
1827
1953
|
cp.add_argument("--aggregate", default="")
|
|
1828
1954
|
cp.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1955
|
+
cp.add_argument("--under", action="append", default=[],
|
|
1956
|
+
help="keep only paths inside this subtree; repeatable. Omitted, every "
|
|
1957
|
+
"path the concept records is returned.")
|
|
1829
1958
|
cp.add_argument("--root", default="")
|
|
1830
1959
|
cp.add_argument("--bundle", default="knowledge")
|
|
1831
1960
|
cp.set_defaults(func=cmd_concept_paths)
|
|
1832
1961
|
|
|
1962
|
+
cw = sub.add_parser("co-owners", help="read-only: the features that SHARE this change's aggregates (no referrer edge, same files)")
|
|
1963
|
+
cw.add_argument("--cr", default="", help="the change id; its **Aggregates touched** scopes the answer")
|
|
1964
|
+
cw.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1965
|
+
cw.add_argument("--context-dir", dest="context_dir", default="",
|
|
1966
|
+
help="the bounded context directory; defaults to the feature dir's parent")
|
|
1967
|
+
cw.add_argument("--root", default="")
|
|
1968
|
+
cw.set_defaults(func=cmd_co_owners)
|
|
1969
|
+
|
|
1833
1970
|
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
1971
|
fa.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1835
1972
|
fa.add_argument("--root", default="")
|
|
@@ -12,5 +12,31 @@
|
|
|
12
12
|
"prototype_run": "npm start --prefix prototype",
|
|
13
13
|
"detect": [
|
|
14
14
|
"web/angular.json"
|
|
15
|
+
],
|
|
16
|
+
"reserved_names": [
|
|
17
|
+
"Array",
|
|
18
|
+
"Date",
|
|
19
|
+
"Document",
|
|
20
|
+
"Element",
|
|
21
|
+
"Error",
|
|
22
|
+
"Event",
|
|
23
|
+
"Headers",
|
|
24
|
+
"History",
|
|
25
|
+
"Image",
|
|
26
|
+
"Location",
|
|
27
|
+
"Map",
|
|
28
|
+
"Node",
|
|
29
|
+
"Notification",
|
|
30
|
+
"Object",
|
|
31
|
+
"Option",
|
|
32
|
+
"Range",
|
|
33
|
+
"Record",
|
|
34
|
+
"Request",
|
|
35
|
+
"Response",
|
|
36
|
+
"Selection",
|
|
37
|
+
"Set",
|
|
38
|
+
"Storage",
|
|
39
|
+
"Text",
|
|
40
|
+
"Worker"
|
|
15
41
|
]
|
|
16
42
|
}
|
|
@@ -13,5 +13,43 @@
|
|
|
13
13
|
"detect": [
|
|
14
14
|
"web/**/*.csproj",
|
|
15
15
|
"web/**/wwwroot/index.html"
|
|
16
|
+
],
|
|
17
|
+
"reserved_names": [
|
|
18
|
+
"Activity",
|
|
19
|
+
"Array",
|
|
20
|
+
"Attribute",
|
|
21
|
+
"Buffer",
|
|
22
|
+
"Comparer",
|
|
23
|
+
"Convert",
|
|
24
|
+
"Delegate",
|
|
25
|
+
"Dictionary",
|
|
26
|
+
"Directory",
|
|
27
|
+
"Endpoint",
|
|
28
|
+
"Environment",
|
|
29
|
+
"Exception",
|
|
30
|
+
"File",
|
|
31
|
+
"Guid",
|
|
32
|
+
"Index",
|
|
33
|
+
"List",
|
|
34
|
+
"Lock",
|
|
35
|
+
"Math",
|
|
36
|
+
"Object",
|
|
37
|
+
"Path",
|
|
38
|
+
"Progress",
|
|
39
|
+
"Queue",
|
|
40
|
+
"Random",
|
|
41
|
+
"Range",
|
|
42
|
+
"Results",
|
|
43
|
+
"Route",
|
|
44
|
+
"Stack",
|
|
45
|
+
"Stream",
|
|
46
|
+
"String",
|
|
47
|
+
"Task",
|
|
48
|
+
"Thread",
|
|
49
|
+
"Timer",
|
|
50
|
+
"Tuple",
|
|
51
|
+
"Type",
|
|
52
|
+
"Uri",
|
|
53
|
+
"Version"
|
|
16
54
|
]
|
|
17
55
|
}
|
|
@@ -12,5 +12,43 @@
|
|
|
12
12
|
"detect": [
|
|
13
13
|
"*.sln",
|
|
14
14
|
"src/**/*.csproj"
|
|
15
|
+
],
|
|
16
|
+
"reserved_names": [
|
|
17
|
+
"Activity",
|
|
18
|
+
"Array",
|
|
19
|
+
"Attribute",
|
|
20
|
+
"Buffer",
|
|
21
|
+
"Comparer",
|
|
22
|
+
"Convert",
|
|
23
|
+
"Delegate",
|
|
24
|
+
"Dictionary",
|
|
25
|
+
"Directory",
|
|
26
|
+
"Endpoint",
|
|
27
|
+
"Environment",
|
|
28
|
+
"Exception",
|
|
29
|
+
"File",
|
|
30
|
+
"Guid",
|
|
31
|
+
"Index",
|
|
32
|
+
"List",
|
|
33
|
+
"Lock",
|
|
34
|
+
"Math",
|
|
35
|
+
"Object",
|
|
36
|
+
"Path",
|
|
37
|
+
"Progress",
|
|
38
|
+
"Queue",
|
|
39
|
+
"Random",
|
|
40
|
+
"Range",
|
|
41
|
+
"Results",
|
|
42
|
+
"Route",
|
|
43
|
+
"Stack",
|
|
44
|
+
"Stream",
|
|
45
|
+
"String",
|
|
46
|
+
"Task",
|
|
47
|
+
"Thread",
|
|
48
|
+
"Timer",
|
|
49
|
+
"Tuple",
|
|
50
|
+
"Type",
|
|
51
|
+
"Uri",
|
|
52
|
+
"Version"
|
|
15
53
|
]
|
|
16
54
|
}
|