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.
Files changed (75) hide show
  1. package/README.md +76 -81
  2. package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
  3. package/assets/.appweaver/STACK-PROFILES.md +95 -60
  4. package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
  5. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
  6. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
  7. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
  8. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
  9. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
  10. package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
  11. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
  12. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
  14. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
  23. package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
  24. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
  25. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
  30. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
  31. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
  32. package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
  33. package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
  34. package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
  35. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
  36. package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
  37. package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
  38. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
  39. package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
  40. package/assets/.appweaver/templates/api-plan-template.md +107 -0
  41. package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
  42. package/assets/.appweaver/templates/delta-design-template.md +25 -23
  43. package/assets/.appweaver/templates/delta-spec-template.md +3 -3
  44. package/assets/.appweaver/templates/design-template.md +20 -42
  45. package/assets/.appweaver/templates/domain-model-template.md +3 -0
  46. package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
  47. package/assets/.appweaver/templates/ui-design-template.md +4 -4
  48. package/assets/foundations/foundation.spec.template.txt +25 -18
  49. package/assets/guardrails/README.md +61 -25
  50. package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
  51. package/assets/guardrails/methodology/ux/content-design.md +34 -0
  52. package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
  53. package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
  54. package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
  55. package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
  56. package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
  57. package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
  58. package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
  59. package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
  60. package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
  61. package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
  62. package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
  63. package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +216 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/utils/config.js +1 -1
  68. package/dist/utils/config.js.map +1 -1
  69. package/dist/utils/foundation.d.ts +36 -0
  70. package/dist/utils/foundation.d.ts.map +1 -0
  71. package/dist/utils/foundation.js +109 -0
  72. package/dist/utils/foundation.js.map +1 -0
  73. package/dist/utils/paths.js +1 -1
  74. package/dist/utils/paths.js.map +1 -1
  75. 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
- "contract_run": "...", "contract": "...", "client": "...", "settings_text": "...", ...}
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": "...", "contract_run": "...", "contract": "...",
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-run`); it is read as the underscored name.
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 = (("contract_run", "start command"), ("contract", "document URL"),
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-run:`,
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
- start its service on its own to export the API document, which for a solution with
899
- an orchestration host is not the lane's `run` command. A pack with no such file has
900
- nothing to add, and the lane keeps its stack profile's values.
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, and the plan before the code it maps to.
31
- ROUTES = ["specify", "plan", "implement", "review"]
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()