aw-spec-cli 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/assets/.appweaver/RULE-PARTITION.md +8 -9
  2. package/assets/.appweaver/STACK-PROFILES.md +74 -9
  3. package/assets/.appweaver/interpreter/run-skill.py +28 -7
  4. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +17 -7
  5. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +1 -1
  6. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +44 -1
  7. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +172 -5
  8. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +5 -4
  9. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +3 -1
  10. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +16 -8
  11. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +15 -18
  12. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +4 -1
  13. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
  14. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +7 -6
  15. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -1
  16. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +7 -1
  17. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  18. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +224 -16
  19. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +20 -19
  20. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +35 -11
  21. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +1 -1
  22. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +1 -1
  23. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +91 -7
  24. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +70 -7
  25. package/assets/.appweaver/scripts/python/aw-foundation.py +88 -12
  26. package/assets/.appweaver/scripts/python/aw-stack.py +481 -10
  27. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +2 -0
  28. package/assets/.appweaver/stack-profiles/java-spring.json +2 -0
  29. package/assets/.appweaver/stacks.json +5 -2
  30. package/assets/.appweaver/templates/delta-design-template.md +7 -5
  31. package/assets/.appweaver/templates/design-template.md +3 -0
  32. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  33. package/assets/.appweaver/templates/ui-design-template.md +6 -4
  34. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.lane.json +6 -0
  35. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.lane.json +6 -0
  36. package/assets/foundations/foundation.spec.template.txt +26 -1
  37. package/assets/foundations/java/Hexagonal/Hexagonal.lane.json +6 -0
  38. package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +7 -0
  39. package/assets/guardrails/project-specific/domain-rule-bindings.md +20 -8
  40. package/assets/guardrails/project-specific/tech-rule-bindings.md +25 -37
  41. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
  42. package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
  43. package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
  44. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
  45. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
  46. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
  47. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
  48. package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
  49. package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
  50. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
  51. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +1 -1
  52. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
  53. package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
  54. package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
  55. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
  56. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
  57. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
  58. package/assets/guardrails/technology/nextjs/api-client.md +1 -1
  59. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
  60. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
  61. package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
  62. package/assets/guardrails/technology/nextjs/client-security.md +1 -1
  63. package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
  64. package/assets/guardrails/technology/nextjs/component-conventions.md +1 -1
  65. package/assets/guardrails/technology/nextjs/design-system.md +1 -1
  66. package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
  67. package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
  68. package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
  69. package/assets/guardrails/technology/nextjs/i18n.md +1 -1
  70. package/assets/guardrails/technology/nextjs/observability.md +1 -1
  71. package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
  72. package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
  73. package/assets/guardrails/technology/nextjs/state-management.md +1 -1
  74. 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 seven aw-spec-actions subcommands, called identically by the bash and the
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 — THE SOLE WRITER
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
- `declare` is the sole writer and has exactly one caller — `select-stack`, where a
19
- person picks — so the rule is a grep rather than a convention. The `origin` key
20
- stays optional in the schema for a future caller that proposes a lane without a
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
- cmd = (lane.get(phase) or "").strip()
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 (sole writer)")
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