aw-spec-cli 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -81
- package/assets/.appweaver/DESIGN-FORMAT.md +56 -17
- package/assets/.appweaver/STACK-PROFILES.md +95 -60
- package/assets/.appweaver/orchestrator/LAUNCHER.md +1 -1
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +173 -32
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +29 -1
- package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +49 -60
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +58 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +26 -8
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +20 -16
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +30 -6
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +9 -7
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +5 -5
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +341 -36
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +254 -43
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +248 -68
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +251 -20
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +316 -10
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +6 -47
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +62 -16
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +132 -9
- package/assets/.appweaver/orchestrator/workflows/aw-spec-fix-test-coverage.orch.yaml +965 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +222 -169
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +28 -12
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +453 -116
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-lane.orch.yaml +7 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +83 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +45 -2
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +324 -74
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +275 -44
- package/assets/.appweaver/scripts/python/aw-foundation.py +16 -13
- package/assets/.appweaver/scripts/python/aw-gaps.py +11 -3
- package/assets/.appweaver/scripts/python/aw-model-guard.py +295 -0
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +412 -3
- package/assets/.appweaver/scripts/python/aw-plangate.py +23 -16
- package/assets/.appweaver/scripts/python/aw-stack.py +1794 -265
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +0 -2
- package/assets/.appweaver/stack-profiles/java-spring.json +0 -2
- package/assets/.appweaver/templates/api-plan-template.md +107 -0
- package/assets/.appweaver/templates/delta-api-plan-template.md +87 -0
- package/assets/.appweaver/templates/delta-design-template.md +25 -23
- package/assets/.appweaver/templates/delta-spec-template.md +3 -3
- package/assets/.appweaver/templates/design-template.md +20 -42
- package/assets/.appweaver/templates/domain-model-template.md +3 -0
- package/assets/.appweaver/templates/ui-delta-design-template.md +11 -8
- package/assets/.appweaver/templates/ui-design-template.md +4 -4
- package/assets/foundations/foundation.spec.template.txt +25 -18
- package/assets/guardrails/README.md +61 -25
- package/assets/guardrails/methodology/ux/accessibility-standards.md +38 -0
- package/assets/guardrails/methodology/ux/content-design.md +34 -0
- package/assets/guardrails/methodology/ux/interaction-states.md +45 -0
- package/assets/guardrails/methodology/ux/journey-mapping.md +41 -0
- package/assets/guardrails/project-specific/domain/domain-requirements.md +25 -38
- package/assets/guardrails/project-specific/domain-rule-bindings.md +33 -69
- package/assets/guardrails/project-specific/frontend/ui-requirements.md +2 -2
- package/assets/guardrails/project-specific/service/technical-requirements.md +2 -2
- package/assets/guardrails/project-specific/tech-rule-bindings.md +16 -13
- package/assets/guardrails/sample-healthcare/domain/domain-requirements.md +58 -0
- package/assets/guardrails/sample-healthcare/domain-rule-bindings.md +138 -0
- package/assets/guardrails/sample-healthcare/frontend/ui-requirements.md +43 -0
- package/assets/guardrails/sample-healthcare/service/technical-requirements.md +40 -0
- package/assets/guardrails/sample-healthcare/tech-rule-bindings.md +303 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +216 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/utils/config.js +1 -1
- package/dist/utils/config.js.map +1 -1
- package/dist/utils/foundation.d.ts +36 -0
- package/dist/utils/foundation.d.ts.map +1 -0
- package/dist/utils/foundation.js +109 -0
- package/dist/utils/foundation.js.map +1 -0
- package/dist/utils/paths.js +1 -1
- package/dist/utils/paths.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
aw-stack — the lane model's deterministic core.
|
|
4
4
|
|
|
5
|
-
Backs
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
contract-
|
|
24
|
-
contract-
|
|
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-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
|
|
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
|
|
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
|
|
132
|
-
# document a frontend generates its client from
|
|
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
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
|
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
|
|
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
|
|
1615
|
-
# unreadable, never
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if not
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
|
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
|
|
2022
|
-
#
|
|
2023
|
-
#
|
|
2024
|
-
#
|
|
2025
|
-
#
|
|
2026
|
-
#
|
|
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
|
-
|
|
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", ""),
|
|
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,
|
|
2141
|
-
# prompt that asks for one — a workflow cannot write it
|
|
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,
|
|
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
|
|
2154
|
-
"
|
|
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
|
-
|
|
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
|
-
#
|
|
2169
|
-
#
|
|
2170
|
-
|
|
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(
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
|
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
|
|
2228
|
-
|
|
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
|
|
2234
|
-
#
|
|
2235
|
-
#
|
|
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
|
|
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
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2492
|
-
|
|
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
|
|
2510
|
-
"
|
|
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
|
|
2593
|
-
"""
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
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
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
|
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
|
|
2638
|
-
"
|
|
2639
|
-
|
|
2640
|
-
|
|
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
|
|
2647
|
-
"
|
|
2648
|
-
"
|
|
2649
|
-
"
|
|
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
|
-
|
|
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:
|
|
2666
|
-
#
|
|
2667
|
-
#
|
|
2668
|
-
#
|
|
2669
|
-
#
|
|
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":
|
|
2697
|
-
"
|
|
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 =
|
|
3177
|
+
combined = {
|
|
2704
3178
|
"openapi": "3.0.3",
|
|
2705
|
-
"info": {"title": "API —
|
|
2706
|
-
"description": ("
|
|
2707
|
-
"
|
|
2708
|
-
|
|
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
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
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
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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
|
|
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
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
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"] = ("
|
|
2763
|
-
"
|
|
2764
|
-
|
|
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
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
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
|
-
|
|
2781
|
-
|
|
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
|
-
#
|
|
2792
|
-
#
|
|
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
|
|
2795
|
-
#
|
|
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
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3014
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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 =
|
|
3090
|
-
|
|
3091
|
-
lower = {k.lower(): k for k in
|
|
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
|
|
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
|
-
#
|
|
3098
|
-
#
|
|
3099
|
-
|
|
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
|
|
3102
|
-
|
|
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",
|
|
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="
|
|
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
|
|
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
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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()
|