aw-spec-cli 0.1.32 → 0.1.34
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/README.md +76 -81
- package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
- package/assets/.appweaver/STACK-PROFILES.md +95 -60
- package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
- package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
- package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
- package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
- package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
- package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
- package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
- package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
- package/assets/.appweaver/templates/api-plan-template.md +107 -0
- package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
- package/assets/.appweaver/templates/delta-design-template.md +25 -23
- package/assets/.appweaver/templates/delta-spec-template.md +3 -3
- package/assets/.appweaver/templates/design-template.md +20 -42
- package/assets/.appweaver/templates/domain-model-template.md +3 -0
- package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
- package/assets/.appweaver/templates/ui-design-template.md +4 -4
- package/assets/foundations/foundation.spec.template.txt +25 -18
- package/assets/guardrails/README.md +61 -25
- package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
- package/assets/guardrails/methodology/ux/content-design.md +34 -0
- package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
- package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
- package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
- package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
- package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
- package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
- package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
- package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
- package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
- package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
- package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
- package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +216 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/utils/config.js +1 -1
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/foundation.d.ts +36 -0
- package/dist/utils/foundation.d.ts.map +1 -0
- package/dist/utils/foundation.js +109 -0
- package/dist/utils/foundation.js.map +1 -0
- package/dist/utils/paths.js +1 -1
- package/dist/utils/paths.js.map +1 -1
- package/package.json +1 -1
|
@@ -66,7 +66,8 @@ Usage:
|
|
|
66
66
|
aw-foundation.py spec-capture --lane <id> [--kind <k>] (--file <path> | --input <path|text>)
|
|
67
67
|
[--app <App>]
|
|
68
68
|
-> {"ok": bool, "spec_path": ".appweaver/foundation/<id>.spec.md", "source": "...",
|
|
69
|
-
"
|
|
69
|
+
"contract_write": "...", "contract_run": "...", "contract": "...", "client": "...",
|
|
70
|
+
"settings_text": "...", ...}
|
|
70
71
|
aw-foundation.py lane-files --source <path> [--test <path>] [--workdir <path>] [--profile <id>]
|
|
71
72
|
-> {"has_files": bool, "count": n, "sample": [...], "generated": [...],
|
|
72
73
|
"root_files": [...], "folders": [...], "workdir": "...", "summary": "...",
|
|
@@ -83,8 +84,8 @@ Usage:
|
|
|
83
84
|
"stale": [...], "note": "..."}
|
|
84
85
|
aw-foundation.py rules-done [--state <repo-relative folder>] -> {"ok": true, "removed": bool}
|
|
85
86
|
aw-foundation.py pack-lane --name <Foundation> --app <App>
|
|
86
|
-
-> {"found": bool, "file": "...", "
|
|
87
|
-
"client": "...", "reason": "..."}
|
|
87
|
+
-> {"found": bool, "file": "...", "contract_write": "...", "contract_run": "...",
|
|
88
|
+
"contract": "...", "client": "...", "reason": "..."}
|
|
88
89
|
|
|
89
90
|
A pack may also carry LANE SETTINGS, `<pack name>.lane.json` beside it: values the lane
|
|
90
91
|
records in stacks.json once the pack is unpacked (see pack_lane).
|
|
@@ -205,8 +206,8 @@ LABEL_SEP = " — "
|
|
|
205
206
|
MANUAL = "[manual spec]"
|
|
206
207
|
SKIP = "[skip]"
|
|
207
208
|
HEADER_KEYS = ("kind", "profiles", "title", "foundation", "sentinel", "files",
|
|
208
|
-
"contract_run", "contract", "client")
|
|
209
|
-
# A key may be written with hyphens (`contract-
|
|
209
|
+
"contract_write", "contract_run", "contract", "client")
|
|
210
|
+
# A key may be written with hyphens (`contract-write`); it is read as the underscored name.
|
|
210
211
|
HEADER_RE = re.compile(r"^#\s*([a-z][a-z_-]*)\s*:\s*(.*?)\s*$")
|
|
211
212
|
|
|
212
213
|
|
|
@@ -397,8 +398,8 @@ def resolve_choice(repo, selection, kind="", profile="", ecosystems=None):
|
|
|
397
398
|
SPEC_LIMIT = 200_000
|
|
398
399
|
|
|
399
400
|
|
|
400
|
-
SETTING_LABELS = (("
|
|
401
|
-
("client", "client command"))
|
|
401
|
+
SETTING_LABELS = (("contract_write", "document command"), ("contract_run", "start command"),
|
|
402
|
+
("contract", "document URL"), ("client", "client command"))
|
|
402
403
|
|
|
403
404
|
|
|
404
405
|
def capture_spec(repo, lane, kind="", file="", text="", app=""):
|
|
@@ -410,8 +411,8 @@ def capture_spec(repo, lane, kind="", file="", text="", app=""):
|
|
|
410
411
|
shaped like a path (a separator, or a file extension), which is a path that was
|
|
411
412
|
mistyped: capturing it as a fifteen-character spec would build a project from it.
|
|
412
413
|
|
|
413
|
-
Also returns the lane settings the spec's header gives — `# contract-
|
|
414
|
-
`# contract:` and `# client:`, with `<App>` replaced by `app` — which foundation
|
|
414
|
+
Also returns the lane settings the spec's header gives — `# contract-write:`,
|
|
415
|
+
`# contract-run:`, `# contract:` and `# client:`, with `<App>` replaced by `app` — which foundation
|
|
415
416
|
records on the lane once the project is built. They are read here, from the spec as
|
|
416
417
|
given, because the copy opens with a comment and has no header left to read.
|
|
417
418
|
"""
|
|
@@ -887,7 +888,7 @@ def pack_rules(repo, name, app, out, reference=False, state=MERGE_STATE):
|
|
|
887
888
|
|
|
888
889
|
|
|
889
890
|
LANE_FORMAT = "aw-spec-pack-lane/v1"
|
|
890
|
-
LANE_KEYS = ("contract_run", "contract", "client")
|
|
891
|
+
LANE_KEYS = ("contract_write", "contract_run", "contract", "client")
|
|
891
892
|
|
|
892
893
|
|
|
893
894
|
def pack_lane(repo, name, app):
|
|
@@ -895,9 +896,11 @@ def pack_lane(repo, name, app):
|
|
|
895
896
|
sentinel renamed as in the pack's own files.
|
|
896
897
|
|
|
897
898
|
These are what only the pack knows about the project it scaffolds — chiefly how to
|
|
898
|
-
|
|
899
|
-
an orchestration host is not the lane's
|
|
900
|
-
|
|
899
|
+
write its service's API document (`contract_write`), or how to start the service on
|
|
900
|
+
its own to fetch it, which for a solution with an orchestration host is not the lane's
|
|
901
|
+
`run` command. The backend gates check the code's API against the plan with that
|
|
902
|
+
document. A pack with no such file has nothing to add, and the lane keeps its stack
|
|
903
|
+
profile's values.
|
|
901
904
|
|
|
902
905
|
{"format": "aw-spec-pack-lane/v1",
|
|
903
906
|
"contract_run": "dotnet run --project src/<Sentinel>.Service -- --urls http://localhost:5000",
|
|
@@ -12,6 +12,9 @@ The checks already tag each finding with the command that closes it (`fix`). Thi
|
|
|
12
12
|
tag into the routing the caller reports: per-route counts, the ids involved, and a line of
|
|
13
13
|
prose per route that a gate message can print without assembling it itself.
|
|
14
14
|
|
|
15
|
+
A finding that a requirement has no test naming it is closed by `fix-test-coverage`, which
|
|
16
|
+
writes tests and never code, so it has its own route, `tests`, after the code's.
|
|
17
|
+
|
|
15
18
|
A finding whose `fix` is missing or unrecognised is NOT quietly folded into the biggest
|
|
16
19
|
bucket. It goes to `review` and is counted there, because a finding nobody can be told to
|
|
17
20
|
close is worse than one routed to the wrong place: the first is visible, the second is not.
|
|
@@ -19,7 +22,7 @@ close is worse than one routed to the wrong place: the first is visible, the sec
|
|
|
19
22
|
Usage:
|
|
20
23
|
aw-gaps.py route --gaps '<json>' [--gaps '<json>']...
|
|
21
24
|
-> {total, implement_count/_refs/_text, plan_count/…, specify_count/…,
|
|
22
|
-
review_count/…, high_count, summary, routes}
|
|
25
|
+
tests_count/…, review_count/…, high_count, summary, routes}
|
|
23
26
|
"""
|
|
24
27
|
|
|
25
28
|
import argparse
|
|
@@ -27,8 +30,9 @@ import json
|
|
|
27
30
|
import sys
|
|
28
31
|
|
|
29
32
|
# The commands a finding can be routed to, in the order a reader should act on them:
|
|
30
|
-
# fix the spec before the plan that realises it,
|
|
31
|
-
|
|
33
|
+
# fix the spec before the plan that realises it, the plan before the code it maps to, and
|
|
34
|
+
# the code before its tests.
|
|
35
|
+
ROUTES = ["specify", "plan", "implement", "tests", "review"]
|
|
32
36
|
|
|
33
37
|
HOW = {
|
|
34
38
|
"specify": "/appweaver specify {cr} — the delta spec or the model delta is what is missing",
|
|
@@ -36,6 +40,8 @@ HOW = {
|
|
|
36
40
|
"(then /appweaver analyze-plan-delta {cr} and /appweaver implement {cr})",
|
|
37
41
|
"implement": "/appweaver implement {cr} — the code is missing or does not do what the "
|
|
38
42
|
"change asked",
|
|
43
|
+
"tests": "/appweaver fix-test-coverage {cr} — no test names these requirements, so "
|
|
44
|
+
"write their tests",
|
|
39
45
|
"review": "no `fix` command was recorded for these — read them in the report and decide",
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -62,6 +68,8 @@ def load(raw_list):
|
|
|
62
68
|
|
|
63
69
|
def route_of(gap):
|
|
64
70
|
fix = str(gap.get("fix") or "").strip().lower()
|
|
71
|
+
if "fix-test-coverage" in fix:
|
|
72
|
+
return "tests"
|
|
65
73
|
# The tag may arrive as a whole command line ("/appweaver plan CR-…"); the verb is what
|
|
66
74
|
# routes, and anything else is unrecognised rather than guessed at.
|
|
67
75
|
for r in ("specify", "plan", "implement"):
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-model-guard — what a whole-file rewrite of a context's domain-model.md must keep.
|
|
4
|
+
|
|
5
|
+
THE RISK. Two steps re-author the bounded context's domain-model.md whole: specify's
|
|
6
|
+
boot_model_generate and adopt's reverse_model. The engine writes the agent's reply over the
|
|
7
|
+
entire file. From a context's second feature on, that file already holds what other
|
|
8
|
+
features rely on — their aggregate sections, the BR-/XBR- rules their specs cite by id, the
|
|
9
|
+
ledger close-delta keeps — and the prompt's "do not rewrite, renumber or tidy" was the only
|
|
10
|
+
thing between a slip and a sibling spec citing a rule that no longer exists. Nothing reads
|
|
11
|
+
a finished sibling again until its next change, so such a loss stays silent.
|
|
12
|
+
|
|
13
|
+
THE CHECK. `snapshot` copies the model aside before the step; `check` compares the rewrite
|
|
14
|
+
with that copy. What must survive:
|
|
15
|
+
- every `## Aggregate: <Name>` section, under exactly that name;
|
|
16
|
+
- every BR-n and XBR-n, defined once, under the same `##` section, with the same rule
|
|
17
|
+
text (whitespace, line wrapping, bold and code markers ignored);
|
|
18
|
+
- each aggregate's `**Serving specs**` names;
|
|
19
|
+
- the Ubiquitous Language terms, and the other contexts the Context Map names;
|
|
20
|
+
- every Change History row, cell for cell;
|
|
21
|
+
- the `**Context version**` value.
|
|
22
|
+
Additions are free: new aggregates, rules, entities, terms, rows and Serving specs names.
|
|
23
|
+
A term's meaning, a Context Map row's other cells, and the prose of entities, value objects
|
|
24
|
+
and events are not compared, because a later feature may legitimately extend them — a new
|
|
25
|
+
event crossing to an existing neighbour context lands in that neighbour's row.
|
|
26
|
+
|
|
27
|
+
ON A LOSS the model is put back from the copy byte for byte, the rewrite is kept beside it
|
|
28
|
+
as domain-model.rejected.md, and the result lists every loss; the workflow's gate stops on
|
|
29
|
+
it. A clean rewrite deletes the copy. Both commands exit 0 — the gate owns the stop.
|
|
30
|
+
|
|
31
|
+
NOT RECORDED: template placeholders — a `[bracketed]` aggregate name, rule text, term,
|
|
32
|
+
Serving specs name or Context Map name, and any Change History row with a bracketed cell —
|
|
33
|
+
so a context's first feature, written over the template, is held to nothing but the
|
|
34
|
+
Context version. The engine's MOCK transport writes a stub, not a model, so a mock rewrite
|
|
35
|
+
is reported as skipped rather than checked.
|
|
36
|
+
|
|
37
|
+
It reads Markdown only, so it is the same check for every stack and for a model reversed
|
|
38
|
+
from legacy code.
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
aw-model-guard.py snapshot --model <path> --root <p>
|
|
42
|
+
-> {ok, model, before, exists, aggregates[], rules, cross_rules, guarded}
|
|
43
|
+
aw-model-guard.py check --model <path> --root <p>
|
|
44
|
+
-> {ok, checked, skipped, model, rejected, restored, losses[], loss_count, losses_text,
|
|
45
|
+
reason}
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
import argparse
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import re
|
|
52
|
+
import shutil
|
|
53
|
+
import sys
|
|
54
|
+
|
|
55
|
+
BEFORE_NAME = ".domain-model.before.md"
|
|
56
|
+
REJECTED_NAME = "domain-model.rejected.md"
|
|
57
|
+
MOCK_MARK = "<!-- authored by aw-orchestrator MOCK transport -->"
|
|
58
|
+
|
|
59
|
+
_RULE_BULLET = re.compile(r"^\s*[-*+]\s+\**\s*(X?BR-\d+)\b\**(.*)$")
|
|
60
|
+
_RULE_ROW = re.compile(r"^\s*\|\s*\**\s*(X?BR-\d+)\b\**\s*\|(.*)$")
|
|
61
|
+
_SERVING = re.compile(r"^\s*(?:[-*+]\s+)?\*\*Serving specs(?:\*\*\s*:|:\*\*)\s*(.*)$", re.I)
|
|
62
|
+
_CTX_VERSION = re.compile(r"^\s*(?:[-*+]\s+)?\*\*Context version(?:\*\*\s*:|:\*\*)\s*(.*)$", re.I)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read(path):
|
|
66
|
+
try:
|
|
67
|
+
with open(path, encoding="utf-8") as fh:
|
|
68
|
+
return fh.read()
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _rel(path, root):
|
|
74
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _norm(s):
|
|
78
|
+
"""Text as compared: markup that does not change meaning dropped, whitespace collapsed."""
|
|
79
|
+
s = s.replace("*", "").replace("`", "")
|
|
80
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _placeholder(s):
|
|
84
|
+
s = s.strip()
|
|
85
|
+
return s.startswith("[") and s.endswith("]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cells(line):
|
|
89
|
+
return [c.strip() for c in line.strip().strip("|").split("|")]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_separator(line):
|
|
93
|
+
return bool(re.match(r"^\s*\|[\s|:\-]+\|?\s*$", line))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def inventory(text):
|
|
97
|
+
"""Everything in one model that another document or script relies on."""
|
|
98
|
+
inv = {"aggregates": [], "rules": {}, "dupes": set(), "serving": {}, "terms": {},
|
|
99
|
+
"context_map": {}, "history": [], "context_version": ""}
|
|
100
|
+
text = re.sub(r"<!--.*?-->", "", text or "", flags=re.S)
|
|
101
|
+
section, aggregate, rule, in_fence = "", "", None, False
|
|
102
|
+
for raw in text.splitlines():
|
|
103
|
+
line = raw.rstrip()
|
|
104
|
+
if line.lstrip().startswith("```"):
|
|
105
|
+
in_fence, rule = not in_fence, None
|
|
106
|
+
continue
|
|
107
|
+
if in_fence:
|
|
108
|
+
continue
|
|
109
|
+
m = re.match(r"^(#{1,6})\s+(.*)$", line)
|
|
110
|
+
if m:
|
|
111
|
+
rule = None
|
|
112
|
+
if len(m.group(1)) <= 2:
|
|
113
|
+
section, aggregate = _norm(m.group(2)), ""
|
|
114
|
+
am = re.match(r"^Aggregate:\s*(.+)$", section, re.I)
|
|
115
|
+
if am:
|
|
116
|
+
aggregate = am.group(1).strip()
|
|
117
|
+
if not _placeholder(aggregate) and aggregate not in inv["aggregates"]:
|
|
118
|
+
inv["aggregates"].append(aggregate)
|
|
119
|
+
continue
|
|
120
|
+
if not line.strip():
|
|
121
|
+
rule = None
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
rm = _RULE_BULLET.match(line) or _RULE_ROW.match(line)
|
|
125
|
+
if rm:
|
|
126
|
+
rid, rest = rm.group(1), rm.group(2).replace("|", " ")
|
|
127
|
+
body = re.sub(r"^[\s:\-–—]+", "", _norm(rest))
|
|
128
|
+
rule = None
|
|
129
|
+
if not _placeholder(body):
|
|
130
|
+
if rid in inv["rules"]:
|
|
131
|
+
inv["dupes"].add(rid)
|
|
132
|
+
else:
|
|
133
|
+
inv["rules"][rid] = [section, body]
|
|
134
|
+
rule = rid
|
|
135
|
+
continue
|
|
136
|
+
# A rule's text runs on over wrapped lines and nested bullets until a blank line,
|
|
137
|
+
# a heading, a table row or the next bullet at its own level.
|
|
138
|
+
if rule and (re.match(r"^\s{2,}\S", line) or not re.match(r"^\s*([-*+]\s|\|)", line)):
|
|
139
|
+
inv["rules"][rule][1] = _norm(inv["rules"][rule][1] + " " + line)
|
|
140
|
+
continue
|
|
141
|
+
rule = None
|
|
142
|
+
|
|
143
|
+
sm = _SERVING.match(line)
|
|
144
|
+
if sm and aggregate and not _placeholder(aggregate):
|
|
145
|
+
names = {_norm(n) for n in sm.group(1).split(",")}
|
|
146
|
+
inv["serving"].setdefault(aggregate, {})
|
|
147
|
+
for n in names:
|
|
148
|
+
if n and not _placeholder(n):
|
|
149
|
+
inv["serving"][aggregate][n.casefold()] = n
|
|
150
|
+
continue
|
|
151
|
+
cm = _CTX_VERSION.match(line)
|
|
152
|
+
if cm and not inv["context_version"]:
|
|
153
|
+
inv["context_version"] = _norm(cm.group(1))
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
low = section.casefold()
|
|
157
|
+
is_row = line.lstrip().startswith("|") and not _is_separator(line)
|
|
158
|
+
if low == "ubiquitous language":
|
|
159
|
+
term = ""
|
|
160
|
+
if is_row:
|
|
161
|
+
term = _norm(_cells(line)[0])
|
|
162
|
+
if term.casefold() == "term":
|
|
163
|
+
term = ""
|
|
164
|
+
else:
|
|
165
|
+
bm = re.match(r"^\s*[-*+]\s+\*\*(.+?)\*\*", line)
|
|
166
|
+
term = _norm(bm.group(1)) if bm else ""
|
|
167
|
+
if term and not _placeholder(term):
|
|
168
|
+
inv["terms"][term.casefold()] = term
|
|
169
|
+
elif low == "context map" and is_row:
|
|
170
|
+
name = _norm(_cells(line)[0])
|
|
171
|
+
if name and name.casefold() != "other context" and not _placeholder(name):
|
|
172
|
+
inv["context_map"][name.casefold()] = name
|
|
173
|
+
elif low == "change history" and is_row:
|
|
174
|
+
cells = [_norm(c) for c in _cells(line)]
|
|
175
|
+
if cells and cells[0].casefold() != "context version" \
|
|
176
|
+
and not any(_placeholder(c) for c in cells):
|
|
177
|
+
row = " | ".join(cells)
|
|
178
|
+
if row not in inv["history"]:
|
|
179
|
+
inv["history"].append(row)
|
|
180
|
+
return inv
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def losses(before, after):
|
|
184
|
+
"""What `before` held that `after` no longer does, one sentence per item."""
|
|
185
|
+
out = []
|
|
186
|
+
for agg in before["aggregates"]:
|
|
187
|
+
if agg not in after["aggregates"]:
|
|
188
|
+
out.append(f"aggregate `{agg}` (its `## Aggregate: {agg}` section is gone or renamed)")
|
|
189
|
+
for rid, (sec, body) in before["rules"].items():
|
|
190
|
+
if rid not in after["rules"]:
|
|
191
|
+
out.append(f"{rid} is no longer defined")
|
|
192
|
+
continue
|
|
193
|
+
asec, abody = after["rules"][rid]
|
|
194
|
+
if asec != sec:
|
|
195
|
+
out.append(f"{rid} moved from `{sec}` to `{asec or 'no section'}`")
|
|
196
|
+
elif abody != body:
|
|
197
|
+
out.append(f"{rid}'s rule text changed")
|
|
198
|
+
if rid in after["dupes"] and rid not in before["dupes"]:
|
|
199
|
+
out.append(f"{rid} is now defined more than once")
|
|
200
|
+
for agg, names in before["serving"].items():
|
|
201
|
+
if agg not in after["aggregates"]:
|
|
202
|
+
continue
|
|
203
|
+
kept = after["serving"].get(agg, {})
|
|
204
|
+
for key, name in names.items():
|
|
205
|
+
if key not in kept:
|
|
206
|
+
out.append(f"Serving specs of `{agg}` no longer names `{name}`")
|
|
207
|
+
for key, term in before["terms"].items():
|
|
208
|
+
if key not in after["terms"]:
|
|
209
|
+
out.append(f"Ubiquitous Language term `{term}` is gone")
|
|
210
|
+
for key, name in before["context_map"].items():
|
|
211
|
+
if key not in after["context_map"]:
|
|
212
|
+
out.append(f"the Context Map row for `{name}` is gone")
|
|
213
|
+
for row in before["history"]:
|
|
214
|
+
if row not in after["history"]:
|
|
215
|
+
out.append(f"Change History row `{row}` is gone or changed")
|
|
216
|
+
if before["context_version"] and after["context_version"] != before["context_version"]:
|
|
217
|
+
now = after["context_version"]
|
|
218
|
+
out.append(f"Context version `{before['context_version']}` "
|
|
219
|
+
+ (f"changed to `{now}`" if now else "is gone"))
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def snapshot(model, root):
|
|
224
|
+
before = os.path.join(os.path.dirname(model), BEFORE_NAME)
|
|
225
|
+
text = _read(model)
|
|
226
|
+
out = {"ok": True, "model": _rel(model, root), "before": "", "exists": text is not None,
|
|
227
|
+
"aggregates": [], "rules": 0, "cross_rules": 0, "guarded": 0}
|
|
228
|
+
if text is None:
|
|
229
|
+
# Nothing to lose. A copy left by an earlier, unfinished run would be compared
|
|
230
|
+
# against a model it never belonged to, so it goes.
|
|
231
|
+
if os.path.exists(before):
|
|
232
|
+
os.remove(before)
|
|
233
|
+
return out
|
|
234
|
+
shutil.copyfile(model, before)
|
|
235
|
+
inv = inventory(text)
|
|
236
|
+
rules = [r for r in inv["rules"] if r.startswith("BR-")]
|
|
237
|
+
cross = [r for r in inv["rules"] if r.startswith("XBR-")]
|
|
238
|
+
out.update({
|
|
239
|
+
"before": _rel(before, root), "aggregates": inv["aggregates"],
|
|
240
|
+
"rules": len(rules), "cross_rules": len(cross),
|
|
241
|
+
"guarded": (len(inv["aggregates"]) + len(inv["rules"])
|
|
242
|
+
+ sum(len(v) for v in inv["serving"].values()) + len(inv["terms"])
|
|
243
|
+
+ len(inv["context_map"]) + len(inv["history"])
|
|
244
|
+
+ (1 if inv["context_version"] else 0)),
|
|
245
|
+
})
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def check(model, root):
|
|
250
|
+
before = os.path.join(os.path.dirname(model), BEFORE_NAME)
|
|
251
|
+
out = {"ok": True, "checked": False, "skipped": "", "model": _rel(model, root),
|
|
252
|
+
"rejected": "", "restored": False, "losses": [], "loss_count": 0,
|
|
253
|
+
"losses_text": "", "reason": ""}
|
|
254
|
+
old = _read(before)
|
|
255
|
+
if old is None:
|
|
256
|
+
out["reason"] = "no snapshot — the model did not exist before the rewrite"
|
|
257
|
+
return out
|
|
258
|
+
new = _read(model)
|
|
259
|
+
if new is not None and new.lstrip().startswith(MOCK_MARK):
|
|
260
|
+
os.remove(before)
|
|
261
|
+
out.update({"skipped": "mock", "reason": "the rewrite is mock-transport output, not a model"})
|
|
262
|
+
return out
|
|
263
|
+
lost = losses(inventory(old), inventory(new or ""))
|
|
264
|
+
out["checked"] = True
|
|
265
|
+
if not lost:
|
|
266
|
+
os.remove(before)
|
|
267
|
+
out["reason"] = "everything the model held before the rewrite is still there"
|
|
268
|
+
return out
|
|
269
|
+
rejected = os.path.join(os.path.dirname(model), REJECTED_NAME)
|
|
270
|
+
with open(rejected, "w", encoding="utf-8") as fh:
|
|
271
|
+
fh.write(new or "")
|
|
272
|
+
shutil.copyfile(before, model)
|
|
273
|
+
os.remove(before)
|
|
274
|
+
out.update({"ok": False, "rejected": _rel(rejected, root), "restored": True,
|
|
275
|
+
"losses": lost, "loss_count": len(lost), "losses_text": "; ".join(lost),
|
|
276
|
+
"reason": f"{len(lost)} item(s) other features rely on were lost; the model was restored"})
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main():
|
|
281
|
+
ap = argparse.ArgumentParser(description="what a rewrite of domain-model.md must keep")
|
|
282
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
283
|
+
for name in ("snapshot", "check"):
|
|
284
|
+
p = sub.add_parser(name)
|
|
285
|
+
p.add_argument("--model", required=True)
|
|
286
|
+
p.add_argument("--root", required=True)
|
|
287
|
+
args = ap.parse_args()
|
|
288
|
+
model = os.path.abspath(args.model)
|
|
289
|
+
root = os.path.abspath(args.root)
|
|
290
|
+
out = snapshot(model, root) if args.cmd == "snapshot" else check(model, root)
|
|
291
|
+
sys.stdout.write(json.dumps(out) + "\n")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__":
|
|
295
|
+
main()
|