aw-spec-cli 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.appweaver/RULE-PARTITION.md +8 -9
- package/assets/.appweaver/STACK-PROFILES.md +74 -9
- package/assets/.appweaver/interpreter/run-skill.py +28 -7
- package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +17 -7
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +44 -1
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +172 -5
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +5 -4
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +3 -1
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +16 -8
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +15 -18
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +4 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +7 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +7 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +224 -16
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +20 -19
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +35 -11
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +91 -7
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +70 -7
- package/assets/.appweaver/scripts/python/aw-foundation.py +88 -12
- package/assets/.appweaver/scripts/python/aw-stack.py +481 -10
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +2 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +2 -0
- package/assets/.appweaver/stacks.json +5 -2
- package/assets/.appweaver/templates/delta-design-template.md +7 -5
- package/assets/.appweaver/templates/design-template.md +3 -0
- package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
- package/assets/.appweaver/templates/ui-design-template.md +6 -4
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.lane.json +6 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.lane.json +6 -0
- package/assets/foundations/foundation.spec.template.txt +26 -1
- package/assets/foundations/java/Hexagonal/Hexagonal.lane.json +6 -0
- package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +7 -0
- package/assets/guardrails/project-specific/domain-rule-bindings.md +20 -8
- package/assets/guardrails/project-specific/tech-rule-bindings.md +25 -37
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
- package/assets/guardrails/technology/nextjs/api-client.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
- package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
- package/assets/guardrails/technology/nextjs/client-security.md +1 -1
- package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
- package/assets/guardrails/technology/nextjs/component-conventions.md +1 -1
- package/assets/guardrails/technology/nextjs/design-system.md +1 -1
- package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
- package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
- package/assets/guardrails/technology/nextjs/i18n.md +1 -1
- package/assets/guardrails/technology/nextjs/observability.md +1 -1
- package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
- package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
- package/assets/guardrails/technology/nextjs/state-management.md +1 -1
- package/package.json +1 -1
|
@@ -2,24 +2,45 @@
|
|
|
2
2
|
"""
|
|
3
3
|
aw-stack — the lane model's deterministic core.
|
|
4
4
|
|
|
5
|
-
Backs
|
|
5
|
+
Backs ten 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
|
|
9
9
|
detect match the repo's own files against those profiles — the BROWNFIELD
|
|
10
10
|
candidate list, since an existing repo already answered the question
|
|
11
|
-
declare upsert a lane into .appweaver/stacks.json —
|
|
11
|
+
declare upsert a lane into .appweaver/stacks.json — the writer of WHOLE lanes
|
|
12
|
+
set change named keys on a lane already declared — the writer of SETTINGS
|
|
12
13
|
resolve read .appweaver/stacks.json — for every downstream reader; with
|
|
13
14
|
--feature-dir, narrowed to the lanes that feature has a surface in
|
|
14
15
|
verify is the declaration USABLE for the phase about to run — the gate
|
|
15
16
|
run-phase run a phase's command for EVERY declared lane, not just the first
|
|
16
17
|
probe is there evidence of a lane of this kind — read-only, never fatal
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
contract-export start the service lane, fetch its API document, stop it
|
|
19
|
+
contract-check the operation ids a frontend plan cites vs that document
|
|
20
|
+
|
|
21
|
+
This file is the only code that writes stacks.json, through two subcommands with
|
|
22
|
+
different jobs. `declare` creates or replaces a whole lane and has exactly one caller —
|
|
23
|
+
`select-stack`, where a person picks. `set` changes only the keys in CONTRACT_KEYS on a lane that is
|
|
24
|
+
already there, and never its root, profile or phase commands: `foundation` records the
|
|
25
|
+
API-export settings the pack it unpacked carries, and `implement` records the client
|
|
26
|
+
command a developer supplies. Re-declaring a lane to change one key would reset every
|
|
27
|
+
other command to the profile's default, which is why the two are separate. The `origin`
|
|
28
|
+
key stays optional in the schema for a future caller that proposes a lane without a
|
|
21
29
|
person in the loop; a reader that branches on it keeps working either way.
|
|
22
30
|
|
|
31
|
+
THE API CONTRACT BETWEEN THE LANES. The service lane publishes an API document and the
|
|
32
|
+
frontend lane generates its client from it. Three lane keys carry that, each read from
|
|
33
|
+
the lane first and from its stack profile when the lane does not set it, so a lane
|
|
34
|
+
declared before the keys existed answers them too:
|
|
35
|
+
|
|
36
|
+
contract_run (backend) the command that starts the service for the export
|
|
37
|
+
contract (backend) the URL the running service serves its document at
|
|
38
|
+
client (frontend) the command that generates the client; `{{contract}}` in
|
|
39
|
+
it is the exported document, as seen from where it runs
|
|
40
|
+
|
|
41
|
+
The document is written to CONTRACT_DOC under the service lane root folder, so it is
|
|
42
|
+
committed with the service that publishes it.
|
|
43
|
+
|
|
23
44
|
A person editing stacks.json in a text editor is the other way a lane arrives,
|
|
24
45
|
and it is supported: the kit ships the file with an empty `lanes` array for exactly
|
|
25
46
|
that, plus a `_lane_format` key holding one sample lane of each kind with every field
|
|
@@ -64,6 +85,14 @@ import sys
|
|
|
64
85
|
STACKS_REL = ".appweaver/stacks.json"
|
|
65
86
|
PROFILES_REL = ".appweaver/stack-profiles"
|
|
66
87
|
|
|
88
|
+
# Where the exported API document lands, relative to the service lane root folder.
|
|
89
|
+
CONTRACT_DOC = "contracts/openapi.json"
|
|
90
|
+
|
|
91
|
+
# The API-contract keys: the only ones `set` may change, and the ones read from the
|
|
92
|
+
# stack profile when the lane does not set them (lane_value). Everything else on a lane
|
|
93
|
+
# is the declaration itself and changes only through `declare`.
|
|
94
|
+
CONTRACT_KEYS = ("contract_run", "contract", "client")
|
|
95
|
+
|
|
67
96
|
# A lane id names the ROLE in the project; the profile names the technology.
|
|
68
97
|
# Keeping them separate is what lets a project re-stack a lane without renaming
|
|
69
98
|
# the paths, rules and constitution half that hang off the lane id.
|
|
@@ -709,8 +738,20 @@ def lane_command(lane, phase, repo=None):
|
|
|
709
738
|
--project <folder>` needs exactly one project in that folder, and a solution has
|
|
710
739
|
several. It is looked up when the command is read, so a project scaffolded after the
|
|
711
740
|
lane was declared is found; with no single match it falls back to `{{root}}`.
|
|
741
|
+
|
|
742
|
+
`{{contract}}` is the service lane's exported API document (contract_doc), named as
|
|
743
|
+
seen from where THIS lane's command runs — what a frontend lane's `client` command
|
|
744
|
+
generates from. With no service lane it is left as written.
|
|
712
745
|
"""
|
|
713
|
-
|
|
746
|
+
if phase in CONTRACT_KEYS:
|
|
747
|
+
cmd = lane_value(repo, lane, phase)
|
|
748
|
+
else:
|
|
749
|
+
cmd = (lane.get(phase) or "").strip()
|
|
750
|
+
if "{{contract}}" in cmd and repo:
|
|
751
|
+
doc = contract_doc(repo)
|
|
752
|
+
if doc:
|
|
753
|
+
rel = os.path.relpath(os.path.join(repo, doc), lane_workdir(repo, lane))
|
|
754
|
+
cmd = cmd.replace("{{contract}}", rel.replace("\\", "/"))
|
|
714
755
|
if "{{host}}" in cmd:
|
|
715
756
|
cmd = cmd.replace("{{host}}", lane_host(repo, lane) or "{{root}}")
|
|
716
757
|
if "{{root}}" not in cmd:
|
|
@@ -723,6 +764,31 @@ def lane_command(lane, phase, repo=None):
|
|
|
723
764
|
return cmd.replace("{{root}}", sub)
|
|
724
765
|
|
|
725
766
|
|
|
767
|
+
def lane_value(repo, lane, key):
|
|
768
|
+
"""A lane's value for one key, or its stack profile's when the lane sets none.
|
|
769
|
+
|
|
770
|
+
Only CONTRACT_KEYS fall back. The phase commands are written onto the lane when it
|
|
771
|
+
is declared; these are read from the profile at run time, so a lane declared before
|
|
772
|
+
they existed answers them, and a value set on the lane — by a pack or a developer —
|
|
773
|
+
overrides the profile's.
|
|
774
|
+
"""
|
|
775
|
+
value = (lane.get(key) or "").strip() if lane else ""
|
|
776
|
+
if not value and lane and repo and key in CONTRACT_KEYS:
|
|
777
|
+
value = (profile_of(repo, lane).get(key) or "").strip()
|
|
778
|
+
return value
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def contract_doc(repo, lanes=None):
|
|
782
|
+
"""The service lane's exported API document, repo-relative; "" with no service lane."""
|
|
783
|
+
if lanes is None:
|
|
784
|
+
lanes = (read_stacks(repo) or {}).get("lanes") or []
|
|
785
|
+
lane = next((l for l in lanes if l.get("kind") == "backend"), {})
|
|
786
|
+
r = norm_root(lane.get("root"))
|
|
787
|
+
if not r:
|
|
788
|
+
return ""
|
|
789
|
+
return CONTRACT_DOC if r == REPO_ROOT else r + CONTRACT_DOC
|
|
790
|
+
|
|
791
|
+
|
|
726
792
|
def roots_overlap(a, b):
|
|
727
793
|
"""Roots own their subtree, so containment either way is an overlap.
|
|
728
794
|
|
|
@@ -979,6 +1045,53 @@ def write_stacks(repo, data):
|
|
|
979
1045
|
fh.write("\n")
|
|
980
1046
|
|
|
981
1047
|
|
|
1048
|
+
def cmd_set(args):
|
|
1049
|
+
"""Change CONTRACT_KEYS on a lane that is already declared, leaving the rest of it
|
|
1050
|
+
as it is. `--set KEY=` with an empty value changes nothing, so a caller can pass every
|
|
1051
|
+
key it might have; `--unset KEY` removes one, so the lane reads its profile's again."""
|
|
1052
|
+
repo = args.repo
|
|
1053
|
+
data = read_stacks(repo) or {"lanes": []}
|
|
1054
|
+
lanes = data.get("lanes") or []
|
|
1055
|
+
wanted = strip_root_label(args.id) if args.id else ""
|
|
1056
|
+
lane = next((l for l in lanes if (l.get("id") == wanted if wanted
|
|
1057
|
+
else l.get("kind") == args.kind)), None)
|
|
1058
|
+
if lane is None:
|
|
1059
|
+
fail("lane-set: no lane %s is declared in %s"
|
|
1060
|
+
% ("'%s'" % wanted if wanted else "of kind '%s'" % (args.kind or "?"), STACKS_REL))
|
|
1061
|
+
|
|
1062
|
+
set_, removed = {}, []
|
|
1063
|
+
for item in args.set or []:
|
|
1064
|
+
key, sep, value = item.partition("=")
|
|
1065
|
+
key = key.strip()
|
|
1066
|
+
if not sep or key not in CONTRACT_KEYS:
|
|
1067
|
+
fail("lane-set: '%s' is not KEY=VALUE with KEY one of %s"
|
|
1068
|
+
% (item, ", ".join(CONTRACT_KEYS)))
|
|
1069
|
+
if value.strip():
|
|
1070
|
+
set_[key] = value.strip()
|
|
1071
|
+
for key in args.unset or []:
|
|
1072
|
+
if key not in CONTRACT_KEYS:
|
|
1073
|
+
fail("lane-set: --unset '%s' is not one of %s" % (key, ", ".join(CONTRACT_KEYS)))
|
|
1074
|
+
if key not in set_ and key in lane:
|
|
1075
|
+
removed.append(key)
|
|
1076
|
+
|
|
1077
|
+
lane.update(set_)
|
|
1078
|
+
for key in removed:
|
|
1079
|
+
del lane[key]
|
|
1080
|
+
if set_ or removed:
|
|
1081
|
+
data["lanes"] = lanes
|
|
1082
|
+
write_stacks(repo, data)
|
|
1083
|
+
emit({
|
|
1084
|
+
"ok": True,
|
|
1085
|
+
"lane_id": lane.get("id", ""),
|
|
1086
|
+
"kind": lane.get("kind", ""),
|
|
1087
|
+
"set": set_,
|
|
1088
|
+
"removed": removed,
|
|
1089
|
+
"lane": lane,
|
|
1090
|
+
"path": STACKS_REL,
|
|
1091
|
+
"written": bool(set_ or removed),
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
|
|
982
1095
|
def profile_of(repo, lane):
|
|
983
1096
|
"""The stack profile a lane names, or {} when it names none the kit ships."""
|
|
984
1097
|
if not lane:
|
|
@@ -1041,6 +1154,12 @@ def kind_block(repo, lanes, kind):
|
|
|
1041
1154
|
kind + "_test": lane_command(lane, "test", repo),
|
|
1042
1155
|
kind + "_run": lane_command(lane, "run", repo),
|
|
1043
1156
|
kind + "_migrate": lane_command(lane, "migrate", repo) or prof.get("migrate", ""),
|
|
1157
|
+
# The API-contract settings, the lane's own or its profile's (lane_value). A
|
|
1158
|
+
# service lane uses the first two and a frontend lane the third; the others read
|
|
1159
|
+
# empty on each.
|
|
1160
|
+
kind + "_contract_run": lane_command(lane, "contract_run", repo),
|
|
1161
|
+
kind + "_contract": lane_value(repo, lane, "contract"),
|
|
1162
|
+
kind + "_client": lane_command(lane, "client", repo),
|
|
1044
1163
|
}
|
|
1045
1164
|
|
|
1046
1165
|
|
|
@@ -1124,6 +1243,7 @@ def cmd_resolve(args):
|
|
|
1124
1243
|
"lane_id": "", "root": "", "source_path": "", "test_path": "", "root_exists": False,
|
|
1125
1244
|
"build": "", "test": "", "run": "", "migrate": "",
|
|
1126
1245
|
"language": "", "ecosystem": "",
|
|
1246
|
+
"contract_doc": "", "contract_doc_exists": False,
|
|
1127
1247
|
"path": STACKS_REL,
|
|
1128
1248
|
}
|
|
1129
1249
|
payload.update(kind_block(args.repo, [], "backend"))
|
|
@@ -1141,6 +1261,7 @@ def cmd_resolve(args):
|
|
|
1141
1261
|
match = next((l for l in lanes if l.get("id") == wanted), {})
|
|
1142
1262
|
elif args.kind:
|
|
1143
1263
|
match = next((l for l in lanes if l.get("kind") == args.kind), {})
|
|
1264
|
+
doc = contract_doc(args.repo, lanes)
|
|
1144
1265
|
|
|
1145
1266
|
# `declared` is about LANES and `exists` about the file — they part company on
|
|
1146
1267
|
# the shipped-but-unfilled seed, which is on disk and declares nothing.
|
|
@@ -1165,6 +1286,9 @@ def cmd_resolve(args):
|
|
|
1165
1286
|
"language": profile_of(args.repo, match).get("language", ""),
|
|
1166
1287
|
"ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
|
|
1167
1288
|
"prototype": data.get("prototype", {}),
|
|
1289
|
+
# The service lane's exported API document, and whether an export has written it.
|
|
1290
|
+
"contract_doc": doc,
|
|
1291
|
+
"contract_doc_exists": bool(doc) and os.path.isfile(os.path.join(args.repo, doc)),
|
|
1168
1292
|
"path": STACKS_REL,
|
|
1169
1293
|
}
|
|
1170
1294
|
payload.update(kind_block(args.repo, lanes, "backend"))
|
|
@@ -1443,6 +1567,329 @@ def cmd_run_phase(args):
|
|
|
1443
1567
|
})
|
|
1444
1568
|
|
|
1445
1569
|
|
|
1570
|
+
# ─────────────────────────── contract-export ───────────────────────────────
|
|
1571
|
+
# The service lane's API document, taken from the service itself. A document served by a
|
|
1572
|
+
# running service is the one thing every web stack can produce the same way — Swagger,
|
|
1573
|
+
# springdoc, FastAPI and the rest all serve one — so the export starts the service with
|
|
1574
|
+
# the lane's `contract_run`, fetches `contract`, and stops it. How a stack (or the pack
|
|
1575
|
+
# the lane was scaffolded from) has to be started is the command's business, not this
|
|
1576
|
+
# code's.
|
|
1577
|
+
|
|
1578
|
+
CONTRACT_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _fetch(url, timeout=5):
|
|
1582
|
+
"""(status, body bytes, error text). A local https service usually has a development
|
|
1583
|
+
certificate, so certificate checks are skipped for localhost only."""
|
|
1584
|
+
import ssl
|
|
1585
|
+
import urllib.error
|
|
1586
|
+
import urllib.parse
|
|
1587
|
+
import urllib.request
|
|
1588
|
+
ctx = None
|
|
1589
|
+
if url.lower().startswith("https://"):
|
|
1590
|
+
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
|
1591
|
+
if host in ("localhost", "127.0.0.1", "::1"):
|
|
1592
|
+
ctx = ssl._create_unverified_context()
|
|
1593
|
+
try:
|
|
1594
|
+
with urllib.request.urlopen(url, timeout=timeout, context=ctx) as resp:
|
|
1595
|
+
return resp.status, resp.read(), ""
|
|
1596
|
+
except urllib.error.HTTPError as e:
|
|
1597
|
+
return e.code, b"", "HTTP %d" % e.code
|
|
1598
|
+
except Exception as e: # refused, reset, timed out — the service is not up yet
|
|
1599
|
+
return 0, b"", str(getattr(e, "reason", e))
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _api_document(body):
|
|
1603
|
+
"""The parsed document, or None when the body is not an OpenAPI/Swagger document."""
|
|
1604
|
+
try:
|
|
1605
|
+
doc = json.loads(body.decode("utf-8-sig"))
|
|
1606
|
+
except (ValueError, UnicodeDecodeError):
|
|
1607
|
+
return None
|
|
1608
|
+
if isinstance(doc, dict) and ("openapi" in doc or "swagger" in doc) \
|
|
1609
|
+
and isinstance(doc.get("paths"), dict):
|
|
1610
|
+
return doc
|
|
1611
|
+
return None
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _operations(doc):
|
|
1615
|
+
"""Every operation in a document: [{"id", "method", "path"}], in document order."""
|
|
1616
|
+
ops = []
|
|
1617
|
+
for path, item in (doc.get("paths") or {}).items():
|
|
1618
|
+
if not isinstance(item, dict):
|
|
1619
|
+
continue
|
|
1620
|
+
for method in CONTRACT_HTTP_METHODS:
|
|
1621
|
+
op = item.get(method)
|
|
1622
|
+
if isinstance(op, dict):
|
|
1623
|
+
ops.append({"id": str(op.get("operationId") or ""),
|
|
1624
|
+
"method": method.upper(), "path": path})
|
|
1625
|
+
return ops
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _start(cmd, cwd, log):
|
|
1629
|
+
import subprocess
|
|
1630
|
+
kw = {"shell": True, "cwd": cwd, "stdout": log, "stderr": subprocess.STDOUT}
|
|
1631
|
+
if os.name == "nt":
|
|
1632
|
+
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
1633
|
+
else:
|
|
1634
|
+
kw["start_new_session"] = True # its own process group, so the stop reaches children
|
|
1635
|
+
return subprocess.Popen(cmd, **kw)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _stop(proc):
|
|
1639
|
+
"""Stop the service and everything it started — `dotnet run` and `npm run` both run
|
|
1640
|
+
the real server as a child, which outlives a kill of the parent alone."""
|
|
1641
|
+
import signal
|
|
1642
|
+
import subprocess
|
|
1643
|
+
if proc.poll() is not None:
|
|
1644
|
+
return
|
|
1645
|
+
if os.name == "nt":
|
|
1646
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1647
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1648
|
+
else:
|
|
1649
|
+
try:
|
|
1650
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
1651
|
+
except OSError:
|
|
1652
|
+
pass
|
|
1653
|
+
try:
|
|
1654
|
+
proc.wait(timeout=15)
|
|
1655
|
+
except subprocess.TimeoutExpired:
|
|
1656
|
+
try:
|
|
1657
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
1658
|
+
except OSError:
|
|
1659
|
+
pass
|
|
1660
|
+
try:
|
|
1661
|
+
proc.wait(timeout=15)
|
|
1662
|
+
except Exception:
|
|
1663
|
+
pass
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def _tail(path, budget=LANE_TAIL):
|
|
1667
|
+
try:
|
|
1668
|
+
with open(path, "rb") as fh:
|
|
1669
|
+
return fh.read().decode("utf-8", "replace")[-budget:].strip()
|
|
1670
|
+
except OSError:
|
|
1671
|
+
return ""
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def cmd_contract_export(args):
|
|
1675
|
+
import tempfile
|
|
1676
|
+
import time
|
|
1677
|
+
|
|
1678
|
+
repo = args.repo
|
|
1679
|
+
lanes = (read_stacks(repo) or {}).get("lanes") or []
|
|
1680
|
+
lane = next((l for l in lanes if l.get("kind") == "backend"), {})
|
|
1681
|
+
doc_rel = contract_doc(repo, lanes)
|
|
1682
|
+
doc_abs = os.path.join(repo, doc_rel) if doc_rel else ""
|
|
1683
|
+
cmd = lane_command(lane, "contract_run", repo)
|
|
1684
|
+
url = lane_value(repo, lane, "contract")
|
|
1685
|
+
result = {
|
|
1686
|
+
"ok": False, "ran": False, "lane": lane.get("id", ""), "cmd": cmd, "url": url,
|
|
1687
|
+
"cwd": "", "doc": doc_rel, "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
|
|
1688
|
+
"operations": 0, "unnamed": 0, "seconds": 0, "already_running": False,
|
|
1689
|
+
"reason": "", "log_tail": "",
|
|
1690
|
+
# The placeholder a `client` command names the document by, spelled out for a
|
|
1691
|
+
# prompt that asks for one — a workflow cannot write it literally, since the
|
|
1692
|
+
# engine would read it as a variable.
|
|
1693
|
+
"placeholder": "{{contract}}",
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
def done(**kw):
|
|
1697
|
+
result.update(kw)
|
|
1698
|
+
result["doc_exists"] = bool(doc_abs) and os.path.isfile(doc_abs)
|
|
1699
|
+
# One line on which document a reader now has, for a prompt to quote.
|
|
1700
|
+
if result["ok"]:
|
|
1701
|
+
result["status"] = "%s, exported from the built service in this run" % doc_rel
|
|
1702
|
+
elif result["doc_exists"]:
|
|
1703
|
+
result["status"] = ("%s, from an earlier export — this run's failed, so it may "
|
|
1704
|
+
"be older than the code" % doc_rel)
|
|
1705
|
+
else:
|
|
1706
|
+
result["status"] = ""
|
|
1707
|
+
emit(result)
|
|
1708
|
+
|
|
1709
|
+
if not lane:
|
|
1710
|
+
return done(reason="no service lane is declared in %s" % STACKS_REL)
|
|
1711
|
+
if not root_on_disk(repo, lane.get("root", "")):
|
|
1712
|
+
return done(reason="the service lane root folder '%s' is not on disk yet"
|
|
1713
|
+
% lane.get("root", ""))
|
|
1714
|
+
if not url:
|
|
1715
|
+
return done(reason="the service lane has no `contract` URL — set it in %s, or in "
|
|
1716
|
+
"its stack profile" % STACKS_REL)
|
|
1717
|
+
|
|
1718
|
+
# A service already answering at the URL is used as it is: a second instance could
|
|
1719
|
+
# not bind the port anyway. It may be running older code than this run built, which
|
|
1720
|
+
# the caller is told.
|
|
1721
|
+
status, body, _err = _fetch(url)
|
|
1722
|
+
proc, log_path, started, exited = None, "", time.time(), None
|
|
1723
|
+
if status != 200 or _api_document(body) is None:
|
|
1724
|
+
if not cmd:
|
|
1725
|
+
return done(reason="the service lane has no `contract_run` command to start it "
|
|
1726
|
+
"— set it in %s, or in its stack profile" % STACKS_REL)
|
|
1727
|
+
workdir = lane_workdir(repo, lane)
|
|
1728
|
+
result["cwd"] = os.path.relpath(workdir, repo).replace("\\", "/")
|
|
1729
|
+
fd, log_path = tempfile.mkstemp(prefix="aw-contract-", suffix=".log")
|
|
1730
|
+
log = os.fdopen(fd, "wb")
|
|
1731
|
+
try:
|
|
1732
|
+
proc = _start(cmd, workdir, log)
|
|
1733
|
+
result["ran"] = True
|
|
1734
|
+
deadline = started + args.timeout
|
|
1735
|
+
status, body = 0, b""
|
|
1736
|
+
while time.time() < deadline:
|
|
1737
|
+
if proc.poll() is not None:
|
|
1738
|
+
exited = proc.returncode
|
|
1739
|
+
break
|
|
1740
|
+
status, body, _err = _fetch(url)
|
|
1741
|
+
if status == 200 and _api_document(body) is not None:
|
|
1742
|
+
break
|
|
1743
|
+
time.sleep(2)
|
|
1744
|
+
finally:
|
|
1745
|
+
if proc is not None:
|
|
1746
|
+
_stop(proc)
|
|
1747
|
+
log.close()
|
|
1748
|
+
else:
|
|
1749
|
+
result["already_running"] = True
|
|
1750
|
+
|
|
1751
|
+
seconds = int(time.time() - started)
|
|
1752
|
+
tail = _tail(log_path) if log_path else ""
|
|
1753
|
+
if log_path:
|
|
1754
|
+
try:
|
|
1755
|
+
os.remove(log_path)
|
|
1756
|
+
except OSError:
|
|
1757
|
+
pass
|
|
1758
|
+
doc = _api_document(body) if status == 200 else None
|
|
1759
|
+
if doc is None:
|
|
1760
|
+
if exited is not None:
|
|
1761
|
+
why = "`%s` exited (code %s) before %s answered" % (cmd, exited, url)
|
|
1762
|
+
elif status == 200:
|
|
1763
|
+
why = "%s answered, but not with an OpenAPI or Swagger JSON document" % url
|
|
1764
|
+
elif status:
|
|
1765
|
+
why = "%s answered HTTP %d for %d s" % (url, status, seconds)
|
|
1766
|
+
else:
|
|
1767
|
+
why = "nothing answered at %s within %d s" % (url, args.timeout)
|
|
1768
|
+
return done(seconds=seconds, log_tail=tail,
|
|
1769
|
+
reason=why + (" — the last exported document is kept" if result["doc_exists"] else ""))
|
|
1770
|
+
|
|
1771
|
+
os.makedirs(os.path.dirname(doc_abs), exist_ok=True)
|
|
1772
|
+
with open(doc_abs, "w", encoding="utf-8") as fh:
|
|
1773
|
+
json.dump(doc, fh, indent=2)
|
|
1774
|
+
fh.write("\n")
|
|
1775
|
+
ops = _operations(doc)
|
|
1776
|
+
note = ("fetched from a service that was already running at %s — it may be older than "
|
|
1777
|
+
"the code this run built" % url) if result["already_running"] else ""
|
|
1778
|
+
done(ok=True, seconds=seconds, operations=len(ops),
|
|
1779
|
+
unnamed=sum(1 for o in ops if not o["id"]), reason=note, log_tail="")
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
# ─────────────────────────── contract-check ────────────────────────────────
|
|
1783
|
+
# Do the operation ids a frontend plan cites exist in the exported document? Read by
|
|
1784
|
+
# script, so the answer does not depend on a model reading two documents carefully.
|
|
1785
|
+
#
|
|
1786
|
+
# The ids are read from two table shapes, the two plan templates' own:
|
|
1787
|
+
# - the master plan's §5, a table with an `Operation id` column;
|
|
1788
|
+
# - a delta plan's ADDED/MODIFIED tables, where a row whose section is `Contract
|
|
1789
|
+
# Binding` names the operation id as its element's first `code` span.
|
|
1790
|
+
|
|
1791
|
+
_OP_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
|
1792
|
+
_NOT_AN_ID = {"none", "tbd", "todo", "na"}
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _cells(line):
|
|
1796
|
+
s = line.strip()
|
|
1797
|
+
if not s.startswith("|"):
|
|
1798
|
+
return None
|
|
1799
|
+
return [c.strip() for c in s.strip("|").split("|")]
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _is_rule(cells):
|
|
1803
|
+
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", c or "") for c in cells)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _op_ids(text):
|
|
1807
|
+
"""The operation ids a cell names: `code` spans first, else bare words. Placeholders
|
|
1808
|
+
(`[OperationId]`), dashes and anything with a space are not ids."""
|
|
1809
|
+
spans = re.findall(r"`([^`]+)`", text)
|
|
1810
|
+
tokens = [t.strip() for t in (spans if spans else re.split(r"[,·;]\s*|\s+", text))]
|
|
1811
|
+
return [t for t in tokens if _OP_ID.match(t) and t.lower() not in _NOT_AN_ID]
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def plan_operation_ids(text):
|
|
1815
|
+
"""(cited ids in order, rows with a binding but no id)."""
|
|
1816
|
+
lines = text.splitlines()
|
|
1817
|
+
cited, uncited = [], 0
|
|
1818
|
+
i = 0
|
|
1819
|
+
while i < len(lines):
|
|
1820
|
+
header = _cells(lines[i])
|
|
1821
|
+
rule = _cells(lines[i + 1]) if i + 1 < len(lines) else None
|
|
1822
|
+
if header is None or rule is None or not _is_rule(rule):
|
|
1823
|
+
i += 1
|
|
1824
|
+
continue
|
|
1825
|
+
lowered = [h.lower() for h in header]
|
|
1826
|
+
id_col = next((n for n, h in enumerate(lowered) if h.replace("-", " ") == "operation id"), -1)
|
|
1827
|
+
section_col = next((n for n, h in enumerate(lowered) if h.startswith("section")), -1)
|
|
1828
|
+
i += 2
|
|
1829
|
+
while i < len(lines) and _cells(lines[i]) is not None:
|
|
1830
|
+
row = _cells(lines[i])
|
|
1831
|
+
i += 1
|
|
1832
|
+
if id_col >= 0 and id_col < len(row):
|
|
1833
|
+
ids = _op_ids(row[id_col])
|
|
1834
|
+
elif section_col >= 0 and section_col < len(row) \
|
|
1835
|
+
and "contract binding" in row[section_col].lower() and section_col + 1 < len(row):
|
|
1836
|
+
spans = re.findall(r"`([^`]+)`", row[section_col + 1])
|
|
1837
|
+
ids = [s for s in spans[:1] if _OP_ID.match(s)]
|
|
1838
|
+
else:
|
|
1839
|
+
continue
|
|
1840
|
+
if ids:
|
|
1841
|
+
cited.extend(x for x in ids if x not in cited)
|
|
1842
|
+
else:
|
|
1843
|
+
uncited += 1
|
|
1844
|
+
return cited, uncited
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def cmd_contract_check(args):
|
|
1848
|
+
repo = args.repo
|
|
1849
|
+
plan_abs = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
|
|
1850
|
+
doc_rel = args.doc or contract_doc(repo)
|
|
1851
|
+
doc_abs = (doc_rel if os.path.isabs(doc_rel) else os.path.join(repo, doc_rel)) if doc_rel else ""
|
|
1852
|
+
result = {"ok": False, "plan": args.plan, "doc": doc_rel,
|
|
1853
|
+
"doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
|
|
1854
|
+
"cited": 0, "ids": [], "missing": [], "uncited": 0,
|
|
1855
|
+
"doc_operations": 0, "doc_unnamed": 0, "reason": ""}
|
|
1856
|
+
if not os.path.isfile(plan_abs):
|
|
1857
|
+
result["reason"] = "the frontend plan %s is not on disk" % args.plan
|
|
1858
|
+
return emit(result)
|
|
1859
|
+
with open(plan_abs, encoding="utf-8", errors="replace") as fh:
|
|
1860
|
+
cited, uncited = plan_operation_ids(fh.read())
|
|
1861
|
+
result.update(cited=len(cited), ids=cited, uncited=uncited)
|
|
1862
|
+
if not result["doc_exists"]:
|
|
1863
|
+
result["reason"] = "no exported API document at %s" % (doc_rel or "(no service lane)")
|
|
1864
|
+
return emit(result)
|
|
1865
|
+
with open(doc_abs, "rb") as fh:
|
|
1866
|
+
doc = _api_document(fh.read())
|
|
1867
|
+
if doc is None:
|
|
1868
|
+
result["reason"] = "%s is not an OpenAPI or Swagger JSON document" % doc_rel
|
|
1869
|
+
return emit(result)
|
|
1870
|
+
ops = _operations(doc)
|
|
1871
|
+
known = {o["id"] for o in ops if o["id"]}
|
|
1872
|
+
lower = {k.lower(): k for k in known}
|
|
1873
|
+
missing = []
|
|
1874
|
+
for x in cited:
|
|
1875
|
+
if x not in known:
|
|
1876
|
+
near = lower.get(x.lower())
|
|
1877
|
+
missing.append(x + (" (the document has %s)" % near if near else ""))
|
|
1878
|
+
result.update(missing=missing, doc_operations=len(ops),
|
|
1879
|
+
doc_unnamed=sum(1 for o in ops if not o["id"]))
|
|
1880
|
+
parts = []
|
|
1881
|
+
if missing:
|
|
1882
|
+
parts.append("%d cited operation id(s) are not in %s: %s"
|
|
1883
|
+
% (len(missing), doc_rel, ", ".join(missing)))
|
|
1884
|
+
if not cited:
|
|
1885
|
+
parts.append("the plan cites no operation id")
|
|
1886
|
+
if uncited:
|
|
1887
|
+
parts.append("%d binding row(s) cite no operation id" % uncited)
|
|
1888
|
+
result["reason"] = "; ".join(parts)
|
|
1889
|
+
result["ok"] = bool(cited) and not missing
|
|
1890
|
+
emit(result)
|
|
1891
|
+
|
|
1892
|
+
|
|
1446
1893
|
# ──────────────────────────────── probe ────────────────────────────────────
|
|
1447
1894
|
|
|
1448
1895
|
def profile_terms(prof):
|
|
@@ -1555,7 +2002,7 @@ def main():
|
|
|
1555
2002
|
"evidence there, so a service profile cannot relocate onto the UI's files")
|
|
1556
2003
|
p.set_defaults(func=cmd_detect)
|
|
1557
2004
|
|
|
1558
|
-
p = sub.add_parser("declare", help="upsert a lane into stacks.json
|
|
2005
|
+
p = sub.add_parser("declare", help="upsert a whole lane into stacks.json")
|
|
1559
2006
|
p.add_argument("--profile", required=True,
|
|
1560
2007
|
help="profile id, a select_options label, 'none', or 'detected' "
|
|
1561
2008
|
"(a stack the kit ships no profile for — needs --stack and --root)")
|
|
@@ -1612,12 +2059,36 @@ def main():
|
|
|
1612
2059
|
p.set_defaults(func=cmd_verify)
|
|
1613
2060
|
|
|
1614
2061
|
p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
|
|
1615
|
-
p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate"],
|
|
1616
|
-
help="which project-supplied command to run"
|
|
2062
|
+
p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate", "client"],
|
|
2063
|
+
help="which project-supplied command to run; `client` is a frontend lane's "
|
|
2064
|
+
"client generator, with {{contract}} expanded")
|
|
1617
2065
|
p.add_argument("--kind", choices=["backend", "frontend"],
|
|
1618
2066
|
help="restrict to one lane kind (default: every declared lane)")
|
|
1619
2067
|
p.set_defaults(func=cmd_run_phase)
|
|
1620
2068
|
|
|
2069
|
+
p = sub.add_parser("set", help="change API-contract keys on a declared lane")
|
|
2070
|
+
p.add_argument("--id", default="", help="the lane id")
|
|
2071
|
+
p.add_argument("--kind", choices=["backend", "frontend"],
|
|
2072
|
+
help="the lane of this kind, when --id is not given")
|
|
2073
|
+
p.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
|
|
2074
|
+
help="repeatable; KEY is one of %s, and an empty VALUE changes nothing"
|
|
2075
|
+
% ", ".join(CONTRACT_KEYS))
|
|
2076
|
+
p.add_argument("--unset", action="append", default=[], metavar="KEY",
|
|
2077
|
+
help="repeatable; remove KEY, so the lane reads its profile's value again")
|
|
2078
|
+
p.set_defaults(func=cmd_set)
|
|
2079
|
+
|
|
2080
|
+
p = sub.add_parser("contract-export",
|
|
2081
|
+
help="start the service lane, save its API document, stop it")
|
|
2082
|
+
p.add_argument("--timeout", type=int, default=180,
|
|
2083
|
+
help="seconds to wait for the document after starting the service")
|
|
2084
|
+
p.set_defaults(func=cmd_contract_export)
|
|
2085
|
+
|
|
2086
|
+
p = sub.add_parser("contract-check",
|
|
2087
|
+
help="the operation ids a frontend plan cites vs the exported document")
|
|
2088
|
+
p.add_argument("--plan", required=True, help="the frontend plan (master or delta)")
|
|
2089
|
+
p.add_argument("--doc", default="", help="the API document (default: the exported one)")
|
|
2090
|
+
p.set_defaults(func=cmd_contract_check)
|
|
2091
|
+
|
|
1621
2092
|
p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
|
|
1622
2093
|
p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
|
|
1623
2094
|
p.add_argument("--hint", default="", help="free prose to scan for a stack name")
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"build": "dotnet build",
|
|
18
18
|
"test": "dotnet test",
|
|
19
19
|
"run": "dotnet run --project {{host}}",
|
|
20
|
+
"contract_run": "dotnet run --project {{host}} -- --environment Development --urls http://localhost:5000",
|
|
21
|
+
"contract": "http://localhost:5000/swagger/v1/swagger.json",
|
|
20
22
|
"host_patterns": [
|
|
21
23
|
"**/*.AppHost.csproj",
|
|
22
24
|
"**/*.Api.csproj",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"build": "./mvnw clean install",
|
|
18
18
|
"test": "./mvnw test",
|
|
19
19
|
"run": "./mvnw spring-boot:run",
|
|
20
|
+
"contract_run": "./mvnw spring-boot:run -Dspring-boot.run.arguments=--server.port=8080",
|
|
21
|
+
"contract": "http://localhost:8080/v3/api-docs",
|
|
20
22
|
"detect": [
|
|
21
23
|
"pom.xml",
|
|
22
24
|
"build.gradle"
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
"build": "dotnet build",
|
|
15
15
|
"test": "dotnet test",
|
|
16
16
|
"run": "dotnet run --project {{host}}",
|
|
17
|
-
"migrate": "dotnet ef database update --project {{host}}"
|
|
17
|
+
"migrate": "dotnet ef database update --project {{host}}",
|
|
18
|
+
"contract_run": "dotnet run --project {{host}} -- --environment Development --urls http://localhost:5000",
|
|
19
|
+
"contract": "http://localhost:5000/swagger/v1/swagger.json"
|
|
18
20
|
},
|
|
19
21
|
{
|
|
20
22
|
"id": "web",
|
|
@@ -28,7 +30,8 @@
|
|
|
28
30
|
"build": "npm run build",
|
|
29
31
|
"test": "npm test",
|
|
30
32
|
"run": "npm run dev",
|
|
31
|
-
"migrate": ""
|
|
33
|
+
"migrate": "",
|
|
34
|
+
"client": "npx openapi-typescript {{contract}} -o lib/api/schema.d.ts"
|
|
32
35
|
}
|
|
33
36
|
],
|
|
34
37
|
"prototype": {
|
|
@@ -89,7 +89,7 @@ it realises. "Realisation only — no spec delta" for a plan-only change.]
|
|
|
89
89
|
| Section (§) | Element | Kind | Serves |
|
|
90
90
|
|---|---|---|---|
|
|
91
91
|
| Application (§2) | `[Command/Query]` + handler | use-case | US-[n] · FR-[nnn] |
|
|
92
|
-
| API (§3) | `[VERB] [/route]` | endpoint | US-[n] · FR-[nnn] |
|
|
92
|
+
| API (§3) | `[OperationId]` · `[VERB] [/route]` | endpoint | US-[n] · FR-[nnn] |
|
|
93
93
|
| Domain (§1) | `[Event]` handling / `[VO]` mapping | model realisation | BR-[nnn] · CN-[nnn] |
|
|
94
94
|
| Infrastructure (§4.2) | `I[External]` adapter | contract realisation | IC-[nnn] |
|
|
95
95
|
| Cross-cutting (§5) | [audit / retention / exposure decision] | constraint realisation | CN-[nnn] · DC-[nnn] |
|
|
@@ -118,12 +118,14 @@ realisation); new migration needed? (yes/no); any contract change other systems
|
|
|
118
118
|
What this change adds to or alters in the service's PUBLISHED surface — the only part
|
|
119
119
|
of this document the frontend lane may bind against. One row per contract; the frontend
|
|
120
120
|
delta plan cites these rather than re-deriving them. "None" when the surface is
|
|
121
|
-
unchanged, which is what a purely internal refactor should say.
|
|
121
|
+
unchanged, which is what a purely internal refactor should say. The operation id is the
|
|
122
|
+
endpoint's name in the published API document, unique across the service; an endpoint
|
|
123
|
+
the master plan already names keeps that id.
|
|
122
124
|
-->
|
|
123
125
|
|
|
124
|
-
| Contract | Change | Shape | Serves |
|
|
125
|
-
|
|
126
|
-
| `[VERB] [/route]` | added / changed | [request → response field shapes] | IC-[nnn] · FR-[nnn] |
|
|
126
|
+
| Operation id | Contract | Change | Shape | Serves |
|
|
127
|
+
|---|---|---|---|---|
|
|
128
|
+
| `[OperationId]` | `[VERB] [/route]` | added / changed | [request → response field shapes] | IC-[nnn] · FR-[nnn] |
|
|
127
129
|
|
|
128
130
|
## Other open deltas
|
|
129
131
|
|