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
@@ -2,7 +2,7 @@
2
2
  """
3
3
  aw-stack — the lane model's deterministic core.
4
4
 
5
- Backs seventeen aw-spec-actions subcommands, called identically by the bash and the
5
+ Backs twenty-one aw-spec-actions subcommands, called identically by the bash and the
6
6
  powershell twin so the two stay in parity by construction rather than by review:
7
7
 
8
8
  list enumerate .appweaver/stack-profiles/ — the candidate list
@@ -18,51 +18,66 @@ powershell twin so the two stay in parity by construction rather than by review:
18
18
  slice-gate-stamp one lane's analyze-slice-delta result, written into the change
19
19
  run-phase run a phase's command for EVERY declared lane, not just the first
20
20
  probe is there evidence of a lane of this kind — read-only, never fatal
21
- contract-export start the service lane, fetch its API document, stop it
22
- contract-draft draft a feature's API document from its backend plan's API section
23
- contract-combine the exported document plus every draft operation it does not serve
24
- contract-requests the frontend plan's contract requests and the backend plan's answers
21
+ rules which stacks the active project's technical map has rule files for, and
22
+ the warning for a declared lane whose stack lacks them — read-only
23
+ contract-export write the service lane's API document as built, the way the lane says
24
+ contract-draft draft a feature's API document from its API plan, or a change's from
25
+ its delta API plan
26
+ contract-combine every planned operation — the drafts, with the open changes applied —
27
+ plus the operations the built service serves that no plan describes
28
+ contract-requests the frontend plan's contract requests and the API plan's answers
25
29
  contract-check the operation ids a frontend plan cites vs that document
26
30
  contract-impact operations a change's service delta changes or removes that the
27
31
  frontend binds to, with no matching frontend change
32
+ contract-verify the API the built service serves vs the operations its plan names
33
+ contract-merge a change's delta API plan, merged into its feature's API plan
34
+ api-split a feature's API, moved out of its backend plan into an API plan
28
35
 
29
36
  This file is the only code that writes stacks.json, through two subcommands with
30
37
  different jobs. `declare` creates or replaces a whole lane and has exactly one caller —
31
38
  `select-stack`, where a person picks. `set` changes only the keys in CONTRACT_KEYS on a lane that is
32
39
  already there, and never its root, profile or phase commands: `foundation` records the
33
- API-export settings the pack it unpacked carries, and `implement` records the client
34
- command a developer supplies. Re-declaring a lane to change one key would reset every
40
+ API-document settings the pack it unpacked or the spec it built from carries, and
41
+ `implement` records the client command a developer supplies. Re-declaring a lane to change one key would reset every
35
42
  other command to the profile's default, which is why the two are separate. The `origin`
36
43
  key stays optional in the schema for a future caller that proposes a lane without a
37
44
  person in the loop; a reader that branches on it keeps working either way.
38
45
 
39
- THE API CONTRACT BETWEEN THE LANES. The service lane publishes an API document and the
40
- frontend lane generates its client from it. Three lane keys carry that, each read from
41
- the lane first and from its stack profile when the lane does not set it, so a lane
42
- declared before the keys existed answers them too:
43
-
44
- contract_run (backend) the command that starts the service for the export
45
- contract (backend) the URL the running service serves its document at
46
- client (frontend) the command that generates the client; `{{contract}}` in
47
- it is the exported document, as seen from where it runs
48
-
49
- The document is written to CONTRACT_DOC under the service lane root folder, so it is
50
- committed with the service that publishes it.
51
-
52
- THE BACKEND PUBLISHES, THE FRONTEND READS. The export runs where the service is worked on —
53
- implement's backend half, and build after a green compile — and never from a frontend run:
54
- a frontend developer may have neither the service's code built nor its runtime installed.
55
- What a frontend run reads is the committed document, plus a DRAFT for what is planned and
56
- not built yet: `contract-draft` turns each backend plan's API section into DRAFT_NAME beside
57
- the plan, by script, and `contract-combine` writes COMBINED_DOC — the exported document with
58
- every draft operation it does not serve yet added and marked `x-aw-draft`. The client is
59
- generated from the combined document, so a frontend can bind to an endpoint the backend has
60
- planned and not built; the draft operation drops out of it once the service serves its id.
46
+ THE API CONTRACT BETWEEN THE LANES IS THE API PLAN. Each feature's API_PLAN_NAME, beside
47
+ its backend plan, names the endpoints the service publishes, and `contract-draft` turns it
48
+ into DRAFT_NAME, by script, so it exists on every stack and before any code does. A change
49
+ that alters the API drafts its own, from its DELTA_API_PLAN_NAME, into its change folder,
50
+ and `contract-merge` merges that delta into the API plan when the change is finalized —
51
+ the API plan is a master, like spec.md, while the backend plan stays derived. A feature
52
+ planned before the API plan existed keeps its endpoints in its backend plan's API section,
53
+ which every reader here falls back to until `api-split` moves them out.
54
+ The frontend lane binds to those: `contract-combine` writes COMBINED_DOC from every
55
+ feature's draft with the open changes applied over it, and the frontend's client is
56
+ generated from that. The frontend never needs the service built, started or installed.
57
+
58
+ THE CODE IS CHECKED AGAINST THE PLAN, NOT READ FROM. `contract-export` writes the API the
59
+ built service actually serves to CONTRACT_DOC under the service lane root folder, and
60
+ `contract-verify` compares it with the operations the plan names — the backend gates run
61
+ both, after their fix loops. The as-built document reaches the frontend only for the
62
+ operations no plan describes, marked `x-aw-unplanned`: the endpoints of a brownfield
63
+ feature nobody has adopted yet. Four lane keys carry the settings, each read from the lane
64
+ first and from its stack profile when the lane does not set it:
65
+
66
+ contract_write (backend) a command that writes the service's API document to
67
+ `{{contract}}` — the stack's own generator, a script, any
68
+ way that does not need the service running
69
+ contract_run (backend) the command that starts the service, when the document can
70
+ only be fetched from it running …
71
+ contract (backend) … and the URL it serves the document at
72
+ client (frontend) the command that generates the client; `{{contract}}` in
73
+ it is the combined document, as seen from where it runs
74
+
75
+ A lane that sets none of the backend keys writes no as-built document, and its API is not
76
+ checked: no profile assumes a way, because starting a service runs its startup code.
61
77
 
62
78
  THE REQUEST LEDGER. A frontend plan asks for what the service does not offer in its §5
63
- "Contract deltas needed" table, one RQ-n row each; the backend plan answers in its API
64
- section's "Requests from the frontend lane" table, published as an operation id or declined
65
- with the reason. Each developer writes only their own plan, and `contract-requests` reads
79
+ "Contract deltas needed" table, one RQ-n row each; the API plan answers in its "Requests
80
+ from the frontend lane" table, published as an operation id or declined with the reason. Each developer writes only their own plan, and `contract-requests` reads
66
81
  both to say which requests are open, answered, declined, or answered and not re-bound yet.
67
82
 
68
83
  A person editing stacks.json in a text editor is the other way a lane arrives,
@@ -125,22 +140,31 @@ PROFILES_REL = ".appweaver/stack-profiles"
125
140
  LANE_REL = ".appweaver/lane.json"
126
141
  WORKING_LANES = ("backend", "frontend", "both")
127
142
 
128
- # Where the exported API document lands, relative to the service lane root folder.
143
+ # Where the as-built API document lands, relative to the service lane root folder.
129
144
  CONTRACT_DOC = "contracts/openapi.json"
130
145
 
131
- # A feature's draft API document, beside the plans it is drafted from, and the combined
132
- # document a frontend generates its client from (repository-relative, not committed).
146
+ # A feature's draft API document, beside the plans it is drafted from — and a change's,
147
+ # in its change folder — and the combined document a frontend generates its client from
148
+ # (repository-relative, not committed).
133
149
  DRAFT_NAME = "api-draft.json"
134
150
  COMBINED_DOC = ".appweaver/contracts/openapi.combined.json"
151
+ # Where a `contract_write` command writes, before the document is checked and moved to
152
+ # CONTRACT_DOC: a command that fails halfway leaves the committed document as it was.
153
+ EXPORT_SCRATCH = ".appweaver/contracts/openapi.export.json"
135
154
 
136
155
  # Each lane's plan document, under its current name and its pre-lane-split one.
137
156
  BACKEND_PLAN_NAMES = ("backend-plan.md", "design.md")
138
157
  FRONTEND_PLAN_NAMES = ("frontend-plan.md", "design.ui.md")
139
158
 
159
+ # The service's published API — its endpoint blocks and its answers to the frontend lane's
160
+ # requests — beside the backend plan, and a change's API delta in its change folder.
161
+ API_PLAN_NAME = "api-plan.md"
162
+ DELTA_API_PLAN_NAME = "delta-api-plan.md"
163
+
140
164
  # The API-contract keys: the only ones `set` may change, and the ones read from the
141
165
  # stack profile when the lane does not set them (lane_value). Everything else on a lane
142
166
  # is the declaration itself and changes only through `declare`.
143
- CONTRACT_KEYS = ("contract_run", "contract", "client")
167
+ CONTRACT_KEYS = ("contract_write", "contract_run", "contract", "client")
144
168
 
145
169
  # A lane id names the ROLE in the project; the profile names the technology.
146
170
  # Keeping them separate is what lets a project re-stack a lane without renaming
@@ -788,10 +812,11 @@ def lane_command(lane, phase, repo=None, contract=""):
788
812
  several. It is looked up when the command is read, so a project scaffolded after the
789
813
  lane was declared is found; with no single match it falls back to `{{root}}`.
790
814
 
791
- `{{contract}}` is the API document a frontend lane's `client` command generates from,
792
- named as seen from where THIS lane's command runs: `contract` when the caller passes
793
- one (implement passes the combined document), else the service lane's exported
794
- document (contract_doc). With neither it is left as written.
815
+ `{{contract}}` is the API document the command reads or writes, named as seen from
816
+ where THIS lane's command runs: `contract` when the caller passes one (implement passes
817
+ the combined document a `client` command generates from, contract-export the scratch
818
+ path a `contract_write` command writes to), else the service lane's as-built document
819
+ (contract_doc). With neither it is left as written.
795
820
  """
796
821
  if phase in CONTRACT_KEYS:
797
822
  cmd = lane_value(repo, lane, phase)
@@ -829,7 +854,7 @@ def lane_value(repo, lane, key):
829
854
 
830
855
 
831
856
  def contract_doc(repo, lanes=None):
832
- """The service lane's exported API document, repo-relative; "" with no service lane."""
857
+ """The service lane's as-built API document, repo-relative; "" with no service lane."""
833
858
  if lanes is None:
834
859
  lanes = (read_stacks(repo) or {}).get("lanes") or []
835
860
  lane = next((l for l in lanes if l.get("kind") == "backend"), {})
@@ -1205,8 +1230,9 @@ def kind_block(repo, lanes, kind):
1205
1230
  kind + "_run": lane_command(lane, "run", repo),
1206
1231
  kind + "_migrate": lane_command(lane, "migrate", repo) or prof.get("migrate", ""),
1207
1232
  # The API-contract settings, the lane's own or its profile's (lane_value). A
1208
- # service lane uses the first two and a frontend lane the third; the others read
1233
+ # service lane uses the first three and a frontend lane the last; the others read
1209
1234
  # empty on each.
1235
+ kind + "_contract_write": lane_command(lane, "contract_write", repo),
1210
1236
  kind + "_contract_run": lane_command(lane, "contract_run", repo),
1211
1237
  kind + "_contract": lane_value(repo, lane, "contract"),
1212
1238
  kind + "_client": lane_command(lane, "client", repo),
@@ -1611,14 +1637,20 @@ def cmd_slice_gate_stamp(args):
1611
1637
  lane = args.lane
1612
1638
  truthy = lambda v: str(v or "").strip().lower() == "true"
1613
1639
  tests_green, requirements_ok = truthy(args.tests_green), truthy(args.requirements_ok)
1614
- # The findings are the lane check's own list. One that cannot be read is recorded as
1615
- # unreadable, never as none.
1616
- try:
1617
- findings = json.loads(args.findings) if (args.findings or "").strip() else []
1618
- if not isinstance(findings, list):
1619
- raise ValueError("not a list")
1620
- except ValueError:
1621
- findings = None
1640
+ # The findings are the lane's own lists — the requirement check's and the test-citation
1641
+ # check's — joined. One that cannot be read makes the count unreadable, never none.
1642
+ findings = []
1643
+ for raw in args.findings or []:
1644
+ if not (raw or "").strip():
1645
+ continue
1646
+ try:
1647
+ got = json.loads(raw)
1648
+ if not isinstance(got, list):
1649
+ raise ValueError("not a list")
1650
+ except ValueError:
1651
+ findings = None
1652
+ break
1653
+ findings.extend(got)
1622
1654
  high = (sum(1 for f in findings if isinstance(f, dict)
1623
1655
  and str(f.get("severity", "")).strip().lower() == "high")
1624
1656
  if findings is not None else None)
@@ -1716,7 +1748,7 @@ def cmd_resolve(args):
1716
1748
  "language": profile_of(args.repo, match).get("language", ""),
1717
1749
  "ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
1718
1750
  "prototype": data.get("prototype", {}),
1719
- # The service lane's exported API document, and whether an export has written it.
1751
+ # The service lane's as-built API document, and whether contract-export has written it.
1720
1752
  "contract_doc": doc,
1721
1753
  "contract_doc_exists": bool(doc) and os.path.isfile(os.path.join(args.repo, doc)),
1722
1754
  "path": STACKS_REL,
@@ -2018,12 +2050,17 @@ def cmd_run_phase(args):
2018
2050
 
2019
2051
 
2020
2052
  # ─────────────────────────── contract-export ───────────────────────────────
2021
- # The service lane's API document, taken from the service itself. A document served by a
2022
- # running service is the one thing every web stack can produce the same way — Swagger,
2023
- # springdoc, FastAPI and the rest all serve one — so the export starts the service with
2024
- # the lane's `contract_run`, fetches `contract`, and stops it. How a stack (or the pack
2025
- # the lane was scaffolded from) has to be started is the command's business, not this
2026
- # code's.
2053
+ # The API the built service actually serves, written to CONTRACT_DOC so the backend gates
2054
+ # can check it against the plan (contract-verify). How a stack produces its document is the
2055
+ # lane's business, in one of two ways:
2056
+ # WRITE `contract_write` — a command that writes the document to `{{contract}}`: the
2057
+ # stack's own generator, or a script that loads the application and dumps its
2058
+ # document with no server started. Preferred: nothing of the service runs.
2059
+ # FETCH `contract_run` + `contract` — start the service, fetch the document from its URL,
2060
+ # stop it and everything it started. For a stack that can only serve its document.
2061
+ # A lane that sets neither writes nothing, and says so. No profile assumes a way, because
2062
+ # starting a service runs its startup code — migrations, seeding, connections — wherever its
2063
+ # configuration points.
2027
2064
 
2028
2065
  CONTRACT_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
2029
2066
 
@@ -2130,51 +2167,126 @@ def cmd_contract_export(args):
2130
2167
  lane = next((l for l in lanes if l.get("kind") == "backend"), {})
2131
2168
  doc_rel = contract_doc(repo, lanes)
2132
2169
  doc_abs = os.path.join(repo, doc_rel) if doc_rel else ""
2133
- cmd = lane_command(lane, "contract_run", repo)
2170
+ write = lane_command(lane, "contract_write", repo, contract=EXPORT_SCRATCH) if lane else ""
2171
+ cmd = lane_command(lane, "contract_run", repo) if lane else ""
2134
2172
  url = lane_value(repo, lane, "contract")
2135
2173
  result = {
2136
- "ok": False, "ran": False, "lane": lane.get("id", ""), "cmd": cmd, "url": url,
2174
+ "ok": False, "ran": False, "lane": lane.get("id", ""),
2175
+ # HOW the document was to be had — write | fetch | none — and, when it was not,
2176
+ # WHY in one word: exited | timeout | http | not-openapi | not-written | no-way |
2177
+ # no-lane | no-root. A caller words its message by the cause, never by the text.
2178
+ "way": "none", "cause": "", "cmd": write or cmd, "url": url,
2137
2179
  "cwd": "", "doc": doc_rel, "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
2138
2180
  "operations": 0, "unnamed": 0, "seconds": 0, "already_running": False,
2139
2181
  "reason": "", "log_tail": "",
2140
- # The placeholder a `client` command names the document by, spelled out for a
2141
- # prompt that asks for one — a workflow cannot write it literally, since the
2142
- # engine would read it as a variable.
2182
+ # The placeholder a `client` or `contract_write` command names the document by,
2183
+ # spelled out for a prompt that asks for one — a workflow cannot write it
2184
+ # literally, since the engine would read it as a variable.
2143
2185
  "placeholder": "{{contract}}",
2144
2186
  }
2145
2187
 
2146
2188
  def done(**kw):
2147
2189
  result.update(kw)
2148
2190
  result["doc_exists"] = bool(doc_abs) and os.path.isfile(doc_abs)
2191
+ if result["ok"]:
2192
+ result["cause"] = "ok"
2149
2193
  # One line on which document a reader now has, for a prompt to quote.
2150
2194
  if result["ok"]:
2151
- result["status"] = "%s, exported from the built service in this run" % doc_rel
2195
+ result["status"] = "%s, written from the built service in this run" % doc_rel
2152
2196
  elif result["doc_exists"]:
2153
- result["status"] = ("%s, from an earlier export — this run's failed, so it may "
2154
- "be older than the code" % doc_rel)
2197
+ result["status"] = ("%s, from an earlier run — this run's failed, so it may be "
2198
+ "older than the code" % doc_rel)
2155
2199
  else:
2156
2200
  result["status"] = ""
2157
2201
  emit(result)
2158
2202
 
2203
+ def save(doc, seconds, note=""):
2204
+ os.makedirs(os.path.dirname(doc_abs), exist_ok=True)
2205
+ with open(doc_abs, "w", encoding="utf-8") as fh:
2206
+ json.dump(doc, fh, indent=2)
2207
+ fh.write("\n")
2208
+ ops = _operations(doc)
2209
+ done(ok=True, seconds=seconds, operations=len(ops),
2210
+ unnamed=sum(1 for o in ops if not o["id"]), reason=note, log_tail="")
2211
+
2212
+ kept = lambda: " — the last written document is kept" if result["doc_exists"] else ""
2159
2213
  if not lane:
2160
- return done(reason="no service lane is declared in %s" % STACKS_REL)
2214
+ return done(cause="no-lane", reason="no service lane is declared in %s" % STACKS_REL)
2161
2215
  if not root_on_disk(repo, lane.get("root", "")):
2162
- return done(reason="the service lane root folder '%s' is not on disk yet"
2216
+ return done(cause="no-root", reason="the service lane root folder '%s' is not on disk yet"
2163
2217
  % lane.get("root", ""))
2164
- if not url:
2165
- return done(reason="the service lane has no `contract` URL — set it in %s, or in "
2166
- "its stack profile" % STACKS_REL)
2218
+ workdir = lane_workdir(repo, lane)
2167
2219
 
2168
- # A service already answering at the URL is used as it is: a second instance could
2169
- # not bind the port anyway. It may be running older code than this run built, which
2170
- # the caller is told.
2220
+ # WRITE — the command writes the document to the scratch path; only a document that
2221
+ # reads as OpenAPI replaces the committed one.
2222
+ if write:
2223
+ result.update(way="write", cmd=write)
2224
+ result["cwd"] = os.path.relpath(workdir, repo).replace("\\", "/")
2225
+ scratch = os.path.join(repo, EXPORT_SCRATCH)
2226
+ os.makedirs(os.path.dirname(scratch), exist_ok=True)
2227
+ if os.path.isfile(scratch):
2228
+ os.remove(scratch)
2229
+ fd, log_path = tempfile.mkstemp(prefix="aw-contract-", suffix=".log")
2230
+ log = os.fdopen(fd, "wb")
2231
+ proc, started, exited = None, time.time(), None
2232
+ try:
2233
+ proc = _start(write, workdir, log)
2234
+ result["ran"] = True
2235
+ deadline = started + args.timeout
2236
+ while time.time() < deadline:
2237
+ if proc.poll() is not None:
2238
+ exited = proc.returncode
2239
+ break
2240
+ time.sleep(0.5)
2241
+ finally:
2242
+ if proc is not None:
2243
+ _stop(proc)
2244
+ log.close()
2245
+ seconds = int(time.time() - started)
2246
+ tail = _tail(log_path)
2247
+ try:
2248
+ os.remove(log_path)
2249
+ except OSError:
2250
+ pass
2251
+ if exited is None:
2252
+ return done(cause="timeout", seconds=seconds, log_tail=tail,
2253
+ reason="`%s` did not finish within %d s%s" % (write, args.timeout, kept()))
2254
+ if exited != 0:
2255
+ return done(cause="exited", seconds=seconds, log_tail=tail,
2256
+ reason="`%s` exited (code %s)%s" % (write, exited, kept()))
2257
+ if not os.path.isfile(scratch):
2258
+ return done(cause="not-written", seconds=seconds, log_tail=tail,
2259
+ reason="`%s` finished but wrote nothing at {{contract}} (%s)%s"
2260
+ % (write, EXPORT_SCRATCH, kept()))
2261
+ with open(scratch, "rb") as fh:
2262
+ doc = _api_document(fh.read())
2263
+ os.remove(scratch)
2264
+ if doc is None:
2265
+ return done(cause="not-openapi", seconds=seconds, log_tail=tail,
2266
+ reason="`%s` wrote a file that is not an OpenAPI or Swagger JSON "
2267
+ "document%s" % (write, kept()))
2268
+ return save(doc, seconds)
2269
+
2270
+ if not url:
2271
+ why = ("the service lane has a `contract_run` command but no `contract` URL to fetch "
2272
+ "the document from" if cmd else
2273
+ "the service lane sets no way to write its API document")
2274
+ return done(cause="no-way", reason=why + " — set `contract_write` (a command that "
2275
+ "writes it to {{contract}}), or `contract_run` and `contract`, on the lane "
2276
+ "in %s" % STACKS_REL)
2277
+
2278
+ # FETCH — a service already answering at the URL is used as it is: a second instance
2279
+ # could not bind the port anyway. It may be running older code than this run built,
2280
+ # which the caller is told.
2281
+ result["way"] = "fetch"
2171
2282
  status, body, _err = _fetch(url)
2172
2283
  proc, log_path, started, exited = None, "", time.time(), None
2173
2284
  if status != 200 or _api_document(body) is None:
2174
2285
  if not cmd:
2175
- return done(reason="the service lane has no `contract_run` command to start it "
2176
- "— set it in %s, or in its stack profile" % STACKS_REL)
2177
- workdir = lane_workdir(repo, lane)
2286
+ return done(cause="no-way",
2287
+ reason="nothing answers at %s and the service lane has no "
2288
+ "`contract_run` command to start it — set it in %s"
2289
+ % (url, STACKS_REL))
2178
2290
  result["cwd"] = os.path.relpath(workdir, repo).replace("\\", "/")
2179
2291
  fd, log_path = tempfile.mkstemp(prefix="aw-contract-", suffix=".log")
2180
2292
  log = os.fdopen(fd, "wb")
@@ -2208,31 +2320,25 @@ def cmd_contract_export(args):
2208
2320
  doc = _api_document(body) if status == 200 else None
2209
2321
  if doc is None:
2210
2322
  if exited is not None:
2211
- why = "`%s` exited (code %s) before %s answered" % (cmd, exited, url)
2323
+ cause, why = "exited", "`%s` exited (code %s) before %s answered" % (cmd, exited, url)
2212
2324
  elif status == 200:
2213
- why = "%s answered, but not with an OpenAPI or Swagger JSON document" % url
2325
+ cause, why = "not-openapi", ("%s answered, but not with an OpenAPI or Swagger JSON "
2326
+ "document" % url)
2214
2327
  elif status:
2215
- why = "%s answered HTTP %d for %d s" % (url, status, seconds)
2328
+ cause, why = "http", "%s answered HTTP %d for %d s" % (url, status, seconds)
2216
2329
  else:
2217
- why = "nothing answered at %s within %d s" % (url, args.timeout)
2218
- return done(seconds=seconds, log_tail=tail,
2219
- reason=why + (" — the last exported document is kept" if result["doc_exists"] else ""))
2220
-
2221
- os.makedirs(os.path.dirname(doc_abs), exist_ok=True)
2222
- with open(doc_abs, "w", encoding="utf-8") as fh:
2223
- json.dump(doc, fh, indent=2)
2224
- fh.write("\n")
2225
- ops = _operations(doc)
2330
+ cause, why = "timeout", "nothing answered at %s within %d s" % (url, args.timeout)
2331
+ return done(cause=cause, seconds=seconds, log_tail=tail, reason=why + kept())
2226
2332
  note = ("fetched from a service that was already running at %s — it may be older than "
2227
- "the code this run built" % url) if result["already_running"] else ""
2228
- done(ok=True, seconds=seconds, operations=len(ops),
2229
- unnamed=sum(1 for o in ops if not o["id"]), reason=note, log_tail="")
2333
+ "the code being checked" % url) if result["already_running"] else ""
2334
+ save(doc, seconds, note)
2230
2335
 
2231
2336
 
2232
2337
  # ─────────────────────────── contract-draft ────────────────────────────────
2233
- # A feature's API document as its backend plan DESCRIBES it, for a frontend that binds
2234
- # before the service is built. Read by script from the plan's API section, so it says
2235
- # exactly what the plan says and is redrawn whenever plan or analyze-plan writes the plan.
2338
+ # A feature's API document as its API plan DESCRIBES it, for a frontend that binds before
2339
+ # the service is built. Read by script from the whole API plan — or, for a feature planned
2340
+ # before the API plan existed, from its backend plan's API section — so it says exactly what
2341
+ # the plan says and is redrawn whenever plan, analyze-plan or finalize-delta writes it.
2236
2342
  #
2237
2343
  # What it reads, in the plan template's own shapes:
2238
2344
  # ### `VERB /route` — purpose · Serves … one endpoint per heading
@@ -2241,7 +2347,8 @@ def cmd_contract_export(args):
2241
2347
  # - **Success**: `201` → `{ field: type, … }`
2242
2348
  # - **Errors** table: | Status | When | Body shape |
2243
2349
  # - **Maps to**: `UseCase` (§2.2), whose Request / Response (DTO) stands in for a shape
2244
- # the endpoint names instead of spelling out
2350
+ # the endpoint names instead of spelling out — read from the backend plan too, where
2351
+ # the use cases live when the endpoints are in the API plan
2245
2352
  # Conceptual types become JSON schema: string, int, decimal, money, bool, date, datetime,
2246
2353
  # id<T>, enum(A|B), list<T>, map<K,V> and nested `{ … }`. A shape it cannot read is kept as
2247
2354
  # an untyped schema carrying the plan's text, and counted as unread.
@@ -2249,6 +2356,11 @@ def cmd_contract_export(args):
2249
2356
  _ENDPOINT = re.compile(r"^(#{2,6})\s+`?\s*(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(/[^\s`]*)\s*`?\s*(.*)$",
2250
2357
  re.I)
2251
2358
  _HEADING = re.compile(r"^#{1,6}\s+(.*)$")
2359
+ # An endpoint block's `- **Name**: value` bullet — the colon inside the bold, after it, or
2360
+ # absent; an optional `(…)` note after the name. The keys that name its operation id, first
2361
+ # match wins.
2362
+ _BULLET_FIELD = re.compile(r"^\s*[-*]\s+\*\*([^*]+)\*\*\s*(?:\([^)]*\))?\s*:?\s*(.*)$")
2363
+ _OP_ID_KEYS = ("operation id", "operationid", "operation")
2252
2364
  _FIELD = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(\?)?\s*:\s*(.+?)\s*$", re.S)
2253
2365
  _RQ_ID = re.compile(r"\bRQ-\d+\b")
2254
2366
 
@@ -2333,18 +2445,25 @@ def _is_placeholder(s):
2333
2445
 
2334
2446
 
2335
2447
  class _Drafter(object):
2336
- """One backend plan's endpoints, as OpenAPI operations."""
2448
+ """One plan's endpoints, as OpenAPI operations. `uses` is the text of another document
2449
+ whose use cases the endpoints map to — the backend plan, beside an API plan."""
2337
2450
 
2338
- def __init__(self, text):
2451
+ def __init__(self, text, uses=""):
2339
2452
  self.text = _strip_comments(text)
2340
2453
  self.unread = []
2341
- self.dtos = self._dto_index()
2342
-
2343
- def _dto_index(self):
2454
+ # Set while an error body is read: the template writes those by field name alone
2455
+ # (`{ errors: [{ field, message }] }`), so a name with no type is what is expected
2456
+ # there, not a shape the plan left unfinished.
2457
+ self.lenient = False
2458
+ self.dtos = self._dto_index(_strip_comments(uses))
2459
+ self.dtos.update(self._dto_index(self.text))
2460
+
2461
+ @staticmethod
2462
+ def _dto_index(text):
2344
2463
  """Request / Response (DTO) shapes by use-case name ("Name:request"), and by the
2345
2464
  type name a DTO is given when it is written `Name { … }`."""
2346
2465
  dtos, current = {}, ""
2347
- for line in self.text.splitlines():
2466
+ for line in text.splitlines():
2348
2467
  h = _HEADING.match(line)
2349
2468
  if h:
2350
2469
  ticks = re.findall(r"`([^`]+)`", h.group(1))
@@ -2379,6 +2498,11 @@ class _Drafter(object):
2379
2498
  return {"type": "array", "items": self.schema(m.group(2))}
2380
2499
  if s.endswith("[]"):
2381
2500
  return {"type": "array", "items": self.schema(s[:-2])}
2501
+ # `[ … ]` is a list of what it holds — but only around an object, a base type or a
2502
+ # named DTO, so a template placeholder such as `[type]` stays unread.
2503
+ inner = s[1:-1].strip() if s.startswith("[") and s.endswith("]") else ""
2504
+ if inner and (inner.startswith("{") or inner.lower() in _BASE_TYPES or inner in self.dtos):
2505
+ return {"type": "array", "items": self.schema(inner)}
2382
2506
  m = re.match(r"^(map|dict|dictionary|record)\s*<\s*(.+)\s*>$", s, re.I)
2383
2507
  if m:
2384
2508
  parts = _split_top(m.group(2))
@@ -2403,8 +2527,10 @@ class _Drafter(object):
2403
2527
  return dict(base)
2404
2528
  if s in self.dtos:
2405
2529
  return self.schema(self.dtos[s])
2406
- # A named type the plan does not spell out, or prose — kept, never guessed.
2407
- self.unread.append(raw)
2530
+ # A named type the plan does not spell out, or prose — kept, never guessed. In an
2531
+ # error body it is not counted: a client reads an error by its status and code.
2532
+ if not self.lenient:
2533
+ self.unread.append(raw)
2408
2534
  return {"description": raw}
2409
2535
 
2410
2536
  def object_schema(self, body):
@@ -2414,7 +2540,16 @@ class _Drafter(object):
2414
2540
  continue
2415
2541
  m = _FIELD.match(item)
2416
2542
  if not m:
2417
- self.unread.append(item)
2543
+ # A field named with no type is still a field the client must send or read,
2544
+ # so it is kept, untyped. Outside an error body it is also counted unread:
2545
+ # the frontend's client gets no type for it.
2546
+ bare = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)(\?)?", item.strip())
2547
+ if bare:
2548
+ props[bare.group(1)] = {}
2549
+ if not bare.group(2):
2550
+ required.append(bare.group(1))
2551
+ if not (bare and self.lenient):
2552
+ self.unread.append(item)
2418
2553
  continue
2419
2554
  name, q, typ = m.groups()
2420
2555
  optional = bool(q) or typ.rstrip().endswith("?") or "(optional)" in typ.lower()
@@ -2453,9 +2588,10 @@ class _Drafter(object):
2453
2588
  return self.object_schema(fallback[1:-1])
2454
2589
  return self.schema(word)
2455
2590
 
2456
- def operations(self, plan_rel):
2457
- """[(path, method, operation)] in plan order."""
2458
- section = api_section(self.text)
2591
+ def operations(self, plan_rel, whole=False):
2592
+ """[(path, method, operation)] in plan order — from the whole text when it is an API
2593
+ plan (`whole`), else from the plan's API section when it has one."""
2594
+ section = None if whole else api_section(self.text)
2459
2595
  lines = (section if section is not None else self.text).splitlines()
2460
2596
  blocks, cur = [], None
2461
2597
  for line in lines:
@@ -2481,19 +2617,15 @@ class _Drafter(object):
2481
2617
  summary = re.split(r"\s+·\s+|\s+\|\s+", title)[0].strip()
2482
2618
  fields, errors = {}, []
2483
2619
  for line in b["lines"]:
2484
- m = re.match(r"^\s*[-*]\s+\*\*([^*]+)\*\*\s*(?:\([^)]*\))?\s*:?\s*(.*)$", line)
2620
+ m = _BULLET_FIELD.match(line)
2485
2621
  if m:
2486
2622
  fields[m.group(1).strip().rstrip(":").strip().lower()] = m.group(2).strip()
2487
2623
  continue
2488
2624
  cells = _cells(line)
2489
2625
  if cells and re.fullmatch(r"[1-5]\d\d", cells[0].strip("` ")):
2490
- errors.append((cells[0].strip("` "), cells[1] if len(cells) > 1 else ""))
2491
- oid = ""
2492
- for key in ("operation id", "operationid", "operation"):
2493
- if key in fields:
2494
- ids = _op_ids(fields[key])
2495
- oid = ids[0] if ids else ""
2496
- break
2626
+ errors.append((cells[0].strip("` "), cells[1] if len(cells) > 1 else "",
2627
+ cells[2] if len(cells) > 2 else ""))
2628
+ oid = _fields_operation_id(fields)
2497
2629
  maps = ""
2498
2630
  if "maps to" in fields:
2499
2631
  ticks = re.findall(r"`([^`]+)`", fields["maps to"])
@@ -2506,8 +2638,8 @@ class _Drafter(object):
2506
2638
  op["operationId"] = oid
2507
2639
  if summary:
2508
2640
  op["summary"] = summary
2509
- op["description"] = ("Drafted from %s — what the backend plan says this endpoint will "
2510
- "serve, not what the service serves." % plan_rel)
2641
+ op["description"] = ("Drafted from %s — what the plan says this endpoint "
2642
+ "serves. The backend gates check the code against it." % plan_rel)
2511
2643
  op["x-aw-draft"] = True
2512
2644
 
2513
2645
  path_names = re.findall(r"\{([^}]+)\}", path)
@@ -2553,9 +2685,16 @@ class _Drafter(object):
2553
2685
  responses = {code: {"description": "Success"}}
2554
2686
  if result is not None and code != "204":
2555
2687
  responses[code]["content"] = {"application/json": {"schema": result}}
2556
- for err, when in errors:
2688
+ for err, when, shape in errors:
2557
2689
  if err not in responses:
2558
2690
  responses[err] = {"description": re.sub(r"\s+", " ", when).strip() or "Error"}
2691
+ self.lenient = True
2692
+ try:
2693
+ body = self.shape(shape) if shape.strip() else None
2694
+ finally:
2695
+ self.lenient = False
2696
+ if body:
2697
+ responses[err]["content"] = {"application/json": {"schema": body}}
2559
2698
  op["responses"] = responses
2560
2699
  return path, method, op
2561
2700
 
@@ -2589,35 +2728,203 @@ def _is_draft_file(path):
2589
2728
  return False
2590
2729
 
2591
2730
 
2592
- def _draft_ids(doc):
2593
- """The operation ids a document carries only as draft operations."""
2594
- out = set()
2595
- for item in (doc.get("paths") or {}).values():
2596
- if isinstance(item, dict):
2597
- for method in CONTRACT_HTTP_METHODS:
2598
- op = item.get(method)
2599
- if isinstance(op, dict) and op.get("x-aw-draft") and op.get("operationId"):
2600
- out.add(str(op["operationId"]))
2731
+ def api_source(fd):
2732
+ """(path, whole) — where a feature's API is read from: its API plan, all of it, or the
2733
+ backend plan of a feature planned before the API plan existed, its API section only.
2734
+ ("", False) with neither."""
2735
+ p = os.path.join(fd, API_PLAN_NAME)
2736
+ if os.path.isfile(p):
2737
+ return p, True
2738
+ return _first_file(fd, BACKEND_PLAN_NAMES), False
2739
+
2740
+
2741
+ def delta_api_source(cr_dir):
2742
+ """(path, whole) — where a change's API delta is read from: its delta API plan, or the
2743
+ delta backend plan of a change planned before the delta API plan existed."""
2744
+ p = os.path.join(cr_dir, DELTA_API_PLAN_NAME)
2745
+ if os.path.isfile(p):
2746
+ return p, True
2747
+ return _first_file(cr_dir, DELTA_PLAN_NAMES["backend"]), False
2748
+
2749
+
2750
+ def _delta_contract_rows(text):
2751
+ """[{id, change, method, path, shape}] from a change's "Contract deltas for the frontend
2752
+ lane" table — one per operation the change adds, changes or removes."""
2753
+ out, seen = [], set()
2754
+ for header, rows in _tables(text):
2755
+ ic, cc = _col(header, "operation id"), _col(header, "change")
2756
+ if ic < 0 or cc < 0:
2757
+ continue
2758
+ kc, sc = _col(header, "contract"), _col(header, "shape")
2759
+ for row in rows:
2760
+ ids = _op_ids(row[ic]) if ic < len(row) else []
2761
+ change = row[cc].lower() if cc < len(row) else ""
2762
+ kind = ("removed" if re.search(r"remov|delet|retir", change)
2763
+ else "changed" if re.search(r"chang|modif|narrow|renam", change)
2764
+ else "added" if re.search(r"add|new|introduc", change) else "")
2765
+ if not ids or not kind or ids[0] in seen or _is_placeholder(ids[0]):
2766
+ continue
2767
+ seen.add(ids[0])
2768
+ m = re.search(r"\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+`?(/[^\s`|]*)",
2769
+ row[kc] if 0 <= kc < len(row) else "", re.I)
2770
+ out.append({"id": ids[0], "change": kind,
2771
+ "method": m.group(1).lower() if m else "",
2772
+ "path": m.group(2).rstrip(".,;") if m else "",
2773
+ "shape": row[sc] if 0 <= sc < len(row) else ""})
2601
2774
  return out
2602
2775
 
2603
2776
 
2604
- def cmd_contract_draft(args):
2605
- repo = args.repo
2606
- fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
2607
- rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
2608
- draft = os.path.join(fd, DRAFT_NAME)
2609
- # No feature named means no plan, never the repository root's.
2610
- plan = _first_file(fd, BACKEND_PLAN_NAMES) if args.feature_dir else ""
2611
- out = {"ok": True, "written": False, "removed": False, "path": rel(draft), "plan": rel(plan),
2612
- "operations": 0, "ids": [], "ids_csv": "", "unnamed": 0, "unread": [],
2613
- "unread_count": 0, "duplicates": [], "reason": ""}
2777
+ def _draft_change(repo, fd, cr, out, rel):
2778
+ """The change's draft: changes/<cr>/DRAFT_NAME, from its delta API plan — or, for a
2779
+ change planned before that existed, from its delta backend plan's API section.
2780
+
2781
+ An endpoint the change adds or changes is read from its block, written exactly as the
2782
+ API plan writes one; the "Contract deltas" table says which of the two it is, and which
2783
+ operations the change removes. A row with no block is drafted from the table alone —
2784
+ its shapes then only as precise as that one cell."""
2785
+ cr_dir = os.path.join(fd, "changes", cr)
2786
+ draft = os.path.join(cr_dir, DRAFT_NAME)
2787
+ out.update(cr=cr, path=rel(draft), added_ids=[], changed_ids=[], removed_ids=[],
2788
+ from_table=[])
2789
+ if not os.path.isdir(cr_dir):
2790
+ out["reason"] = "change %s has no folder in %s" % (cr, rel(fd))
2791
+ return emit(out)
2792
+ plan, whole = delta_api_source(cr_dir)
2793
+ out["plan"] = rel(plan)
2614
2794
  if not plan:
2615
- out["reason"] = "no backend plan in %s — nothing to draft" % rel(fd)
2795
+ out["reason"] = ("change %s has no delta API plan and no delta backend plan — nothing "
2796
+ "to draft" % cr)
2616
2797
  return emit(out)
2617
2798
  text = _read(plan)
2618
- drafter = _Drafter(text)
2799
+ # The use cases an endpoint maps to are the backend plans', the change's and the master's.
2800
+ uses = "\n".join(_read(p) for p in (_first_file(cr_dir, DELTA_PLAN_NAMES["backend"]),
2801
+ _first_file(fd, BACKEND_PLAN_NAMES)) if p) if whole else ""
2802
+ rows = _delta_contract_rows(_strip_comments(text))
2803
+ kinds = {r["id"]: r["change"] for r in rows}
2804
+ master = {}
2805
+ master_path = os.path.join(fd, DRAFT_NAME)
2806
+ if os.path.isfile(master_path):
2807
+ try:
2808
+ master = {o["id"] for o in _operations(json.load(open(master_path, encoding="utf-8")))
2809
+ if o["id"]}
2810
+ except Exception:
2811
+ master = set()
2812
+ section = text if whole else api_section(text)
2813
+ drafter = _Drafter(section if section is not None else "", uses)
2814
+ paths, seen, dupes, blocks = {}, set(), [], set()
2815
+ for path, method, op in (drafter.operations(rel(plan), whole) if section is not None else []):
2816
+ if (path, method) in seen:
2817
+ dupes.append("%s %s" % (method.upper(), path))
2818
+ continue
2819
+ seen.add((path, method))
2820
+ oid = op.get("operationId", "")
2821
+ kind = kinds.get(oid) or ("changed" if oid in master else "added")
2822
+ if kind == "removed":
2823
+ continue
2824
+ op["x-aw-change"], op["x-aw-cr"] = kind, cr
2825
+ paths.setdefault(path, {})[method] = op
2826
+ blocks.add(oid)
2827
+ out["%s_ids" % kind].append(oid or "%s %s" % (method.upper(), path))
2828
+ removed = []
2829
+ for r in rows:
2830
+ if r["change"] == "removed":
2831
+ removed.append({k: r[k] for k in ("id", "method", "path")})
2832
+ out["removed_ids"].append(r["id"])
2833
+ continue
2834
+ if r["id"] in blocks or not r["method"] or not r["path"]:
2835
+ continue
2836
+ # No block: the row's `request → response` cell is all there is to draft from.
2837
+ parts = re.split(r"→|->", r["shape"], maxsplit=1)
2838
+ op = {"operationId": r["id"],
2839
+ "description": ("Drafted from %s, its Contract deltas table — the change gives "
2840
+ "this endpoint no block, so its shapes are only as precise as "
2841
+ "that table cell." % rel(plan)),
2842
+ "x-aw-draft": True, "x-aw-change": r["change"], "x-aw-cr": cr}
2843
+ body = drafter.shape(parts[0]) if len(parts) > 1 and r["method"] in ("post", "put", "patch") else None
2844
+ if body is not None:
2845
+ op["requestBody"] = {"required": True, "content": {"application/json": {"schema": body}}}
2846
+ result_shape = drafter.shape(parts[-1]) if r["shape"].strip() else None
2847
+ op["responses"] = {"200": {"description": "Success"}}
2848
+ if result_shape is not None:
2849
+ op["responses"]["200"]["content"] = {"application/json": {"schema": result_shape}}
2850
+ path = re.sub(r":([A-Za-z_]\w*)", r"{\1}", r["path"])
2851
+ names = re.findall(r"\{([^}]+)\}", path)
2852
+ if names:
2853
+ op["parameters"] = [{"name": n, "in": "path", "required": True,
2854
+ "schema": {"type": "string"}} for n in names]
2855
+ paths.setdefault(path, {})[r["method"]] = op
2856
+ out["%s_ids" % r["change"]].append(r["id"])
2857
+ out["from_table"].append(r["id"])
2858
+ count = sum(len(v) for v in paths.values())
2859
+ ids = [op["operationId"] for v in paths.values() for op in v.values() if op.get("operationId")]
2860
+ out.update(operations=count, ids=ids, ids_csv=", ".join(ids), unnamed=count - len(ids),
2861
+ unread=drafter.unread[:20], unread_count=len(drafter.unread),
2862
+ unread_text="; ".join(drafter.unread[:20]) or "none", duplicates=dupes)
2863
+ if not count and not removed:
2864
+ if os.path.isfile(draft) and _is_draft_file(draft):
2865
+ os.remove(draft)
2866
+ out["removed"] = True
2867
+ out["reason"] = ("%s changes no endpoint — its Contract deltas table names none, and it "
2868
+ "has no endpoint block such as ### `POST /route`" % rel(plan))
2869
+ return emit(out)
2870
+ doc = {
2871
+ "openapi": "3.0.3",
2872
+ "info": {
2873
+ "title": "%s — API draft of change %s" % (os.path.basename(os.path.normpath(fd)), cr),
2874
+ "version": "draft",
2875
+ "description": ("Drafted by script from %s — the operations change %s adds or "
2876
+ "changes, and the ones it removes (x-aw-removed). Applied over the "
2877
+ "feature's draft while the change is open; finalize-delta merges "
2878
+ "the change into the feature's API plan. Rewritten by plan and "
2879
+ "analyze-plan-delta; do not edit." % (rel(plan), cr)),
2880
+ },
2881
+ "x-aw-draft": {"source": rel(plan), "source_sha": _sha_text(text), "cr": cr},
2882
+ "paths": paths,
2883
+ }
2884
+ if removed:
2885
+ doc["x-aw-removed"] = removed
2886
+ body = json.dumps(doc, indent=2) + "\n"
2887
+ if _read(draft) != body:
2888
+ with open(draft, "w", encoding="utf-8") as fh:
2889
+ fh.write(body)
2890
+ out["written"] = True
2891
+ emit(out)
2892
+
2893
+
2894
+ def _draft_result(rel, draft):
2895
+ return {"ok": True, "written": False, "removed": False, "path": rel(draft), "plan": "",
2896
+ "operations": 0, "ids": [], "ids_csv": "", "unnamed": 0, "unread": [],
2897
+ "unread_count": 0, "unread_text": "none", "duplicates": [],
2898
+ "dropped": [], "dropped_count": 0, "dropped_text": "none", "reason": ""}
2899
+
2900
+
2901
+ def _op_labels(doc):
2902
+ """{key: label} for every operation of a document — keyed by operation id, or by verb and
2903
+ route for one without an id."""
2904
+ out = {}
2905
+ for p, m, op in _doc_ops(doc):
2906
+ oid = str(op.get("operationId") or "")
2907
+ out[oid or _route_key(p, m)] = ("%s (%s %s)" % (oid, m.upper(), p)) if oid \
2908
+ else "%s %s" % (m.upper(), p)
2909
+ return out
2910
+
2911
+
2912
+ def _draft_master(repo, fd, rel):
2913
+ """Redraw a feature's DRAFT_NAME from its API plan — or its backend plan's API section —
2914
+ and return what it drafted, with every operation the draft it replaced had and this one
2915
+ does not (`dropped`): the frontend may be bound to each."""
2916
+ draft = os.path.join(fd, DRAFT_NAME)
2917
+ out = _draft_result(rel, draft)
2918
+ plan, whole = api_source(fd)
2919
+ out["plan"] = rel(plan)
2920
+ if not plan:
2921
+ out["reason"] = "no API plan and no backend plan in %s — nothing to draft" % rel(fd)
2922
+ return out
2923
+ text = _read(plan)
2924
+ uses = _read(_first_file(fd, BACKEND_PLAN_NAMES)) if whole else ""
2925
+ drafter = _Drafter(text, uses)
2619
2926
  paths, ids, dupes, seen = {}, [], [], set()
2620
- for path, method, op in drafter.operations(rel(plan)):
2927
+ for path, method, op in drafter.operations(rel(plan), whole):
2621
2928
  if (path, method) in seen:
2622
2929
  dupes.append("%s %s" % (method.upper(), path))
2623
2930
  continue
@@ -2626,28 +2933,35 @@ def cmd_contract_draft(args):
2626
2933
  if op.get("operationId"):
2627
2934
  ids.append(op["operationId"])
2628
2935
  count = sum(len(v) for v in paths.values())
2936
+ before = _op_labels(_load_doc(draft)) if _is_draft_file(draft) else {}
2937
+ now = _op_labels({"paths": paths})
2938
+ dropped = [label for key, label in before.items() if key not in now]
2629
2939
  out.update(operations=count, ids=ids, ids_csv=", ".join(ids),
2630
2940
  unnamed=count - len(ids), unread=drafter.unread[:20],
2631
- unread_count=len(drafter.unread), duplicates=dupes)
2941
+ unread_count=len(drafter.unread),
2942
+ unread_text="; ".join(drafter.unread[:20]) or "none", duplicates=dupes,
2943
+ dropped=dropped, dropped_count=len(dropped),
2944
+ dropped_text=", ".join(dropped) or "none")
2632
2945
  if not count:
2633
2946
  # A draft this script wrote earlier would now describe endpoints the plan dropped.
2634
2947
  if os.path.isfile(draft) and _is_draft_file(draft):
2635
2948
  os.remove(draft)
2636
2949
  out["removed"] = True
2637
- out["reason"] = ("%s names no endpoint — no heading such as ### `POST /route` in its "
2638
- "API section — so there is nothing to draft" % rel(plan))
2639
- return emit(out)
2640
- section = api_section(text)
2950
+ out["reason"] = ("%s names no endpoint — no heading such as ### `POST /route`%s — so "
2951
+ "there is nothing to draft"
2952
+ % (rel(plan), "" if whole else " in its API section"))
2953
+ return out
2954
+ section = text if whole else api_section(text)
2641
2955
  doc = {
2642
2956
  "openapi": "3.0.3",
2643
2957
  "info": {
2644
2958
  "title": "%s — API draft" % os.path.basename(os.path.normpath(fd)),
2645
2959
  "version": "draft",
2646
- "description": ("Drafted by script from %s, its API section — what the backend "
2647
- "plan says the service will serve, not what it serves. The "
2648
- "service's exported document replaces each operation once it "
2649
- "serves its id. Rewritten by plan and analyze-plan; do not edit."
2650
- % rel(plan)),
2960
+ "description": ("Drafted by script from %s%s — what the plan says the service "
2961
+ "serves. It is the contract the frontend lane binds to, and the "
2962
+ "one the backend gates check the code against. Rewritten by plan, "
2963
+ "analyze-plan, finalize-delta and adopt; do not edit."
2964
+ % (rel(plan), "" if whole else ", its API section")),
2651
2965
  },
2652
2966
  "x-aw-draft": {"source": rel(plan),
2653
2967
  "source_sha": _sha_text(section if section is not None else text)},
@@ -2658,15 +2972,35 @@ def cmd_contract_draft(args):
2658
2972
  with open(draft, "w", encoding="utf-8") as fh:
2659
2973
  fh.write(body)
2660
2974
  out["written"] = True
2661
- emit(out)
2975
+ return out
2976
+
2977
+
2978
+ def cmd_contract_draft(args):
2979
+ repo = args.repo
2980
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
2981
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
2982
+ if not args.feature_dir:
2983
+ out = _draft_result(rel, os.path.join(fd, DRAFT_NAME))
2984
+ out["reason"] = "no feature named — nothing to draft"
2985
+ return emit(out)
2986
+ if args.cr:
2987
+ return _draft_change(repo, fd, args.cr, _draft_result(rel, os.path.join(fd, DRAFT_NAME)),
2988
+ rel)
2989
+ emit(_draft_master(repo, fd, rel))
2662
2990
 
2663
2991
 
2664
2992
  # ─────────────────────────── contract-combine ──────────────────────────────
2665
- # What a frontend generates its client from: the service's exported document, plus every
2666
- # operation a backend plan has drafted that the document does not serve yet. A draft whose
2667
- # id the service serves is dropped — the export is the truth for it — and one whose route
2668
- # and verb the service already answers under another id is left out and reported, since
2669
- # adding it would describe one endpoint twice.
2993
+ # What a frontend generates its client from. THE PLAN WINS: every operation a backend plan
2994
+ # drafts, with each OPEN change's draft applied over its feature's — an operation it adds
2995
+ # appears, one it changes is replaced, one it removes is kept and marked `deprecated` with
2996
+ # the change that removes it, so a frontend still bound to it is told rather than broken.
2997
+ # An operation an open change serves on the route of one an open change removes takes that
2998
+ # route: the removed one leaves `paths` for the document's `x-aw-removed` list, with its
2999
+ # route and the change, so a binding to it still reads as removed rather than missing.
3000
+ # The as-built document, when there is one, adds only what no plan describes, marked
3001
+ # `x-aw-unplanned`, and marks every planned operation it does not serve `x-aw-unserved`.
3002
+ # A clash — one operation drafted twice, or a route the service answers under another id —
3003
+ # is left out and reported, since adding it would describe one endpoint twice.
2670
3004
 
2671
3005
  def _draft_files(repo):
2672
3006
  base = os.path.join(repo, "specs")
@@ -2675,11 +3009,144 @@ def _draft_files(repo):
2675
3009
  return sorted(set(found))
2676
3010
 
2677
3011
 
3012
+ def _change_draft_files(repo):
3013
+ """Every OPEN change's draft, oldest change first — changes/<cr>/DRAFT_NAME, never one
3014
+ under changes/archive/. A change id carries its creation time, so its name orders it."""
3015
+ base = os.path.join(repo, "specs")
3016
+ found = glob.glob(os.path.join(base, "*", "changes", "*", DRAFT_NAME)) + \
3017
+ glob.glob(os.path.join(base, "*", "*", "changes", "*", DRAFT_NAME))
3018
+ found = [f for f in set(found) if os.path.basename(os.path.dirname(f)) != "archive"]
3019
+ return sorted(found, key=lambda f: os.path.basename(os.path.dirname(f)))
3020
+
3021
+
2678
3022
  def _route_key(path, method):
2679
3023
  norm = re.sub(r"\{[^}]*\}", "{}", path.rstrip("/") or "/").lower()
2680
3024
  return norm, method.upper()
2681
3025
 
2682
3026
 
3027
+ def _doc_ops(doc):
3028
+ """[(path, method, operation)] of a document, in document order."""
3029
+ out = []
3030
+ for path, item in ((doc or {}).get("paths") or {}).items():
3031
+ if not isinstance(item, dict):
3032
+ continue
3033
+ for method in CONTRACT_HTTP_METHODS:
3034
+ op = item.get(method)
3035
+ if isinstance(op, dict):
3036
+ out.append((path, method, op))
3037
+ return out
3038
+
3039
+
3040
+ def _load_doc(path):
3041
+ try:
3042
+ with open(path, encoding="utf-8") as fh:
3043
+ doc = json.load(fh)
3044
+ except Exception:
3045
+ return None
3046
+ return doc if isinstance(doc, dict) and isinstance(doc.get("paths"), dict) else None
3047
+
3048
+
3049
+ class _Planned(object):
3050
+ """The planned operations, keyed by id and by route: feature drafts, then open changes."""
3051
+
3052
+ def __init__(self):
3053
+ self.paths, self.by_id, self.routes, self.clashes = {}, {}, {}, []
3054
+ self.removed, self.retired, self.changes = [], [], []
3055
+
3056
+ def put(self, path, method, op):
3057
+ self.paths.setdefault(path, {})[method] = op
3058
+ oid = str(op.get("operationId") or "")
3059
+ if oid:
3060
+ self.by_id[oid] = (path, method)
3061
+ self.routes[_route_key(path, method)] = oid
3062
+
3063
+ def drop(self, path, method):
3064
+ op = self.paths.get(path, {}).pop(method, None)
3065
+ if path in self.paths and not self.paths[path]:
3066
+ del self.paths[path]
3067
+ self.routes.pop(_route_key(path, method), None)
3068
+ if op and op.get("operationId"):
3069
+ self.by_id.pop(str(op["operationId"]), None)
3070
+
3071
+ def get(self, oid):
3072
+ loc = self.by_id.get(oid)
3073
+ return self.paths[loc[0]][loc[1]] if loc else None
3074
+
3075
+ def retire(self, path, method):
3076
+ """Takes an operation an open change removes out of the paths, so the operation that
3077
+ replaces it can have its route; it is listed in `retired`."""
3078
+ op = self.paths[path][method]
3079
+ oid, cr = str(op.get("operationId") or ""), op["x-aw-removed-by"]
3080
+ if "%s (%s)" % (oid, cr) in self.removed:
3081
+ self.removed.remove("%s (%s)" % (oid, cr))
3082
+ self.retired.append({"id": oid, "method": method, "path": path, "cr": cr})
3083
+ self.drop(path, method)
3084
+
3085
+ def add_draft(self, doc, source):
3086
+ import copy
3087
+ for path, method, op in _doc_ops(doc):
3088
+ oid = str(op.get("operationId") or "")
3089
+ if oid and oid in self.by_id:
3090
+ self.clashes.append("%s drafted twice (%s)" % (oid, source))
3091
+ continue
3092
+ if _route_key(path, method) in self.routes:
3093
+ self.clashes.append("%s %s drafted twice (%s)" % (method.upper(), path, source))
3094
+ continue
3095
+ new = copy.deepcopy(op)
3096
+ new["x-aw-draft"], new["x-aw-draft-source"] = True, source
3097
+ self.put(path, method, new)
3098
+
3099
+ def apply_change(self, doc, source, cr):
3100
+ import copy
3101
+ touched = False
3102
+ for r in doc.get("x-aw-removed") or []:
3103
+ op = self.get(str(r.get("id") or ""))
3104
+ if op is not None:
3105
+ op["deprecated"], op["x-aw-removed-by"] = True, cr
3106
+ self.removed.append("%s (%s)" % (r["id"], cr))
3107
+ touched = True
3108
+ for path, method, op in _doc_ops(doc):
3109
+ oid = str(op.get("operationId") or "")
3110
+ prev = self.get(oid) if oid else None
3111
+ if prev is not None:
3112
+ if prev.get("x-aw-change-by") and prev["x-aw-change-by"] != cr:
3113
+ self.clashes.append("%s is changed by both %s and %s — %s, the later, is "
3114
+ "applied" % (oid, prev["x-aw-change-by"], cr, cr))
3115
+ self.drop(*self.by_id[oid])
3116
+ key = _route_key(path, method)
3117
+ held = self.get(self.routes.get(key) or "")
3118
+ if held is not None and held.get("x-aw-removed-by"):
3119
+ self.retire(*self.by_id[self.routes[key]])
3120
+ if key in self.routes:
3121
+ self.clashes.append("%s %s is %s in a draft and %s in change %s" % (
3122
+ method.upper(), path, self.routes[key] or "unnamed", oid or "unnamed", cr))
3123
+ continue
3124
+ new = copy.deepcopy(op)
3125
+ new["x-aw-draft"], new["x-aw-draft-source"], new["x-aw-change-by"] = True, source, cr
3126
+ self.put(path, method, new)
3127
+ touched = True
3128
+ if touched:
3129
+ self.changes.append(cr)
3130
+
3131
+
3132
+ def planned_operations(repo, rel, changes=True):
3133
+ """Every planned operation in the repository: _Planned, plus the files read."""
3134
+ plan, drafts, change_files = _Planned(), [], []
3135
+ for f in _draft_files(repo):
3136
+ doc = _load_doc(f)
3137
+ if doc is not None:
3138
+ drafts.append(rel(f))
3139
+ plan.add_draft(doc, rel(f))
3140
+ for f in (_change_draft_files(repo) if changes else []):
3141
+ doc = _load_doc(f)
3142
+ if doc is not None:
3143
+ change_files.append(rel(f))
3144
+ cr = str((doc.get("x-aw-draft") or {}).get("cr") or
3145
+ os.path.basename(os.path.dirname(f)))
3146
+ plan.apply_change(doc, rel(f), cr)
3147
+ return plan, drafts, change_files
3148
+
3149
+
2683
3150
  def cmd_contract_combine(args):
2684
3151
  import copy
2685
3152
  repo = args.repo
@@ -2691,96 +3158,99 @@ def cmd_contract_combine(args):
2691
3158
  with open(export_abs, "rb") as fh:
2692
3159
  export = _api_document(fh.read())
2693
3160
  out_abs = os.path.join(repo, COMBINED_DOC)
3161
+ plan, drafts, change_files = planned_operations(repo, rel)
3162
+ planned_n = sum(len(v) for v in plan.paths.values())
2694
3163
  result = {"ok": False, "doc": "", "export": export_rel,
2695
3164
  "export_exists": export is not None, "export_operations": 0,
2696
- "drafts": [], "draft_files": 0, "draft_operations": 0,
2697
- "added": 0, "added_ids": [], "added_csv": "", "served_csv": "",
3165
+ "drafts": drafts, "draft_files": len(drafts),
3166
+ "change_drafts": change_files, "changes_applied": plan.changes,
3167
+ "changes_csv": ", ".join(plan.changes),
3168
+ "planned": planned_n, "planned_ids": sorted(plan.by_id),
3169
+ "removed_csv": ", ".join(plan.removed + ["%s (%s)" % (r["id"], r["cr"])
3170
+ for r in plan.retired]),
3171
+ "unplanned": [], "unplanned_csv": "", "unserved": [], "unserved_csv": "",
2698
3172
  "clashes": [], "clashes_csv": "", "status": "", "reason": "",
2699
3173
  # The placeholder a `client` command names the document by, spelled out for
2700
3174
  # a prompt that asks for one (see contract-export).
2701
3175
  "placeholder": "{{contract}}"}
2702
3176
 
2703
- combined = copy.deepcopy(export) if export else {
3177
+ combined = {
2704
3178
  "openapi": "3.0.3",
2705
- "info": {"title": "API — drafts only", "version": "draft",
2706
- "description": ("No exported document yet: every operation here is drafted "
2707
- "from a backend plan and none is served.")},
2708
- "paths": {},
3179
+ "info": {"title": "API — planned", "version": "plan",
3180
+ "description": ("The contract the frontend binds to: every operation the "
3181
+ "features' API plans draft, with each open change applied. "
3182
+ "Written by contract-combine on every frontend implement; do "
3183
+ "not edit.")},
3184
+ "paths": plan.paths,
2709
3185
  }
2710
- combined.setdefault("paths", {})
2711
- export_ops = _operations(export) if export else []
2712
- export_ids = {o["id"] for o in export_ops if o["id"]}
2713
- routes = {_route_key(o["path"], o["method"]) for o in export_ops}
2714
- added_ids = set()
2715
- added, served, clashes = [], [], []
2716
- drafts = _draft_files(repo)
2717
- for f in drafts:
2718
- try:
2719
- with open(f, encoding="utf-8") as fh:
2720
- doc = json.load(fh)
2721
- except Exception:
2722
- continue
2723
- if not isinstance(doc, dict) or not isinstance(doc.get("paths"), dict):
2724
- continue
2725
- result["drafts"].append(rel(f))
2726
- for path, item in doc["paths"].items():
2727
- if not isinstance(item, dict):
3186
+ if plan.retired:
3187
+ combined["x-aw-removed"] = plan.retired
3188
+ unplanned, unserved = [], []
3189
+ if export is not None:
3190
+ ops = _doc_ops(export)
3191
+ result["export_operations"] = len(ops)
3192
+ served_ids, served_routes = set(), set()
3193
+ for path, method, op in ops:
3194
+ oid = str(op.get("operationId") or "")
3195
+ key = _route_key(path, method)
3196
+ served_routes.add(key)
3197
+ if oid and oid in plan.by_id:
3198
+ served_ids.add(oid)
3199
+ continue
3200
+ if key in plan.routes:
3201
+ if plan.routes[key] and oid:
3202
+ plan.clashes.append("the service answers %s %s as %s; the plan names it %s"
3203
+ % (method.upper(), path, oid, plan.routes[key]))
2728
3204
  continue
2729
- for method in CONTRACT_HTTP_METHODS:
2730
- op = item.get(method)
2731
- if not isinstance(op, dict):
3205
+ new = copy.deepcopy(op)
3206
+ new["x-aw-unplanned"] = True
3207
+ plan.put(path, method, new)
3208
+ unplanned.append(oid or "%s %s" % (method.upper(), path))
3209
+ for path, item in plan.paths.items():
3210
+ for method, op in item.items():
3211
+ if op.get("x-aw-unplanned") or op.get("x-aw-removed-by"):
2732
3212
  continue
2733
- result["draft_operations"] += 1
2734
3213
  oid = str(op.get("operationId") or "")
2735
- if oid and oid in export_ids:
2736
- served.append(oid)
2737
- continue
2738
- if oid and oid in added_ids:
2739
- clashes.append("%s drafted twice (%s)" % (oid, rel(f)))
3214
+ if (oid and oid in served_ids) or _route_key(path, method) in served_routes:
2740
3215
  continue
2741
- key = _route_key(path, method)
2742
- if key in routes:
2743
- clashes.append("%s %s (%s) — the service already answers it under another id"
2744
- % (method.upper(), path, oid or "no id"))
2745
- continue
2746
- new = copy.deepcopy(op)
2747
- new["x-aw-draft"] = True
2748
- new["x-aw-draft-source"] = rel(f)
2749
- combined["paths"].setdefault(path, {})[method] = new
2750
- routes.add(key)
2751
- if oid:
2752
- added_ids.add(oid)
2753
- added.append(oid or "%s %s" % (method.upper(), path))
2754
- result.update(draft_files=len(result["drafts"]), export_operations=len(export_ops),
2755
- added=len(added), added_ids=added, added_csv=", ".join(added),
2756
- served_csv=", ".join(served), clashes=clashes, clashes_csv="; ".join(clashes))
2757
-
2758
- if export is None and not added:
3216
+ op["x-aw-unserved"] = True
3217
+ unserved.append(oid or "%s %s" % (method.upper(), path))
3218
+ result.update(unplanned=unplanned, unplanned_csv=", ".join(unplanned),
3219
+ unserved=unserved, unserved_csv=", ".join(unserved),
3220
+ clashes=plan.clashes, clashes_csv="; ".join(plan.clashes))
3221
+
3222
+ if not plan.paths:
2759
3223
  # Nothing to read: a combined document left from an earlier run would be stale.
2760
3224
  if os.path.isfile(out_abs):
2761
3225
  os.remove(out_abs)
2762
- result["reason"] = ("the service has not published %s and no backend plan has a draft "
2763
- "(%s) — there is no API document to bind to"
2764
- % (export_rel or "an API document (no service lane)", DRAFT_NAME))
3226
+ result["reason"] = ("no plan drafts an endpoint (%s)%s — there is no API "
3227
+ "document to bind to" % (DRAFT_NAME, "" if export is not None else
3228
+ ", and the service lane has written no as-built document"))
2765
3229
  return emit(result)
2766
3230
 
2767
3231
  os.makedirs(os.path.dirname(out_abs), exist_ok=True)
2768
3232
  with open(out_abs, "w", encoding="utf-8") as fh:
2769
3233
  json.dump(combined, fh, indent=2)
2770
3234
  fh.write("\n")
2771
- if export is not None and added:
2772
- status = ("%s — the service's exported document (%s, %d operation(s)) plus %d "
2773
- "operation(s) drafted from the backend plans that it does not serve yet, "
2774
- "marked x-aw-draft: %s" % (COMBINED_DOC, export_rel, len(export_ops),
2775
- len(added), ", ".join(added)))
2776
- elif export is not None:
2777
- status = ("%s — the service's exported document (%s, %d operation(s)); no drafted "
2778
- "operation is missing from it" % (COMBINED_DOC, export_rel, len(export_ops)))
3235
+ parts = ["%s — %d planned operation(s) from %d feature draft(s)"
3236
+ % (COMBINED_DOC, planned_n, len(drafts))]
3237
+ if plan.changes:
3238
+ parts.append("with the open change(s) %s applied" % ", ".join(plan.changes))
3239
+ if plan.removed:
3240
+ parts.append("marked deprecated because an open change removes them: %s"
3241
+ % ", ".join(plan.removed))
3242
+ if plan.retired:
3243
+ parts.append("replaced on their route by an open change's operation, and listed in "
3244
+ "x-aw-removed: %s" % ", ".join("%s (%s)" % (r["id"], r["cr"])
3245
+ for r in plan.retired))
3246
+ if export is not None:
3247
+ parts.append("compared with the service's as-built document %s (%d operation(s)): "
3248
+ "%s planned operation(s) it does not serve yet (x-aw-unserved), %d it "
3249
+ "serves that no plan describes (x-aw-unplanned)"
3250
+ % (export_rel, result["export_operations"], len(unserved), len(unplanned)))
2779
3251
  else:
2780
- status = ("%s — %d operation(s) drafted from the backend plans, marked x-aw-draft; the "
2781
- "service has not published %s yet, so none of them is served: %s"
2782
- % (COMBINED_DOC, len(added), export_rel or "an API document", ", ".join(added)))
2783
- result.update(ok=True, doc=COMBINED_DOC, status=status)
3252
+ parts.append("no as-built document, so which of them the service serves is not known")
3253
+ result.update(ok=True, doc=COMBINED_DOC, status="; ".join(parts))
2784
3254
  emit(result)
2785
3255
 
2786
3256
 
@@ -2788,11 +3258,12 @@ def cmd_contract_combine(args):
2788
3258
  # The request ledger between the lanes, read from both plans:
2789
3259
  # frontend plan §5 "Contract deltas needed" — | Request | Operation id | Needed by |
2790
3260
  # Sends | Receives | Serves |, one RQ-n per row, the operation id it proposes
2791
- # backend plan's API section "Requests from the frontend lane" — | Request | Outcome |
2792
- # Operation id | Why |, `published` with the id that serves it, or `declined` with why
3261
+ # the API plan's "Requests from the frontend lane" — | Request | Outcome | Operation id |
3262
+ # Why |, `published` with the id that serves it, or `declined` with why (the backend
3263
+ # plan's API section, for a feature planned before the API plan existed)
2793
3264
  # A request is OPEN until the backend answers it — and still open when the answer names an
2794
- # id the backend plan has no endpoint for. A PUBLISHED request whose id no frontend binding
2795
- # row cites yet is to RE-BIND. A DECLINED one goes back to the frontend plan.
3265
+ # id the API plan has no endpoint for. A PUBLISHED request whose id no frontend binding row
3266
+ # cites yet is to RE-BIND. A DECLINED one goes back to the frontend plan.
2796
3267
 
2797
3268
  def _tables(text):
2798
3269
  """Every markdown table: (lowered header cells, [row cells])."""
@@ -2863,12 +3334,34 @@ def backend_answers(text):
2863
3334
  return answers
2864
3335
 
2865
3336
 
3337
+ def _fields_operation_id(fields):
3338
+ """The operation id a block's bullets ({lowered name: value}) name; "" when none does."""
3339
+ for key in _OP_ID_KEYS:
3340
+ if key in fields:
3341
+ ids = _op_ids(fields[key])
3342
+ return ids[0] if ids else ""
3343
+ return ""
3344
+
3345
+
2866
3346
  def backend_operation_ids(text):
2867
- ids = []
2868
- for value in re.findall(r"\*\*Operation id\*\*\s*:\s*(.+)", _strip_comments(text)):
2869
- for x in _op_ids(value)[:1]:
2870
- if x not in ids:
2871
- ids.append(x)
3347
+ """The operation id of each endpoint block in `text`, in order, read as contract-draft
3348
+ reads it: the block runs from its `VERB /route` heading to the next heading."""
3349
+ ids, fields = [], None
3350
+
3351
+ def close():
3352
+ x = _fields_operation_id(fields) if fields is not None else ""
3353
+ if x and x not in ids:
3354
+ ids.append(x)
3355
+
3356
+ for line in _strip_comments(text).splitlines():
3357
+ if _ENDPOINT.match(line) or _HEADING.match(line):
3358
+ close()
3359
+ fields = {} if _ENDPOINT.match(line) else None
3360
+ continue
3361
+ m = _BULLET_FIELD.match(line) if fields is not None else None
3362
+ if m:
3363
+ fields[m.group(1).strip().rstrip(":").strip().lower()] = m.group(2).strip()
3364
+ close()
2872
3365
  return ids
2873
3366
 
2874
3367
 
@@ -2876,7 +3369,9 @@ def cmd_contract_requests(args):
2876
3369
  repo = args.repo
2877
3370
  fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
2878
3371
  fe_plan = _first_file(fd, FRONTEND_PLAN_NAMES) if args.feature_dir else ""
2879
- be_plan = _first_file(fd, BACKEND_PLAN_NAMES) if args.feature_dir else ""
3372
+ # The backend answers in the API plan — in its backend plan, for a feature planned
3373
+ # before the API plan existed.
3374
+ be_plan = api_source(fd)[0] if args.feature_dir else ""
2880
3375
  fe_text, be_text = _read(fe_plan), _read(be_plan)
2881
3376
  reqs = frontend_requests(fe_text) if fe_plan else []
2882
3377
  answers = backend_answers(be_text) if be_plan else {}
@@ -2891,7 +3386,7 @@ def cmd_contract_requests(args):
2891
3386
  ("needed_by", "sends", "receives", "serves") if r[k])
2892
3387
  ask = "%s — proposed `%s`%s" % (r["id"], r["proposed"] or "—", (" (%s)" % need) if need else "")
2893
3388
  if not be_plan or not a.get("outcome"):
2894
- r.update(status="open", answer="", why="" if be_plan else "no backend plan yet")
3389
+ r.update(status="open", answer="", why="" if be_plan else "no API plan yet")
2895
3390
  elif a["outcome"] == "declined":
2896
3391
  r.update(status="declined", answer="", why=a["why"])
2897
3392
  else:
@@ -2900,7 +3395,7 @@ def cmd_contract_requests(args):
2900
3395
  r.update(status="published", answer=served, why=a["why"])
2901
3396
  else:
2902
3397
  r.update(status="open", answer="",
2903
- why="answered as `%s`, but the backend plan has no endpoint with that id"
3398
+ why="answered as `%s`, but the API plan has no endpoint with that id"
2904
3399
  % (served or "—"))
2905
3400
  r["rebind"] = r["status"] == "published" and r["answer"] not in bound
2906
3401
  if r["status"] == "open":
@@ -2908,7 +3403,7 @@ def cmd_contract_requests(args):
2908
3403
  be_lines.append(ask + ((" — " + r["why"]) if r["why"] else ""))
2909
3404
  elif r["status"] == "declined":
2910
3405
  declined.append(r["id"])
2911
- fe_lines.append("%s was DECLINED by the backend plan (%s) — plan the experience "
3406
+ fe_lines.append("%s was DECLINED by the API plan (%s) — plan the experience "
2912
3407
  "without it, or route the need to /appweaver specify"
2913
3408
  % (r["id"], r["why"] or "no reason given"))
2914
3409
  else:
@@ -2925,6 +3420,9 @@ def cmd_contract_requests(args):
2925
3420
  "ok": True,
2926
3421
  "frontend_plan": os.path.relpath(fe_plan, repo).replace("\\", "/") if fe_plan else "",
2927
3422
  "backend_plan": os.path.relpath(be_plan, repo).replace("\\", "/") if be_plan else "",
3423
+ # Which document `backend_plan` is: the API plan, or a backend plan that still
3424
+ # carries its API section.
3425
+ "answers_in_api_plan": os.path.basename(be_plan) == API_PLAN_NAME if be_plan else False,
2928
3426
  "requests": reqs,
2929
3427
  "count": len(reqs),
2930
3428
  "open": open_, "open_count": len(open_), "open_csv": ", ".join(open_),
@@ -2940,7 +3438,7 @@ def cmd_contract_requests(args):
2940
3438
 
2941
3439
 
2942
3440
  # ─────────────────────────── contract-check ────────────────────────────────
2943
- # Do the operation ids a frontend plan cites exist in the exported document? Read by
3441
+ # Do the operation ids a frontend plan cites exist in the combined document? Read by
2944
3442
  # script, so the answer does not depend on a model reading two documents carefully.
2945
3443
  #
2946
3444
  # The ids are read from two table shapes, the two plan templates' own:
@@ -3010,8 +3508,9 @@ def plan_operation_ids(text):
3010
3508
 
3011
3509
  # ─────────────────────────── contract-impact ───────────────────────────────
3012
3510
  # Does a change's SERVICE delta change or remove an operation the frontend binds to, with no
3013
- # matching change on the frontend side? The service delta's "Contract deltas for the
3014
- # frontend lane" table names each operation it adds, changes or removes; the frontend master
3511
+ # matching change on the frontend side? The change's "Contract deltas for the frontend lane"
3512
+ # table — in its delta API plan, or in the delta backend plan of a change planned before
3513
+ # that existed — names each operation it adds, changes or removes; the frontend master
3015
3514
  # plan's binding rows name the ids it calls. A changed or removed id the master binds to is
3016
3515
  # AFFECTED, and it is ADDRESSED when the change's frontend delta plan names it anywhere. What
3017
3516
  # is left is reported as a warning, never a refusal: the frontend half may be planned later
@@ -3040,7 +3539,9 @@ def cmd_contract_impact(args):
3040
3539
  absolute = lambda p: (p if os.path.isabs(p) else os.path.join(repo, p)) if p else ""
3041
3540
  be_delta, fe_plan, fe_delta = (absolute(args.delta_backend), absolute(args.frontend_plan),
3042
3541
  absolute(args.delta_frontend))
3043
- changes = service_delta_changes(_read(be_delta)) if be_delta else []
3542
+ api_delta = os.path.join(os.path.dirname(be_delta), DELTA_API_PLAN_NAME) if be_delta else ""
3543
+ table_from = api_delta if api_delta and os.path.isfile(api_delta) else be_delta
3544
+ changes = service_delta_changes(_read(table_from)) if table_from else []
3044
3545
  bound = plan_operation_ids(_read(fe_plan))[0] if fe_plan and os.path.isfile(fe_plan) else []
3045
3546
  fe_delta_exists = bool(fe_delta) and os.path.isfile(fe_delta)
3046
3547
  fe_delta_text = _strip_comments(_read(fe_delta)) if fe_delta_exists else ""
@@ -3065,12 +3566,13 @@ def cmd_contract_impact(args):
3065
3566
  def cmd_contract_check(args):
3066
3567
  repo = args.repo
3067
3568
  plan_abs = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
3068
- doc_rel = args.doc or contract_doc(repo)
3569
+ doc_rel = args.doc or COMBINED_DOC
3069
3570
  doc_abs = (doc_rel if os.path.isabs(doc_rel) else os.path.join(repo, doc_rel)) if doc_rel else ""
3070
3571
  result = {"ok": False, "plan": args.plan, "doc": doc_rel,
3071
3572
  "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
3072
3573
  "cited": 0, "ids": [], "missing": [], "uncited": 0,
3073
- "drafted": [], "drafted_csv": "",
3574
+ "removed": [], "removed_csv": "", "unserved": [], "unserved_csv": "",
3575
+ "unplanned": [], "unplanned_csv": "",
3074
3576
  "doc_operations": 0, "doc_unnamed": 0, "reason": ""}
3075
3577
  if not os.path.isfile(plan_abs):
3076
3578
  result["reason"] = "the frontend plan %s is not on disk" % args.plan
@@ -3079,34 +3581,42 @@ def cmd_contract_check(args):
3079
3581
  cited, uncited = plan_operation_ids(fh.read())
3080
3582
  result.update(cited=len(cited), ids=cited, uncited=uncited)
3081
3583
  if not result["doc_exists"]:
3082
- result["reason"] = "no exported API document at %s" % (doc_rel or "(no service lane)")
3584
+ result["reason"] = "no API document at %s" % (doc_rel or "(no service lane)")
3083
3585
  return emit(result)
3084
3586
  with open(doc_abs, "rb") as fh:
3085
3587
  doc = _api_document(fh.read())
3086
3588
  if doc is None:
3087
3589
  result["reason"] = "%s is not an OpenAPI or Swagger JSON document" % doc_rel
3088
3590
  return emit(result)
3089
- ops = _operations(doc)
3090
- known = {o["id"] for o in ops if o["id"]}
3091
- lower = {k.lower(): k for k in known}
3591
+ ops = _doc_ops(doc)
3592
+ by_id = {str(op.get("operationId")): op for _p, _m, op in ops if op.get("operationId")}
3593
+ lower = {k.lower(): k for k in by_id}
3594
+ # An operation an open change removes and another operation replaces on its route is
3595
+ # out of the paths, in the combined document's x-aw-removed list.
3596
+ retired = {str(r["id"]): str(r.get("cr") or "") for r in doc.get("x-aw-removed") or []
3597
+ if isinstance(r, dict) and r.get("id")}
3092
3598
  missing = []
3093
3599
  for x in cited:
3094
- if x not in known:
3600
+ if x not in by_id and x not in retired:
3095
3601
  near = lower.get(x.lower())
3096
3602
  missing.append(x + (" (the document has %s)" % near if near else ""))
3097
- # A cited id the document has only as a draft operation binds, and fails at run time
3098
- # until the service serves it.
3099
- drafted = [x for x in cited if x in _draft_ids(doc)]
3603
+ # What the combined document says about a cited operation that IS there: an open
3604
+ # change removes it, the service does not serve it yet, or the service serves it and
3605
+ # no plan describes it.
3606
+ removed = ["%s (%s)" % (x, by_id[x]["x-aw-removed-by"]) for x in cited
3607
+ if x in by_id and by_id[x].get("x-aw-removed-by")]
3608
+ removed += ["%s (%s)" % (x, retired[x]) for x in cited if x not in by_id and x in retired]
3609
+ unserved = [x for x in cited if x in by_id and by_id[x].get("x-aw-unserved")]
3610
+ unplanned = [x for x in cited if x in by_id and by_id[x].get("x-aw-unplanned")]
3100
3611
  result.update(missing=missing, doc_operations=len(ops),
3101
- doc_unnamed=sum(1 for o in ops if not o["id"]),
3102
- drafted=drafted, drafted_csv=", ".join(drafted))
3612
+ doc_unnamed=sum(1 for _p, _m, op in ops if not op.get("operationId")),
3613
+ removed=removed, removed_csv=", ".join(removed),
3614
+ unserved=unserved, unserved_csv=", ".join(unserved),
3615
+ unplanned=unplanned, unplanned_csv=", ".join(unplanned))
3103
3616
  parts = []
3104
3617
  if missing:
3105
3618
  parts.append("%d cited operation id(s) are not in %s: %s"
3106
3619
  % (len(missing), doc_rel, ", ".join(missing)))
3107
- if drafted:
3108
- parts.append("%d cited operation id(s) are drafts the service does not serve yet: %s"
3109
- % (len(drafted), ", ".join(drafted)))
3110
3620
  if not cited:
3111
3621
  parts.append("the plan cites no operation id")
3112
3622
  if uncited:
@@ -3116,6 +3626,827 @@ def cmd_contract_check(args):
3116
3626
  emit(result)
3117
3627
 
3118
3628
 
3629
+ # ─────────────────────────── contract-verify ───────────────────────────────
3630
+ # Does the built service serve what its plan says? The as-built document (contract-export)
3631
+ # against the operations THIS feature's plan names — never the whole service's, since a
3632
+ # feature is gated on its own surface. A planned operation the service does not serve, or
3633
+ # serves under another route, verb or id, is a finding that fails the gate: the frontend is
3634
+ # bound to the plan and its calls would fail. A field or status code that differs is
3635
+ # reported and never fails — the plan writes conceptual types, and a nullable or a casing
3636
+ # convention is not always a defect. So is an operation the service serves that no plan in
3637
+ # the repository describes, which a brownfield service has many of.
3638
+ #
3639
+ # Two modes. MASTER (analyze-slice): the feature's draft, with every change applied that
3640
+ # is merged and still open — on the change path this gate runs after finalize-delta and
3641
+ # before close-delta, when the change is in spec.md and not yet in the re-derived plan.
3642
+ # CHANGE (analyze-slice-delta, --cr): that change's draft alone — what it adds or changes
3643
+ # must be served as drafted, and what it removes must no longer be served.
3644
+ # A removed operation is still served when the service serves its id, or answers its route
3645
+ # while the plan keeps no other operation on it — a route the plan keeps an operation on is
3646
+ # that operation's, and the planned check reads what the service answers there.
3647
+ # `--check` reads no as-built document: it reports whether the plan names anything to check
3648
+ # (`needed`), so the gates write that document only for a feature or change that does.
3649
+
3650
+ def _crstate():
3651
+ """aw-crstate.py, loaded by path like _testlog — the one definition of "merged"."""
3652
+ try:
3653
+ import importlib.util
3654
+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "aw-crstate.py")
3655
+ spec = importlib.util.spec_from_file_location("aw_crstate", path)
3656
+ mod = importlib.util.module_from_spec(spec)
3657
+ spec.loader.exec_module(mod)
3658
+ return mod
3659
+ except Exception:
3660
+ return None
3661
+
3662
+
3663
+ def _resolve(doc, sch, depth=0):
3664
+ """A schema with local $ref and allOf followed — enough to read its field names."""
3665
+ if not isinstance(sch, dict) or depth > 8:
3666
+ return sch if isinstance(sch, dict) else {}
3667
+ ref = sch.get("$ref")
3668
+ if isinstance(ref, str) and ref.startswith("#/"):
3669
+ node = doc
3670
+ for part in ref[2:].split("/"):
3671
+ node = node.get(part.replace("~1", "/").replace("~0", "~"), {}) \
3672
+ if isinstance(node, dict) else {}
3673
+ return _resolve(doc, node, depth + 1)
3674
+ if sch.get("allOf"):
3675
+ merged = {"type": "object", "properties": {}}
3676
+ for part in sch["allOf"]:
3677
+ merged["properties"].update(_resolve(doc, part, depth + 1).get("properties") or {})
3678
+ return merged
3679
+ return sch
3680
+
3681
+
3682
+ def _field_names(doc, sch):
3683
+ """The top-level field names of a body, or None when its schema names none."""
3684
+ r = _resolve(doc, sch or {})
3685
+ if r.get("type") == "array" or "items" in r:
3686
+ r = _resolve(doc, r.get("items") or {})
3687
+ props = r.get("properties")
3688
+ return set(props) if isinstance(props, dict) and props else None
3689
+
3690
+
3691
+ def _request_schema(op):
3692
+ body = (((op.get("requestBody") or {}).get("content") or {})
3693
+ .get("application/json") or {}).get("schema")
3694
+ if body is None:
3695
+ for prm in op.get("parameters") or []: # Swagger 2 keeps the body here
3696
+ if isinstance(prm, dict) and prm.get("in") == "body":
3697
+ return prm.get("schema")
3698
+ return body
3699
+
3700
+
3701
+ def _success(op):
3702
+ """(status, schema) of an operation's first 2xx response, and every 2xx it declares."""
3703
+ codes = sorted(str(c) for c in (op.get("responses") or {}) if str(c).startswith("2"))
3704
+ for code in codes:
3705
+ r = op["responses"].get(code) or op["responses"].get(int(code)) or {}
3706
+ if isinstance(r, dict):
3707
+ sch = (((r.get("content") or {}).get("application/json") or {}).get("schema")
3708
+ or r.get("schema"))
3709
+ return code, sch, codes
3710
+ return "", None, codes
3711
+
3712
+
3713
+ def _differences(plan_doc, planned, served_doc, served):
3714
+ """What a served operation states differently from its planned one, in words."""
3715
+ notes = []
3716
+ pa = _field_names(plan_doc, _request_schema(planned))
3717
+ pb = _field_names(served_doc, _request_schema(served))
3718
+ if pa is not None and pb is not None and pa != pb:
3719
+ notes.append("request fields — the plan has %s, the service %s"
3720
+ % (", ".join(sorted(pa)) or "none", ", ".join(sorted(pb)) or "none"))
3721
+ code_a, sch_a, _codes_a = _success(planned)
3722
+ code_b, sch_b, codes_b = _success(served)
3723
+ if code_a and codes_b and code_a not in codes_b:
3724
+ notes.append("success status — the plan says %s, the service answers %s"
3725
+ % (code_a, ", ".join(codes_b)))
3726
+ pa, pb = _field_names(plan_doc, sch_a), _field_names(served_doc, sch_b)
3727
+ if pa is not None and pb is not None and pa != pb:
3728
+ notes.append("response fields — the plan has %s, the service %s"
3729
+ % (", ".join(sorted(pa)) or "none", ", ".join(sorted(pb)) or "none"))
3730
+ return notes
3731
+
3732
+
3733
+ def _verify_plan(fd, cr, rel):
3734
+ """(plan, planned, removed, note) — what contract-verify checks: with `cr` that change's
3735
+ draft alone, else the feature's draft with every change applied that is merged and still
3736
+ open. `planned` is [(path, method, op)] the service must serve, `removed` what it must no
3737
+ longer serve; `note` says why there is nothing to check, and is "" when there is."""
3738
+ plan = _Planned()
3739
+ if cr:
3740
+ f = os.path.join(fd, "changes", cr, DRAFT_NAME)
3741
+ doc = _load_doc(f)
3742
+ if doc is None:
3743
+ return plan, [], [], "change %s drafts no API change — nothing to check" % cr
3744
+ plan.apply_change(doc, rel(f), cr)
3745
+ removed = [dict(r, cr=cr) for r in doc.get("x-aw-removed") or []]
3746
+ else:
3747
+ f = os.path.join(fd, DRAFT_NAME)
3748
+ doc = _load_doc(f)
3749
+ if doc is not None:
3750
+ plan.add_draft(doc, rel(f))
3751
+ crs, spec_text = _crstate(), _read(os.path.join(fd, "spec.md"))
3752
+ removed = []
3753
+ for c in sorted(glob.glob(os.path.join(fd, "changes", "*", DRAFT_NAME))):
3754
+ cid = os.path.basename(os.path.dirname(c))
3755
+ change = _load_doc(c)
3756
+ if cid == "archive" or change is None or not crs or not crs.merged_row(spec_text, cid):
3757
+ continue
3758
+ plan.apply_change(change, rel(c), cid)
3759
+ removed += [dict(r, cr=cid) for r in change.get("x-aw-removed") or []]
3760
+ planned = [(p, m, op) for p, item in plan.paths.items() for m, op in item.items()
3761
+ if not op.get("x-aw-removed-by")]
3762
+ note = "" if planned or removed else \
3763
+ "the plan names no endpoint for this feature — there is nothing to check"
3764
+ return plan, planned, removed, note
3765
+
3766
+
3767
+ def cmd_contract_verify(args):
3768
+ repo = args.repo
3769
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
3770
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
3771
+ doc_rel = args.doc or contract_doc(repo)
3772
+ doc_abs = (doc_rel if os.path.isabs(doc_rel) else os.path.join(repo, doc_rel)) if doc_rel else ""
3773
+ cr = (args.cr or "").strip()
3774
+ result = {"ok": True, "checked": False, "mode": "change" if cr else "master", "cr": cr,
3775
+ "doc": doc_rel, "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
3776
+ "planned": 0, "served": 0, "changes_applied": [],
3777
+ "missing": [], "moved": [], "renamed": [], "still_served": [],
3778
+ "fields": [], "unplanned": [], "unplanned_count": 0,
3779
+ "findings": [], "findings_count": 0, "issues_text": "none",
3780
+ "warning": "", "note": "", "reason": ""}
3781
+
3782
+ def finish(**kw):
3783
+ result.update(kw)
3784
+ result["findings_count"] = len(result["findings"])
3785
+ result["ok"] = not result["findings"]
3786
+ result["issues_text"] = "; ".join(
3787
+ "%s %s — %s" % (f["ref"], f["where"], f["gap"]) for f in result["findings"]) or "none"
3788
+ emit(result)
3789
+
3790
+ if not args.feature_dir:
3791
+ if args.check:
3792
+ return emit({"ok": True, "check": True, "mode": result["mode"], "cr": cr,
3793
+ "planned": 0, "removed": 0, "changes_applied": [], "needed": False,
3794
+ "note": "no feature named — the API was not checked"})
3795
+ return finish(note="no feature named — the API was not checked")
3796
+ plan, planned, removed, note = _verify_plan(fd, cr, rel)
3797
+ result["changes_applied"] = plan.changes
3798
+ result["planned"] = len(planned)
3799
+ if args.check:
3800
+ # Read-only, and no as-built document is read: is there anything to check? Asked
3801
+ # before the document is written, so a feature or change that plans no endpoint
3802
+ # never runs the lane's export.
3803
+ return emit({"ok": True, "check": True, "mode": result["mode"], "cr": cr,
3804
+ "planned": len(planned), "removed": len(removed),
3805
+ "changes_applied": plan.changes, "needed": not note, "note": note})
3806
+ if note:
3807
+ return finish(note=note)
3808
+ if not result["doc_exists"]:
3809
+ return finish(note="no as-built API document at %s — the API was not checked"
3810
+ % (doc_rel or "(no service lane)"))
3811
+ with open(doc_abs, "rb") as fh:
3812
+ served_doc = _api_document(fh.read())
3813
+ if served_doc is None:
3814
+ return finish(note="%s is not an OpenAPI or Swagger JSON document — the API was not "
3815
+ "checked" % doc_rel)
3816
+ served_ops = _doc_ops(served_doc)
3817
+ by_id = {str(op.get("operationId")): (p, m, op) for p, m, op in served_ops if op.get("operationId")}
3818
+ by_route = {_route_key(p, m): (p, m, op) for p, m, op in served_ops}
3819
+ result.update(checked=True, served=len(served_ops))
3820
+ findings, fields = [], []
3821
+ plan_doc = {"paths": plan.paths}
3822
+
3823
+ def finding(kind, oid, where, gap):
3824
+ result[kind].append("%s (%s)" % (oid or where, where))
3825
+ findings.append({"severity": "high", "check": "api", "lane": "backend",
3826
+ "ref": oid or where, "where": where, "gap": gap, "fix": "implement"})
3827
+
3828
+ for path, method, op in planned:
3829
+ oid = str(op.get("operationId") or "")
3830
+ where = "%s %s" % (method.upper(), path)
3831
+ hit = by_id.get(oid) if oid else None
3832
+ if hit is not None:
3833
+ if _route_key(hit[0], hit[1]) != _route_key(path, method):
3834
+ finding("moved", oid, where, "the service serves %s at %s %s — the plan says %s"
3835
+ % (oid, hit[1].upper(), hit[0], where))
3836
+ continue
3837
+ else:
3838
+ hit = by_route.get(_route_key(path, method))
3839
+ if hit is not None and oid and hit[2].get("operationId"):
3840
+ finding("renamed", oid, where, "the service serves %s as %s — the plan names it "
3841
+ "%s, the id the frontend binds by" % (where, hit[2]["operationId"], oid))
3842
+ continue
3843
+ if hit is None:
3844
+ finding("missing", oid, where, "the service does not serve it — a planned "
3845
+ "endpoint the frontend is bound to")
3846
+ continue
3847
+ for n in _differences(plan_doc, op, served_doc, hit[2]):
3848
+ fields.append("%s (%s): %s" % (oid or where, where, n))
3849
+ kept_routes = {_route_key(p, m) for p, m, _op in planned}
3850
+ for r in removed:
3851
+ oid = str(r.get("id") or "")
3852
+ hit = by_id.get(oid)
3853
+ if hit is None and r.get("method") and r.get("path"):
3854
+ key = _route_key(r["path"], r["method"])
3855
+ hit = by_route.get(key)
3856
+ # A route the plan keeps an operation on is that operation's to serve; the
3857
+ # planned check above reads what the service answers there.
3858
+ if hit is not None and key in kept_routes:
3859
+ hit = None
3860
+ if hit is not None:
3861
+ finding("still_served", oid, "%s %s" % (hit[1].upper(), hit[0]),
3862
+ "change %s removes it, and the service still serves it" % r["cr"])
3863
+ unplanned = []
3864
+ if not cr:
3865
+ everywhere, _d, _c = planned_operations(repo, rel)
3866
+ for p, m, op in served_ops:
3867
+ oid = str(op.get("operationId") or "")
3868
+ if (oid and oid in everywhere.by_id) or _route_key(p, m) in everywhere.routes:
3869
+ continue
3870
+ unplanned.append(oid or "%s %s" % (m.upper(), p))
3871
+ warn = []
3872
+ if fields:
3873
+ # The document the draft states them from: the API plan (or a backend plan, for a
3874
+ # feature planned before it), or with --cr the change's delta API plan.
3875
+ src = (delta_api_source(os.path.join(fd, "changes", cr)) if cr else api_source(fd))[0]
3876
+ warn.append("the service serves %d planned operation(s) of this feature with fields or "
3877
+ "a status code other than %s states: %s"
3878
+ % (len(set(x.split(" (")[0] for x in fields)), rel(src) or "the plan",
3879
+ "; ".join(fields[:12])))
3880
+ if unplanned:
3881
+ warn.append("%d operation(s) the service serves are described by no plan: %s%s"
3882
+ % (len(unplanned), ", ".join(unplanned[:10]),
3883
+ " …" if len(unplanned) > 10 else ""))
3884
+ finish(findings=findings, fields=fields, unplanned=unplanned[:50],
3885
+ unplanned_count=len(unplanned),
3886
+ warning=("API — " + ". ".join(warn) + ". Reported, not blocking: correct the code or "
3887
+ "the plan (/appweaver plan, then analyze-plan) where it matters.") if warn else "")
3888
+
3889
+
3890
+ # ───────────────────────── contract-merge / api-split ──────────────────────
3891
+ # THE API PLAN IS A MASTER. finalize-delta merges a change's delta API plan into it the way
3892
+ # it merges a delta-spec into spec.md, so the endpoints the change adds or changes are in the
3893
+ # document every redraw reads from the moment the change is merged, and `plan main`, which
3894
+ # re-derives the backend plan, finds them there. The merge is by script and by whole
3895
+ # endpoint block, keyed by operation id (by verb and route for a block without one):
3896
+ # changed the API plan's block is replaced by the change's
3897
+ # added the change's block goes after the last endpoint block — over one with the same
3898
+ # id, so a re-run changes nothing
3899
+ # removed the block is deleted
3900
+ # a Contract deltas row with no block a minimal block is written from the row, and
3901
+ # reported: its shapes are only as precise as that table cell. A row on a route
3902
+ # the change wrote a block for keeps that block, and is reported
3903
+ # a block with no operation id takes the id its row gives its route, else the id of the
3904
+ # block it replaces — operation ids are stable
3905
+ # An operation id is read as contract-draft reads it, from the block's bullets, so the merge
3906
+ # matches every block the draft names.
3907
+ # The requests the change answers replace or join the rows of the "Requests from the
3908
+ # frontend lane" table, and a Change History row records the merge, its Version cell
3909
+ # `(pending)` until close-delta stamps the number. Nothing else in the API plan moves.
3910
+ # `--check` writes nothing: it reports whether the change carries an API delta the API plan
3911
+ # has no Change History row for (`needed`), which close-delta asks before it archives.
3912
+ #
3913
+ # A FEATURE PLANNED BEFORE THE API PLAN EXISTED keeps its endpoints in its backend plan.
3914
+ # `api-split` moves them — every endpoint block and the requests section, wherever the plan
3915
+ # keeps them — into a new API plan, and leaves a line in the backend plan saying where they
3916
+ # went. contract-merge splits first when the feature has no API plan. Both redraw DRAFT_NAME.
3917
+
3918
+ _FENCE = re.compile(r"^\s*(```|~~~)")
3919
+ _REQUESTS_HEAD = re.compile(r"requests\s+from\s+the\s+frontend\s+lane", re.I)
3920
+ _HISTORY_HEAD = re.compile(r"change\s+history", re.I)
3921
+ _HISTORY_COLUMNS = ("Version", "Date", "Change", "User stories", "CR")
3922
+
3923
+
3924
+ def _md_heads(lines):
3925
+ """[(index, level, text)] — every heading outside a code fence."""
3926
+ out, fence = [], False
3927
+ for n, line in enumerate(lines):
3928
+ if _FENCE.match(line):
3929
+ fence = not fence
3930
+ continue
3931
+ m = None if fence else re.match(r"^(#{1,6})\s+(.*)$", line)
3932
+ if m:
3933
+ out.append((n, len(m.group(1)), m.group(2)))
3934
+ return out
3935
+
3936
+
3937
+ def _block_route(path):
3938
+ path = re.sub(r":([A-Za-z_]\w*)", r"{\1}", path.split("?")[0].rstrip(".,;"))
3939
+ return path.rstrip("/") if len(path) > 1 else path
3940
+
3941
+
3942
+ def _md_endpoints(lines):
3943
+ """[{start, end, level, method, path, oid}] — each endpoint block, from its heading to the
3944
+ next heading, the span the drafter reads it from."""
3945
+ heads = _md_heads(lines)
3946
+ out = []
3947
+ for i, (n, level, _t) in enumerate(heads):
3948
+ m = _ENDPOINT.match(lines[n])
3949
+ if not m:
3950
+ continue
3951
+ end = heads[i + 1][0] if i + 1 < len(heads) else len(lines)
3952
+ ids = backend_operation_ids("\n".join(lines[n:end]))
3953
+ out.append({"start": n, "end": end, "level": level, "method": m.group(2).lower(),
3954
+ "path": _block_route(m.group(3)), "oid": ids[0] if ids else ""})
3955
+ return out
3956
+
3957
+
3958
+ def _md_section(lines, pattern):
3959
+ """(heading index, level, end) of the first heading matching `pattern`, ending at the next
3960
+ heading of any level; None when there is none."""
3961
+ heads = _md_heads(lines)
3962
+ for i, (n, level, text) in enumerate(heads):
3963
+ if pattern.search(text):
3964
+ return n, level, heads[i + 1][0] if i + 1 < len(heads) else len(lines)
3965
+ return None
3966
+
3967
+
3968
+ def _tidy(lines):
3969
+ """No run of blank lines longer than one, and none at either end."""
3970
+ out = []
3971
+ for line in lines:
3972
+ if not line.strip() and out and not out[-1].strip():
3973
+ continue
3974
+ out.append(line.rstrip())
3975
+ while out and not out[0].strip():
3976
+ out.pop(0)
3977
+ while out and not out[-1].strip():
3978
+ out.pop()
3979
+ return out
3980
+
3981
+
3982
+ def _clean_block(lines, level):
3983
+ """A block as it is merged: authoring comments out, its heading at `level`."""
3984
+ body = _strip_comments("\n".join(lines)).splitlines()
3985
+ body = _tidy(body)
3986
+ if body:
3987
+ body[0] = re.sub(r"^#{1,6}", "#" * level, body[0], count=1)
3988
+ return body
3989
+
3990
+
3991
+ def _table_rows(lines, start, end, *required):
3992
+ """(header index, header cells, [(row index, cells)]) of the first table in lines[start:end]
3993
+ whose header has every column in `required`; None when there is none."""
3994
+ n = start
3995
+ while n + 1 < end:
3996
+ header, rule = _cells(lines[n]), _cells(lines[n + 1])
3997
+ if header is not None and rule is not None and _is_rule(rule):
3998
+ low = [h.lower() for h in header]
3999
+ rows, k = [], n + 2
4000
+ while k < end and _cells(lines[k]) is not None:
4001
+ rows.append((k, _cells(lines[k])))
4002
+ k += 1
4003
+ if all(_col(low, r) >= 0 for r in required):
4004
+ return n, low, rows
4005
+ n = k
4006
+ continue
4007
+ n += 1
4008
+ return None
4009
+
4010
+
4011
+ def _row_line(header, values):
4012
+ """A table row in `header`'s column order, from {column name: value} — a column takes the
4013
+ value named exactly as it is, else one whose name it starts with."""
4014
+ cells = []
4015
+ for h in header:
4016
+ h = h.replace("-", " ")
4017
+ named = [k for k in values if k]
4018
+ k = h if h in named else next((k for k in named if h.startswith(k)), "")
4019
+ cells.append(values.get(k, "") if k else "")
4020
+ return "| " + " | ".join(cells) + " |"
4021
+
4022
+
4023
+ def _version_of(path):
4024
+ m = re.search(r"^\*\*Version\*\*:[ \t]*(\S+)", _read(path), re.M)
4025
+ return m.group(1) if m else ""
4026
+
4027
+
4028
+ def _api_plan_skeleton(fd):
4029
+ """A new API plan, in the sections of templates/api-plan-template.md, before any endpoint
4030
+ is in it. Its Version moves in lockstep with the feature's other masters."""
4031
+ be = _first_file(fd, BACKEND_PLAN_NAMES)
4032
+ m = re.search(r"^\*\*Feature\*\*:[ \t]*(.+?)[ \t]*(?:<!--.*)?$", _read(be), re.M) if be else None
4033
+ feature = m.group(1).strip().strip("`") if m else os.path.basename(os.path.normpath(fd))
4034
+ version = (_version_of(be) if be else "") or _version_of(os.path.join(fd, "spec.md")) or "v1.0"
4035
+ return [
4036
+ "# API Plan: %s" % feature, "",
4037
+ "**Feature**: `%s`" % feature,
4038
+ "**Version**: %s" % version,
4039
+ "**Status**: Draft", "",
4040
+ "<!-- The endpoints this service publishes — the API contract the frontend lane binds "
4041
+ "to — and its answers to the frontend lane's requests, in the form of "
4042
+ ".appweaver/templates/api-plan-template.md. Drafted by script into api-draft.json; "
4043
+ "finalize-delta merges each change's delta API plan into it. -->", "",
4044
+ "## Endpoints", "",
4045
+ "## Requests from the frontend lane", "",
4046
+ "None", "",
4047
+ "## Change History", "",
4048
+ "| " + " | ".join(_HISTORY_COLUMNS) + " |",
4049
+ "|" + "|".join("-" * (len(c) + 2) for c in _HISTORY_COLUMNS) + "|",
4050
+ ]
4051
+
4052
+
4053
+ def _endpoint_insert_at(lines):
4054
+ """Where a new endpoint block goes: after the last one, else at the end of the Endpoints
4055
+ section — dropping the `None` an empty one reads — else before the requests section or
4056
+ the Change History, else at the end."""
4057
+ blocks = _md_endpoints(lines)
4058
+ if blocks:
4059
+ return blocks[-1]["end"], blocks[-1]["level"]
4060
+ heads = _md_heads(lines)
4061
+ for i, (n, level, text) in enumerate(heads):
4062
+ if re.match(r"^\s*(\d+\.\s*)?endpoints\b", text, re.I):
4063
+ nxt = next((k for k, lv, _t in heads[i + 1:] if lv <= level), len(lines))
4064
+ for k in range(nxt - 1, n, -1):
4065
+ if lines[k].strip().strip("_*").lower() == "none":
4066
+ del lines[k]
4067
+ nxt -= 1
4068
+ return nxt, level + 1
4069
+ for pattern in (_REQUESTS_HEAD, _HISTORY_HEAD):
4070
+ s = _md_section(lines, pattern)
4071
+ if s:
4072
+ return s[0], 3
4073
+ return len(lines), 3
4074
+
4075
+
4076
+ def _insert(lines, at, block):
4077
+ """Put `block` at index `at` with a blank line on either side."""
4078
+ lead = [""] if at > 0 and lines[at - 1].strip() else []
4079
+ lines[at:at] = lead + block + [""]
4080
+
4081
+
4082
+ def _upsert_requests(lines, answered):
4083
+ """Each answered request ({column: value}, keyed by RQ id) replaces the API plan's row for
4084
+ it or joins the table; returns the RQ ids written."""
4085
+ if not answered:
4086
+ return []
4087
+ sec = _md_section(lines, _REQUESTS_HEAD)
4088
+ if sec is None:
4089
+ hist = _md_section(lines, _HISTORY_HEAD)
4090
+ at = hist[0] if hist else len(lines)
4091
+ _insert(lines, at, ["## Requests from the frontend lane", "", "None"])
4092
+ sec = _md_section(lines, _REQUESTS_HEAD)
4093
+ head_n, _level, end = sec
4094
+ table = _table_rows(lines, head_n + 1, end, "request", "outcome")
4095
+ if table is None:
4096
+ # "None" under the heading is the empty ledger; the first answer replaces it.
4097
+ for n in range(end - 1, head_n, -1):
4098
+ if lines[n].strip().strip("_*").lower() == "none":
4099
+ del lines[n]
4100
+ end -= 1
4101
+ _insert(lines, end, ["| Request | Outcome | Operation id | Why |", "|---|---|---|---|"])
4102
+ table = _table_rows(lines, head_n + 1, len(lines), "request", "outcome")
4103
+ hn, header, rows = table
4104
+ rc = _col(header, "request")
4105
+
4106
+ def rq_of(cells):
4107
+ # The row's request is the first RQ id in its Request cell, as backend_answers reads it.
4108
+ m = _RQ_ID.search(cells[rc]) if rc < len(cells) else None
4109
+ return m.group(0) if m else ""
4110
+
4111
+ written = []
4112
+ for rq, values in answered.items():
4113
+ line = _row_line(header, values)
4114
+ hit = next((k for k, cells in rows if rq_of(cells) == rq), None)
4115
+ if hit is not None:
4116
+ lines[hit] = line
4117
+ else:
4118
+ last = rows[-1][0] if rows else hn + 1
4119
+ lines[last + 1:last + 1] = [line]
4120
+ rows.append((last + 1, _cells(line)))
4121
+ written.append(rq)
4122
+ return written
4123
+
4124
+
4125
+ def _history_names(lines, cr):
4126
+ """True when the Change History table in `lines` has a row naming `cr`."""
4127
+ sec = _md_section(lines, _HISTORY_HEAD) if cr else None
4128
+ table = _table_rows(lines, sec[0] + 1, sec[2], "version") if sec else None
4129
+ return bool(table and any(cr in "|".join(cells) for _k, cells in table[2]))
4130
+
4131
+
4132
+ def _history_row(lines, cr, change, version="(pending)", today=""):
4133
+ """A Change History row, unless a row already names `cr`; True when one was written."""
4134
+ sec = _md_section(lines, _HISTORY_HEAD)
4135
+ if sec is None:
4136
+ _insert(lines, len(lines), ["## Change History", "",
4137
+ "| " + " | ".join(_HISTORY_COLUMNS) + " |",
4138
+ "|" + "|".join("-" * (len(c) + 2) for c in _HISTORY_COLUMNS) + "|"])
4139
+ sec = _md_section(lines, _HISTORY_HEAD)
4140
+ head_n, _level, end = sec
4141
+ table = _table_rows(lines, head_n + 1, end, "version")
4142
+ if table is None:
4143
+ _insert(lines, end, ["| " + " | ".join(_HISTORY_COLUMNS) + " |",
4144
+ "|" + "|".join("-" * (len(c) + 2) for c in _HISTORY_COLUMNS) + "|"])
4145
+ sec = _md_section(lines, _HISTORY_HEAD)
4146
+ table = _table_rows(lines, sec[0] + 1, sec[2], "version")
4147
+ hn, header, rows = table
4148
+ if _history_names(lines, cr):
4149
+ return False
4150
+ line = _row_line(header, {"version": version, "date": today, "change": change,
4151
+ "user stories": "—", "cr": cr or "—"})
4152
+ last = rows[-1][0] if rows else hn + 1
4153
+ lines[last + 1:last + 1] = [line]
4154
+ return True
4155
+
4156
+
4157
+ def _split_api(repo, fd, rel, today):
4158
+ """Move a feature's endpoint blocks and requests section out of its backend plan into a
4159
+ new API plan. Writes nothing when the feature already has one or its plan names none."""
4160
+ api = os.path.join(fd, API_PLAN_NAME)
4161
+ be = _first_file(fd, BACKEND_PLAN_NAMES)
4162
+ out = {"ok": True, "split": False, "path": rel(api), "backend_plan": rel(be),
4163
+ "endpoints": 0, "requests_moved": False, "reason": ""}
4164
+ if os.path.isfile(api):
4165
+ out["reason"] = "%s already has an API plan" % rel(fd)
4166
+ return out
4167
+ if not be:
4168
+ out["reason"] = "no backend plan in %s — nothing to split" % rel(fd)
4169
+ return out
4170
+ lines = _read(be).splitlines()
4171
+ blocks = _md_endpoints(lines)
4172
+ req = _md_section(lines, _REQUESTS_HEAD)
4173
+ if not blocks and req is None:
4174
+ out["reason"] = "%s names no endpoint — nothing to split" % rel(be)
4175
+ return out
4176
+ plan = _api_plan_skeleton(fd)
4177
+ at, _lv = _endpoint_insert_at(plan)
4178
+ moved = []
4179
+ for b in blocks:
4180
+ moved += _clean_block(lines[b["start"]:b["end"]], 3) + [""]
4181
+ if moved:
4182
+ _insert(plan, at, _tidy(moved))
4183
+ if req is not None:
4184
+ body = _tidy(_strip_comments("\n".join(lines[req[0] + 1:req[2]])).splitlines())
4185
+ if body and not (len(body) == 1 and body[0].strip().strip("_*").lower() == "none"):
4186
+ sec = _md_section(plan, _REQUESTS_HEAD)
4187
+ plan[sec[0] + 1:sec[2]] = [""] + body + [""]
4188
+ out["requests_moved"] = True
4189
+ version = _version_of(be) or "v1.0"
4190
+ _history_row(plan, "", "Split out of %s by api-split: %d endpoint(s)%s"
4191
+ % (os.path.basename(be), len(blocks),
4192
+ " and the requests table" if out["requests_moved"] else ""), version, today)
4193
+ # The backend plan keeps a line where the API was, so a reader of it knows where it went.
4194
+ spans = sorted([(b["start"], b["end"]) for b in blocks] + ([(req[0], req[2])] if req else []))
4195
+ first = spans[0][0]
4196
+ for s, e in reversed(spans):
4197
+ del lines[s:e]
4198
+ _insert(lines, first, [
4199
+ "The endpoints this service publishes — route, verb, operation id, request, success and "
4200
+ "errors — and its answers to the frontend lane's requests are in "
4201
+ "[%s](%s), the API contract the frontend lane binds to." % (API_PLAN_NAME, API_PLAN_NAME)])
4202
+ _history_row(lines, "", "Endpoints and requests moved to %s (api-split)" % API_PLAN_NAME,
4203
+ version, today)
4204
+ with open(api, "w", encoding="utf-8") as fh:
4205
+ fh.write("\n".join(_tidy(plan)) + "\n")
4206
+ with open(be, "w", encoding="utf-8") as fh:
4207
+ fh.write("\n".join(_tidy(lines)) + "\n")
4208
+ out.update(split=True, endpoints=len(blocks))
4209
+ return out
4210
+
4211
+
4212
+ def _delta_requests(text):
4213
+ """{RQ id: {column: value}} — the requests a change answers, from its requests table."""
4214
+ out = {}
4215
+ for header, rows in _tables(text):
4216
+ rc, oc = _col(header, "request"), _col(header, "outcome")
4217
+ if rc < 0 or oc < 0:
4218
+ continue
4219
+ for row in rows:
4220
+ m = _RQ_ID.search(row[rc]) if rc < len(row) else None
4221
+ if m and not _is_placeholder(row[oc] if oc < len(row) else ""):
4222
+ out[m.group(0)] = {h.replace("-", " "): (row[n] if n < len(row) else "")
4223
+ for n, h in enumerate(header)}
4224
+ return out
4225
+
4226
+
4227
+ def _with_operation_id(block, oid):
4228
+ """An endpoint block's lines with an Operation id bullet before its first bullet."""
4229
+ at = next((n for n, line in enumerate(block) if n and _BULLET_FIELD.match(line)), len(block))
4230
+ return block[:at] + ["- **Operation id**: `%s`" % oid] + block[at:]
4231
+
4232
+
4233
+ def _table_block(row, cr, level):
4234
+ """The block a Contract deltas row stands for when the change wrote none for it."""
4235
+ parts = re.split(r"→|->", row["shape"] or "", maxsplit=1)
4236
+ lines = ["%s `%s %s` — drafted from change %s's Contract deltas table"
4237
+ % ("#" * level, row["method"].upper(), row["path"], cr), "",
4238
+ "- **Operation id**: `%s`" % row["id"]]
4239
+ if len(parts) > 1:
4240
+ lines.append("- **Request**: %s" % parts[0].strip())
4241
+ lines.append("- **Success**: `200` → %s" % parts[1].strip())
4242
+ elif parts[0].strip():
4243
+ lines.append("- **Success**: `200` → %s" % parts[0].strip())
4244
+ return lines
4245
+
4246
+
4247
+ def cmd_contract_merge(args):
4248
+ import datetime
4249
+ repo = args.repo
4250
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
4251
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
4252
+ cr = (args.cr or "").strip()
4253
+ api = os.path.join(fd, API_PLAN_NAME)
4254
+ today = datetime.date.today().isoformat()
4255
+ out = {"ok": True, "merged": False, "written": False, "split": False, "cr": cr,
4256
+ "path": rel(api), "delta": "", "added": [], "changed": [], "removed": [],
4257
+ "from_table": [], "block_kept": [], "id_added": [], "not_found": [], "requests": [],
4258
+ "history_row": False,
4259
+ "draft": "", "draft_operations": 0, "dropped_text": "none", "warning": "",
4260
+ "reason": ""}
4261
+ if not args.feature_dir or not cr:
4262
+ if args.check:
4263
+ fail("contract-merge --check: name the feature (--feature-dir) and the change (--cr)")
4264
+ out["reason"] = "name the feature (--feature-dir) and the change (--cr)"
4265
+ return emit(out)
4266
+ cr_dir = os.path.join(fd, "changes", cr)
4267
+ src, whole = delta_api_source(cr_dir)
4268
+ out["delta"] = rel(src)
4269
+ text = _strip_comments(_read(src)) if src else ""
4270
+ region = text if whole else (api_section(text) or "")
4271
+ rows = _delta_contract_rows(text)
4272
+ kinds = {r["id"]: r for r in rows}
4273
+ region_lines = region.splitlines()
4274
+ delta_blocks = [dict(b, lines=region_lines[b["start"]:b["end"]])
4275
+ for b in _md_endpoints(region_lines)]
4276
+ answered = _delta_requests(text)
4277
+ if args.check:
4278
+ # Read-only: does the change carry an API delta the API plan has not merged? Every
4279
+ # merge writes a Change History row naming the change, so that row is the record.
4280
+ has = bool(rows or delta_blocks or answered)
4281
+ done = os.path.isfile(api) and _history_names(_read(api).splitlines(), cr)
4282
+ reason = ("change %s changes no endpoint and answers no request — nothing to merge" % cr
4283
+ if not has else "%s records the merge of change %s" % (rel(api), cr) if done
4284
+ else "change %s's API delta (%s) is not merged: %s has no Change History row "
4285
+ "naming it" % (cr, rel(src), rel(api)))
4286
+ return emit({"ok": True, "check": True, "cr": cr, "path": rel(api), "delta": rel(src),
4287
+ "has_delta": has, "merged": bool(done), "needed": has and not done,
4288
+ "reason": reason})
4289
+ if not rows and not delta_blocks and not answered:
4290
+ out["reason"] = ("change %s changes no endpoint and answers no request — nothing to merge"
4291
+ % cr)
4292
+ return emit(out)
4293
+
4294
+ if not os.path.isfile(api):
4295
+ split = _split_api(repo, fd, rel, today)
4296
+ out["split"] = split["split"]
4297
+ if not split["split"]:
4298
+ with open(api, "w", encoding="utf-8") as fh:
4299
+ fh.write("\n".join(_api_plan_skeleton(fd)) + "\n")
4300
+ before = _read(api)
4301
+ lines = before.splitlines()
4302
+
4303
+ def find(oid, method, path):
4304
+ for b in _md_endpoints(lines):
4305
+ if oid and b["oid"] == oid:
4306
+ return b
4307
+ key = _route_key(_block_route(path), method) if method and path else None
4308
+ for b in _md_endpoints(lines):
4309
+ if key and _route_key(b["path"], b["method"]) == key:
4310
+ return b
4311
+ return None
4312
+
4313
+ for r in rows:
4314
+ if r["change"] != "removed":
4315
+ continue
4316
+ # One already gone is what the change asks for — a re-run, or a hand edit first.
4317
+ b = find(r["id"], r["method"], r["path"])
4318
+ if b is not None:
4319
+ del lines[b["start"]:b["end"]]
4320
+ out["removed"].append(r["id"])
4321
+
4322
+ def put(oid, method, path, block, kind):
4323
+ b = find(oid, method, path)
4324
+ if b is not None:
4325
+ lines[b["start"]:b["end"]] = _clean_block(block, b["level"]) + [""]
4326
+ else:
4327
+ at, level = _endpoint_insert_at(lines)
4328
+ _insert(lines, at, _clean_block(block, level))
4329
+ if kind == "changed":
4330
+ out["not_found"].append("%s (changed, not in the API plan — added)"
4331
+ % (oid or "%s %s" % (method.upper(), path)))
4332
+ out["changed" if kind == "changed" else "added"].append(
4333
+ oid or "%s %s" % (method.upper(), path))
4334
+
4335
+ row_at = {_route_key(_block_route(r["path"]), r["method"]): r for r in rows
4336
+ if r["change"] != "removed" and r["id"] and r["method"] and r["path"]}
4337
+ written_ids, written_routes = set(), set()
4338
+ for b in delta_blocks:
4339
+ oid, block, key = b["oid"], b["lines"], _route_key(b["path"], b["method"])
4340
+ if not oid:
4341
+ # Operation ids are stable: a block that names none takes the id its row gives
4342
+ # its route, else the id of the block it replaces.
4343
+ held = find("", b["method"], b["path"])
4344
+ oid = row_at[key]["id"] if key in row_at else held["oid"] if held else ""
4345
+ if oid:
4346
+ block = _with_operation_id(block, oid)
4347
+ out["id_added"].append("%s (%s %s)" % (oid, b["method"].upper(), b["path"]))
4348
+ row = kinds.get(oid)
4349
+ if row and row["change"] == "removed":
4350
+ continue
4351
+ kind = row["change"] if row else ("changed" if find(oid, b["method"], b["path"])
4352
+ else "added")
4353
+ put(oid, b["method"], b["path"], block, kind)
4354
+ written_ids.add(oid)
4355
+ written_routes.add(key)
4356
+ for r in rows:
4357
+ if r["change"] == "removed" or r["id"] in written_ids or not r["method"] or not r["path"]:
4358
+ continue
4359
+ # A block this merge wrote on the row's route is the precise one; the row never
4360
+ # overwrites it.
4361
+ if _route_key(_block_route(r["path"]), r["method"]) in written_routes:
4362
+ out["block_kept"].append("%s (%s %s)" % (r["id"], r["method"].upper(), r["path"]))
4363
+ continue
4364
+ put(r["id"], r["method"], r["path"], _table_block(r, cr, 3), r["change"])
4365
+ out["from_table"].append(r["id"])
4366
+ out["requests"] = _upsert_requests(lines, answered)
4367
+
4368
+ bits = ["%s: %s" % (k, ", ".join(out[k])) for k in ("added", "changed", "removed") if out[k]]
4369
+ if out["requests"]:
4370
+ bits.append("requests answered: %s" % ", ".join(out["requests"]))
4371
+ out["history_row"] = _history_row(lines, cr, "API of %s merged by finalize-delta — %s"
4372
+ % (cr, "; ".join(bits) or "no endpoint"), "(pending)", today)
4373
+ after = "\n".join(_tidy(lines)) + "\n"
4374
+ if after != before:
4375
+ with open(api, "w", encoding="utf-8") as fh:
4376
+ fh.write(after)
4377
+ out["written"] = True
4378
+ out["merged"] = True
4379
+ # What the redraw dropped beyond what the change removed on purpose.
4380
+ draft = _draft_master(repo, fd, rel)
4381
+ dropped = [d for d in draft["dropped"] if d.split(" (")[0] not in out["removed"]]
4382
+ out.update(draft=draft["path"], draft_operations=draft["operations"],
4383
+ dropped_text=", ".join(dropped) or "none")
4384
+ warn = []
4385
+ if out["from_table"]:
4386
+ warn.append("%d endpoint(s) had no block in %s and were written from their Contract "
4387
+ "deltas row, so their shapes are only as precise as that cell: %s"
4388
+ % (len(out["from_table"]), rel(src), ", ".join(out["from_table"])))
4389
+ if out["block_kept"]:
4390
+ warn.append("%d Contract deltas row(s) name a route %s also writes a block for, under "
4391
+ "another operation id, so the block was kept: %s"
4392
+ % (len(out["block_kept"]), rel(src), ", ".join(out["block_kept"])))
4393
+ if out["not_found"]:
4394
+ warn.append("%s did not have what the change changes: %s"
4395
+ % (rel(api), ", ".join(out["not_found"])))
4396
+ if warn:
4397
+ out["warning"] = ("API MERGE — %s. Complete them in %s with /appweaver plan main."
4398
+ % ("; ".join(warn), rel(api)))
4399
+ emit(out)
4400
+
4401
+
4402
+ def _api_still_in_plan(fd, cr=""):
4403
+ """{needed, endpoints, path, plan} — whether the backend plan (or, with `cr`, the change's
4404
+ delta backend plan) still carries the API while no API plan beside it does. A document
4405
+ scaffolded beside it from the empty template would be read in its place."""
4406
+ if cr:
4407
+ cr_dir = os.path.join(fd, "changes", cr)
4408
+ target = os.path.join(cr_dir, DELTA_API_PLAN_NAME)
4409
+ plan = _first_file(cr_dir, DELTA_PLAN_NAMES["backend"])
4410
+ text = _strip_comments(_read(plan)) if plan else ""
4411
+ endpoints = len(_md_endpoints((api_section(text) or "").splitlines()))
4412
+ carries = bool(endpoints or _delta_contract_rows(text))
4413
+ else:
4414
+ target = os.path.join(fd, API_PLAN_NAME)
4415
+ plan = _first_file(fd, BACKEND_PLAN_NAMES)
4416
+ lines = _read(plan).splitlines() if plan else []
4417
+ endpoints = len(_md_endpoints(lines))
4418
+ carries = bool(endpoints or _md_section(lines, _REQUESTS_HEAD))
4419
+ return {"needed": carries and not os.path.isfile(target), "endpoints": endpoints,
4420
+ "path": target, "plan": plan}
4421
+
4422
+
4423
+ def cmd_api_split(args):
4424
+ import datetime
4425
+ repo = args.repo
4426
+ rel = lambda p: os.path.relpath(p, repo).replace("\\", "/") if p else ""
4427
+ fd = args.feature_dir if os.path.isabs(args.feature_dir) else os.path.join(repo, args.feature_dir)
4428
+ if not args.feature_dir:
4429
+ return emit({"ok": True, "split": False, "needed": False, "path": "", "backend_plan": "",
4430
+ "endpoints": 0, "requests_moved": False, "draft_operations": 0,
4431
+ "dropped_text": "none", "reason": "no feature named — nothing to split"})
4432
+ if args.check or args.cr:
4433
+ # Read-only: is there an API still to move? A change's API is never split — the
4434
+ # change is planned again into its delta API plan — so --cr only ever checks.
4435
+ s = _api_still_in_plan(fd, (args.cr or "").strip())
4436
+ return emit({"ok": True, "split": False, "needed": s["needed"], "path": rel(s["path"]),
4437
+ "backend_plan": rel(s["plan"]), "endpoints": s["endpoints"],
4438
+ "requests_moved": False, "draft_operations": 0, "dropped_text": "none",
4439
+ "reason": ("%s still carries the API, and %s does not exist yet"
4440
+ % (rel(s["plan"]), rel(s["path"]))) if s["needed"] else ""})
4441
+ out = _split_api(repo, fd, rel, datetime.date.today().isoformat())
4442
+ out["needed"] = False
4443
+ out.update(draft_operations=0, dropped_text="none")
4444
+ if out["split"]:
4445
+ draft = _draft_master(repo, fd, rel)
4446
+ out.update(draft_operations=draft["operations"], dropped_text=draft["dropped_text"])
4447
+ emit(out)
4448
+
4449
+
3119
4450
  # ──────────────────────────────── probe ────────────────────────────────────
3120
4451
 
3121
4452
  def profile_terms(prof):
@@ -3201,6 +4532,159 @@ def cmd_probe(args):
3201
4532
  })
3202
4533
 
3203
4534
 
4535
+ # ─────────────────────────────── rule coverage ─────────────────────────────
4536
+ # WHICH STACKS HAVE A TECHNICAL RULE SET, measured where it matters: against the active
4537
+ # project's tech-rule-bindings.md. A map names a stack's rules with a placeholder in the
4538
+ # folder segment of the path — guardrails/technology/{{backend_stack}}/… — so a stack has
4539
+ # a rule set exactly as far as the files the map names for it exist. A folder holding
4540
+ # some of them is partial, not complete; a map that names no stack folder for a lane
4541
+ # makes the stack choice irrelevant to that lane's rules, which reads `not-mapped`.
4542
+ #
4543
+ # A stack with no rule set is a supported setup (STACK-PROFILES.md §7): the lane runs on
4544
+ # ai-rules.md and the technical constitution. The report is for saying so where the
4545
+ # stack is chosen, rather than leaving every later phase's warning to reveal it.
4546
+ #
4547
+ # THE PLACEHOLDER TABLE IS resolve-tech's: {{stack}} is the service lane's, the other
4548
+ # lane's placeholders resolve from stacks.json, and a bullet that needs a lane nobody
4549
+ # declared is skipped, as resolve-tech skips it. The bullets read are the ones
4550
+ # resolve-tech reads: `- ` lines under a `## /<command>` heading.
4551
+
4552
+ CORPUS_REL = "guardrails/technology/"
4553
+ RULE_HOLE = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
4554
+ CORPUS_HOLES = {"backend": ("backend_stack", "stack"), "frontend": ("frontend_stack",)}
4555
+ LANE_WORDS = {"backend": "service", "frontend": "UI"}
4556
+
4557
+
4558
+ def active_project(repo):
4559
+ try:
4560
+ with open(os.path.join(repo, ".appweaver", "domain.json"), encoding="utf-8") as fh:
4561
+ return (json.load(fh).get("domain") or "").strip()
4562
+ except (ValueError, OSError, AttributeError):
4563
+ return ""
4564
+
4565
+
4566
+ def map_bullets(path):
4567
+ """Every `- ` bullet under a `## /<command>` heading of a rule map. A path written as
4568
+ code (- `path`) is read without its backticks."""
4569
+ out, grab = [], False
4570
+ try:
4571
+ with open(path, encoding="utf-8") as fh:
4572
+ lines = fh.read().splitlines()
4573
+ except OSError:
4574
+ return out
4575
+ for line in lines:
4576
+ if line.startswith("## "):
4577
+ grab = line.strip().startswith("## /")
4578
+ continue
4579
+ m = re.match(r"^\s*-\s+(.*?)\s*$", line) if grab else None
4580
+ if m:
4581
+ out.append(re.sub(r"^`([^`]+)`$", r"\1", m.group(1)))
4582
+ return out
4583
+
4584
+
4585
+ def stack_rule_files(repo, bullets, kind, prof, lanes):
4586
+ """The files the map names in `prof`'s rule folder, resolved as they would be with
4587
+ `prof` declared for `kind` and the other lane as stacks.json has it."""
4588
+ other = "frontend" if kind == "backend" else "backend"
4589
+ ob = kind_block(repo, lanes, other)
4590
+ subs = {kind + "_stack": prof.get("id", ""), kind + "_ecosystem": prof.get("ecosystem", ""),
4591
+ other + "_stack": ob[other + "_profile"], other + "_ecosystem": ob[other + "_ecosystem"]}
4592
+ subs["stack"] = subs["backend_stack"]
4593
+ folder = re.compile(r"^%s\{\{\s*(?:%s)\s*\}\}/" % (re.escape(CORPUS_REL), "|".join(CORPUS_HOLES[kind])))
4594
+ files = []
4595
+ for b in bullets:
4596
+ if not folder.match(b):
4597
+ continue
4598
+ if any(not subs.get(h) for h in RULE_HOLE.findall(b)):
4599
+ continue
4600
+ path = RULE_HOLE.sub(lambda m: subs[m.group(1)], b)
4601
+ if path not in files:
4602
+ files.append(path)
4603
+ return files
4604
+
4605
+
4606
+ def rule_coverage(repo, kind, prof, bullets, lanes):
4607
+ files = stack_rule_files(repo, bullets, kind, prof, lanes)
4608
+ present = [f for f in files if os.path.isfile(os.path.join(repo, f))]
4609
+ missing = [f for f in files if f not in present]
4610
+ folder = CORPUS_REL + prof.get("id", "") + "/"
4611
+ verdict = ("not-mapped" if not files else "complete" if not missing
4612
+ else "partial" if present else "none")
4613
+ return {"id": prof.get("id", ""), "title": prof.get("title", "") or prof.get("id", ""),
4614
+ "verdict": verdict, "mapped": len(files), "present": len(present),
4615
+ "missing_count": len(missing), "missing": missing, "folder": folder,
4616
+ "folder_exists": os.path.isdir(os.path.join(repo, folder))}
4617
+
4618
+
4619
+ def coverage_note(kind, map_rel, rows):
4620
+ """The one line select-stack puts in front of a lane's menu."""
4621
+ if not rows or all(r["verdict"] == "not-mapped" for r in rows):
4622
+ return ("%s names no stack rule folder for a %s lane, so the stack chosen here does "
4623
+ "not change which technical rules load." % (map_rel, kind))
4624
+ have = ["%s — complete" % r["title"] for r in rows if r["verdict"] == "complete"]
4625
+ have += ["%s — %d of %d files" % (r["title"], r["present"], r["mapped"])
4626
+ for r in rows if r["verdict"] == "partial"]
4627
+ none = [r["title"] for r in rows if r["verdict"] == "none"]
4628
+ text = "Technical rule sets in %s for a %s stack: %s." % (
4629
+ map_rel, kind, "; ".join(have) if have else "none yet")
4630
+ if none:
4631
+ text += (" None for: %s. A stack without one can still be declared, built and tested; "
4632
+ "plan, implement, build and analyze-slice then load no stack rules for it "
4633
+ "beyond ai-rules.md and the technical constitution until %s<id>/ holds them. "
4634
+ "On an existing codebase, onboard writes code-derived rules instead."
4635
+ % (", ".join(none), CORPUS_REL))
4636
+ return text
4637
+
4638
+
4639
+ def coverage_warning(kind, map_rel, row):
4640
+ """The warning select-stack raises for a declared lane whose stack lacks rules."""
4641
+ lane = LANE_WORDS[kind]
4642
+ who = "the %s lane's stack, %s (%s)," % (lane, row["title"], row["id"])
4643
+ if row["verdict"] == "none":
4644
+ return ("WARNING — %s has no technical rule set: %s maps %d file(s) under %s and none "
4645
+ "exists. That is a supported setup — plan, implement, build and analyze-slice "
4646
+ "will load no stack rules for this lane beyond ai-rules.md and the technical "
4647
+ "constitution, and each will say so. To give the lane stack rules, write %s "
4648
+ "with the file names the map lists (any existing folder under %s shows the "
4649
+ "shape), or point the map at your own files. On an existing codebase, onboard "
4650
+ "writes code-derived rules into guardrails/brf-<project>/ instead."
4651
+ % (who, map_rel, row["mapped"], row["folder"], row["folder"], CORPUS_REL))
4652
+ if row["verdict"] == "partial":
4653
+ names = ", ".join(os.path.relpath(f, row["folder"]).replace(os.sep, "/")
4654
+ for f in row["missing"])
4655
+ return ("WARNING — %s has an incomplete technical rule set: %d of the %d file(s) %s "
4656
+ "maps under %s exist. Missing: %s. Those rules do not load for this lane until "
4657
+ "the files are written or the map stops naming them."
4658
+ % (who, row["present"], row["mapped"], map_rel, row["folder"], names))
4659
+ return ""
4660
+
4661
+
4662
+ def cmd_rules(args):
4663
+ repo = args.repo
4664
+ project = active_project(repo)
4665
+ map_rel = "guardrails/%s/tech-rule-bindings.md" % project
4666
+ map_exists = bool(project) and os.path.isfile(os.path.join(repo, map_rel))
4667
+ lanes = (read_stacks(repo) or {}).get("lanes") or []
4668
+ bullets = map_bullets(os.path.join(repo, map_rel)) if map_exists else []
4669
+ rows = [rule_coverage(repo, args.kind, p, bullets, lanes)
4670
+ for p in load_profiles(repo, args.kind)]
4671
+ out = {"project": project, "map": map_rel, "map_exists": map_exists, "kind": args.kind,
4672
+ "profiles": rows,
4673
+ "note": coverage_note(args.kind, map_rel, rows) if map_exists else (
4674
+ "The active project has no %s, so no stack loads mapped technical rules."
4675
+ % map_rel)}
4676
+ if args.declared:
4677
+ lane = next((l for l in lanes if l.get("kind") == args.kind), {})
4678
+ pid = lane.get("profile", "")
4679
+ prof = profile_of(repo, lane) or {"id": pid, "title": lane.get("stack", "")}
4680
+ row = rule_coverage(repo, args.kind, prof, bullets, lanes) if lane and map_exists else {}
4681
+ warning = coverage_warning(args.kind, map_rel, row) if row else ""
4682
+ out.update({"declared": bool(lane), "lane_profile": pid,
4683
+ "verdict": row.get("verdict", ""), "lane_coverage": row,
4684
+ "ok": not warning, "warning": warning})
4685
+ emit(out)
4686
+
4687
+
3204
4688
  def main():
3205
4689
  ap = argparse.ArgumentParser(prog="aw-stack", description=__doc__)
3206
4690
  ap.add_argument("--repo", default=".", help="repository root (callers pass their own resolver's answer)")
@@ -3322,8 +4806,9 @@ def main():
3322
4806
  help="true when a test suite actually ran for the lane")
3323
4807
  p.add_argument("--requirements-ok", dest="requirements_ok", default="false",
3324
4808
  help="true when the lane's code met the delta-spec")
3325
- p.add_argument("--findings", default="",
3326
- help="the lane check's findings, a JSON list")
4809
+ p.add_argument("--findings", action="append", default=[],
4810
+ help="the lane check's findings, a JSON list; given more than once, the "
4811
+ "lists are joined")
3327
4812
  p.set_defaults(func=cmd_slice_gate_stamp)
3328
4813
 
3329
4814
  p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
@@ -3348,36 +4833,72 @@ def main():
3348
4833
  p.set_defaults(func=cmd_set)
3349
4834
 
3350
4835
  p = sub.add_parser("contract-export",
3351
- help="start the service lane, save its API document, stop it")
4836
+ help="write the service lane's API document as built, the way the lane says")
3352
4837
  p.add_argument("--timeout", type=int, default=180,
3353
- help="seconds to wait for the document after starting the service")
4838
+ help="seconds to wait for the document — for `contract_write` to finish, or "
4839
+ "for the started service to serve it")
3354
4840
  p.set_defaults(func=cmd_contract_export)
3355
4841
 
3356
4842
  p = sub.add_parser("contract-draft",
3357
- help="draft a feature's API document from its backend plan's API section")
4843
+ help="draft a feature's API document from its API plan, or a change's "
4844
+ "from its delta API plan")
3358
4845
  p.add_argument("--feature-dir", dest="feature_dir", default="")
4846
+ p.add_argument("--cr", default="",
4847
+ help="draft this change's API changes into its change folder instead")
3359
4848
  p.set_defaults(func=cmd_contract_draft)
3360
4849
 
3361
4850
  p = sub.add_parser("contract-combine",
3362
- help="the exported document plus every drafted operation it does not serve")
4851
+ help="every planned operation, with the open changes applied, plus the "
4852
+ "served operations no plan describes")
3363
4853
  p.set_defaults(func=cmd_contract_combine)
3364
4854
 
4855
+ p = sub.add_parser("contract-verify",
4856
+ help="the API the built service serves vs the operations the plan names")
4857
+ p.add_argument("--feature-dir", dest="feature_dir", default="")
4858
+ p.add_argument("--cr", default="",
4859
+ help="check this change's operations only (analyze-slice-delta)")
4860
+ p.add_argument("--doc", default="", help="the as-built document (default: the lane's)")
4861
+ p.add_argument("--check", action="store_true",
4862
+ help="read no as-built document; only report whether the plan names "
4863
+ "anything to check (`needed`)")
4864
+ p.set_defaults(func=cmd_contract_verify)
4865
+
4866
+ p = sub.add_parser("contract-merge",
4867
+ help="merge a change's delta API plan into its feature's API plan")
4868
+ p.add_argument("--feature-dir", dest="feature_dir", default="")
4869
+ p.add_argument("--cr", default="")
4870
+ p.add_argument("--check", action="store_true",
4871
+ help="write nothing; only report whether the change carries an API delta "
4872
+ "the API plan has not merged (`needed`)")
4873
+ p.set_defaults(func=cmd_contract_merge)
4874
+
4875
+ p = sub.add_parser("api-split",
4876
+ help="move a feature's endpoints and requests table out of its backend "
4877
+ "plan into a new API plan")
4878
+ p.add_argument("--feature-dir", dest="feature_dir", default="")
4879
+ p.add_argument("--check", action="store_true",
4880
+ help="only report whether the backend plan still carries the API (`needed`)")
4881
+ p.add_argument("--cr", default="",
4882
+ help="check this change's delta backend plan instead (implies --check)")
4883
+ p.set_defaults(func=cmd_api_split)
4884
+
3365
4885
  p = sub.add_parser("contract-requests",
3366
- help="the frontend plan's contract requests and the backend plan's answers")
4886
+ help="the frontend plan's contract requests and the API plan's answers")
3367
4887
  p.add_argument("--feature-dir", dest="feature_dir", default="")
3368
4888
  p.set_defaults(func=cmd_contract_requests)
3369
4889
 
3370
4890
  p = sub.add_parser("contract-check",
3371
- help="the operation ids a frontend plan cites vs the exported document")
4891
+ help="the operation ids a frontend plan cites vs the combined document")
3372
4892
  p.add_argument("--plan", required=True, help="the frontend plan (master or delta)")
3373
- p.add_argument("--doc", default="", help="the API document (default: the exported one)")
4893
+ p.add_argument("--doc", default="", help="the API document (default: the combined one)")
3374
4894
  p.set_defaults(func=cmd_contract_check)
3375
4895
 
3376
4896
  p = sub.add_parser("contract-impact",
3377
4897
  help="operations a change's service delta changes or removes that the "
3378
4898
  "frontend binds to, and whether its frontend delta carries them")
3379
4899
  p.add_argument("--delta-backend", dest="delta_backend", default="",
3380
- help="the change's delta-backend-plan.md")
4900
+ help="the change's delta-backend-plan.md; the delta-api-plan.md beside it "
4901
+ "carries the table when the change has one")
3381
4902
  p.add_argument("--frontend-plan", dest="frontend_plan", default="",
3382
4903
  help="the feature's master frontend plan")
3383
4904
  p.add_argument("--delta-frontend", dest="delta_frontend", default="",
@@ -3389,6 +4910,14 @@ def main():
3389
4910
  p.add_argument("--hint", default="", help="free prose to scan for a stack name")
3390
4911
  p.set_defaults(func=cmd_probe)
3391
4912
 
4913
+ p = sub.add_parser("rules", help="which stacks the active project's technical map has "
4914
+ "rule files for")
4915
+ p.add_argument("--kind", choices=["backend", "frontend"], required=True)
4916
+ p.add_argument("--declared", action="store_true",
4917
+ help="also report this kind's declared lane, with the warning select-stack "
4918
+ "raises when its stack's rule set is missing or incomplete")
4919
+ p.set_defaults(func=cmd_rules)
4920
+
3392
4921
  args = ap.parse_args()
3393
4922
  if not getattr(args, "func", None):
3394
4923
  ap.print_help()