aw-spec-cli 0.1.26 → 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.
Files changed (137) hide show
  1. package/assets/.agents/skills/appweaver/SKILL.md +7 -47
  2. package/assets/.appweaver/ORCH-DIALECT.md +13 -9
  3. package/assets/.appweaver/RULE-PARTITION.md +11 -12
  4. package/assets/.appweaver/STACK-PROFILES.md +260 -108
  5. package/assets/.appweaver/domain.json +1 -1
  6. package/assets/.appweaver/interpreter/run-skill.py +28 -7
  7. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +38 -10
  8. package/assets/.appweaver/orchestrator/LAUNCHER.md +88 -0
  9. package/assets/.appweaver/orchestrator/README.md +120 -53
  10. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +1 -1
  11. package/assets/.appweaver/orchestrator/modules/delta-plan.orch.yaml +17 -7
  12. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +6 -4
  13. package/assets/.appweaver/orchestrator/modules/foundation-lane.orch.yaml +237 -0
  14. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +392 -0
  15. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +548 -0
  16. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +4 -4
  17. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +3 -3
  18. package/assets/.appweaver/orchestrator/orchestrator.py +162 -63
  19. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +5 -4
  20. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +3 -1
  21. package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +2 -2
  22. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +2 -2
  23. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +21 -13
  24. package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +1 -1
  25. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +28 -31
  26. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +2 -2
  27. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +1 -1
  28. package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +3 -3
  29. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +4 -1
  30. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
  31. package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +41 -3
  32. package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +1 -1
  33. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +17 -16
  34. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -1
  35. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +7 -1
  36. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +3 -3
  37. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +3 -3
  38. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
  39. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +2 -2
  40. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +61 -12
  41. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +1 -1
  42. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +172 -293
  43. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +253 -44
  44. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +72 -71
  45. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
  46. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +38 -14
  47. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +4 -4
  48. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +26 -2
  49. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +21 -15
  50. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +14 -14
  51. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +3 -3
  52. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +3 -3
  53. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +6 -6
  54. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +371 -131
  55. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +293 -76
  56. package/assets/.appweaver/scripts/python/aw-foundation.py +885 -20
  57. package/assets/.appweaver/scripts/python/aw-inventory.py +28 -21
  58. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +2 -2
  59. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +6 -6
  60. package/assets/.appweaver/scripts/python/aw-stack.py +676 -99
  61. package/assets/.appweaver/stack-profiles/angular.json +2 -1
  62. package/assets/.appweaver/stack-profiles/blazor-wasm.json +2 -1
  63. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +18 -3
  64. package/assets/.appweaver/stack-profiles/go-chi.json +6 -1
  65. package/assets/.appweaver/stack-profiles/java-spring.json +14 -5
  66. package/assets/.appweaver/stack-profiles/nextjs.json +2 -1
  67. package/assets/.appweaver/stack-profiles/node-express.json +8 -2
  68. package/assets/.appweaver/stack-profiles/python-fastapi.json +9 -2
  69. package/assets/.appweaver/stack-profiles/react-vite.json +2 -1
  70. package/assets/.appweaver/stack-profiles/sveltekit.json +2 -1
  71. package/assets/.appweaver/stack-profiles/vue-vite.json +2 -1
  72. package/assets/.appweaver/stacks.json +40 -1
  73. package/assets/.appweaver/templates/constitution-ui-template.md +2 -2
  74. package/assets/.appweaver/templates/delta-design-template.md +7 -5
  75. package/assets/.appweaver/templates/design-template.md +3 -0
  76. package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
  77. package/assets/.appweaver/templates/ui-design-template.md +6 -4
  78. package/assets/.claude/skills/appweaver/SKILL.md +7 -46
  79. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.rules.json +31 -0
  80. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.lane.json +6 -0
  81. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.rules.json +16 -0
  82. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.lane.json +6 -0
  83. package/assets/foundations/foundation.spec.template.txt +98 -0
  84. package/assets/foundations/java/Hexagonal/Hexagonal.lane.json +6 -0
  85. package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +8 -1
  86. package/assets/foundations/java/Hexagonal/unpack-foundation.sh +4 -5
  87. package/assets/foundations/node/NextAppRouter/NextAppRouter.spec.txt +68 -0
  88. package/assets/guardrails/README.md +31 -17
  89. package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain-rule-bindings.md +25 -13
  90. package/assets/guardrails/project-specific/tech-rule-bindings.md +296 -0
  91. package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +58 -4
  92. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
  93. package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
  94. package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
  95. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
  96. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
  97. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
  98. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
  99. package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
  100. package/assets/guardrails/technology/dotnet-webapi/eventing.md +1 -1
  101. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
  102. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +1 -1
  103. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +1 -1
  104. package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
  105. package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
  106. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
  107. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
  108. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
  109. package/assets/guardrails/technology/nextjs/api-client.md +1 -1
  110. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
  111. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
  112. package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
  113. package/assets/guardrails/technology/nextjs/client-security.md +1 -1
  114. package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
  115. package/assets/guardrails/technology/nextjs/component-conventions.md +2 -2
  116. package/assets/guardrails/technology/nextjs/design-system.md +1 -1
  117. package/assets/guardrails/technology/nextjs/error-handling.md +1 -1
  118. package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
  119. package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
  120. package/assets/guardrails/technology/nextjs/i18n.md +1 -1
  121. package/assets/guardrails/technology/nextjs/observability.md +1 -1
  122. package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
  123. package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
  124. package/assets/guardrails/technology/nextjs/state-management.md +1 -1
  125. package/assets/guardrails/technology/nextjs/ui-constitution.md +3 -3
  126. package/package.json +1 -1
  127. package/assets/.agents/skills/appweaver-demo/SKILL.md +0 -59
  128. package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +0 -6
  129. package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +0 -100
  130. package/assets/.claude/skills/appweaver-demo/SKILL.md +0 -59
  131. package/assets/.kilocode/agent.json +0 -28
  132. package/assets/.kilocode/workflows/appweaver-demo.md +0 -59
  133. package/assets/.kilocode/workflows/appweaver.md +0 -55
  134. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +0 -271
  135. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain/domain-requirements.md +0 -0
  136. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/frontend/ui-requirements.md +0 -0
  137. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/service/technical-requirements.md +0 -0
@@ -2,42 +2,66 @@
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
- and it is supported: the kit ships the file holding `{"lanes": []}` for exactly
25
- that. It is the one path that does not pass the writer's checks, which is why
45
+ and it is supported: the kit ships the file with an empty `lanes` array for exactly
46
+ that, plus a `_lane_format` key holding one sample lane of each kind with every field
47
+ filled in. Nothing reads `_lane_format`; it is there to copy from. It is the one path that does not pass the writer's checks, which is why
26
48
  `verify` re-checks the roots-disjoint invariant rather than trusting that
27
49
  `declare` already did. `resolve` can never write, which is why these are two
28
50
  subcommands and not one with a mode flag.
29
51
 
30
- A lane's `root` IS its source path — the subtree the lane owns and the only place
52
+ A lane's `root` IS the lane root folder — the subtree the lane owns and the only place
31
53
  its implement phase may write. The two words name one field, and `--source-path`
32
54
  is accepted wherever `--root` is.
33
55
 
34
- WHERE THE COMMANDS RUN is a separate question, because for most stacks the answer
35
- is a different directory: `dotnet build` wants the solution file at the top while
36
- the lane owns `src/`, and `npm run build` wants the package.json at the top while
37
- the lane owns `web/`. `run_from` carries that answer — "repo" (the default, and
38
- the shape of every lane written before the key existed) or "root". A stack whose
39
- manifest lives INSIDE its subtree needs "root" and cannot work without it: a Maven
40
- wrapper at backend/<app>/mvnw is unreachable from the repository root.
56
+ WHERE THE COMMANDS RUN is a separate question, because the answer can be a different
57
+ directory: a classic .NET repository keeps its solution file at the top while the
58
+ lane owns `src/`. `run_from` carries that answer — "repo" (the default, and the shape
59
+ of every lane written before the key existed) or "root". A stack whose build file
60
+ lives INSIDE its subtree needs "root" and cannot work without it: a Maven wrapper at
61
+ backend/<app>/mvnw is unreachable from the repository root. A NEW project is one
62
+ self-contained folder per lane — every profile defaults to `backend/` or `frontend/`
63
+ with `run_from: "root"` — while `declare` answers for code that already exists from
64
+ where its build file actually sits (see `build_files_at`).
41
65
 
42
66
  WHERE THE PATH IS CHECKED AGAINST THE DISK. `declare` records a path; it does not
43
67
  require the path to exist, because in a greenfield project it does not exist yet —
@@ -61,6 +85,14 @@ import sys
61
85
  STACKS_REL = ".appweaver/stacks.json"
62
86
  PROFILES_REL = ".appweaver/stack-profiles"
63
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
+
64
96
  # A lane id names the ROLE in the project; the profile names the technology.
65
97
  # Keeping them separate is what lets a project re-stack a lane without renaming
66
98
  # the paths, rules and constitution half that hang off the lane id.
@@ -270,6 +302,11 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
270
302
  files_for_prof = scoped if prof.get("kind") != "frontend" else files
271
303
  evidence, matched, anchored_hits = [], 0, 0
272
304
  prof_root = norm_root(prof.get("root") or "")
305
+ # The `detect` patterns are written against the layout an EXISTING repository of
306
+ # this stack usually has (`src/**/*.csproj`, `web/next.config.*`), which is the
307
+ # profile's `conventional_root` — not its `root`, the folder a NEW project gets.
308
+ conv_root = norm_root(prof.get("conventional_root") or "")
309
+ bases = [b for b in (prof_root, conv_root) if b and b != REPO_ROOT]
273
310
  for pat in patterns:
274
311
  rx = _glob_rx(pat)
275
312
  rx_any = _glob_rx("**/" + pat)
@@ -278,8 +315,8 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
278
315
  anchored_hits += 1
279
316
  else:
280
317
  hits = [f for f in files_for_prof if rx_any.match(f)]
281
- if not hits and prof_root and pat.startswith(prof_root) \
282
- and os.path.basename(pat) not in ambiguous:
318
+ base = next((b for b in bases if pat.startswith(b)), "")
319
+ if not hits and base and os.path.basename(pat) not in ambiguous:
283
320
  # RELOCATED, with the profile's own root segment substituted rather than
284
321
  # prefixed. Prefixing alone only ever finds the profile's own folder name
285
322
  # deeper in the tree — `apps/portal/web/angular.json` — so a UI in
@@ -288,7 +325,7 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
288
325
  # Restricted to names only one kind claims, so substitution widens where a
289
326
  # stack may live without widening what counts as evidence for it.
290
327
  hits = [f for f in files_for_prof
291
- if _glob_rx("**/" + pat[len(prof_root):]).match(f)]
328
+ if _glob_rx("**/" + pat[len(base):]).match(f)]
292
329
  if not hits:
293
330
  continue
294
331
  matched += 1
@@ -298,20 +335,21 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
298
335
  dirs = sorted({os.path.dirname(f) for f in evidence}, key=lambda d: (d.count("/"), len(d)))
299
336
  # WHERE THE SOURCE LIVES, which is not always where the evidence sits. A manifest
300
337
  # at the repo root — `App.sln`, a workspace `package.json` — marks the repository,
301
- # not the subtree: the code it describes is under the profile's own root. So repo-
302
- # root evidence defers to that root when the directory is really there, and only
338
+ # not the subtree: the code it describes is under one of the profile's roots. So
339
+ # repo-root evidence defers to that root when the directory is really there, and only
303
340
  # falls back to the repo root itself when it is not, which is the case for the
304
341
  # single-package layouts (`create-next-app`, a UI checked out at the top level)
305
- # whose files genuinely are at the top.
342
+ # whose files genuinely are at the top. The conventional root is tried first:
343
+ # repo-root evidence (`App.sln`) is the classic layout, whose code sits under
344
+ # `src/` — not under the `backend/` a new project would get.
306
345
  prof_default = norm_root(prof.get("root") or "")
307
346
  if not dirs:
308
347
  root = prof_default
309
348
  elif dirs[0]:
310
349
  root = norm_root(dirs[0])
311
- elif prof_default and os.path.isdir(os.path.join(repo, prof_default)):
312
- root = prof_default
313
350
  else:
314
- root = REPO_ROOT
351
+ root = next((r for r in (conv_root, prof_default)
352
+ if r and os.path.isdir(os.path.join(repo, r))), REPO_ROOT)
315
353
  out.append({
316
354
  "id": prof["id"],
317
355
  "kind": prof.get("kind", ""),
@@ -404,7 +442,7 @@ def cmd_list(args):
404
442
  repository looks like. Two subcommands would walk the file tree twice and add
405
443
  two steps to a workflow whose whole value is being short. One call returns the
406
444
  options, the profile the files point at, and the directory the evidence sits
407
- in — which is the source path the operator is about to be asked to confirm.
445
+ in — which is the lane root folder the operator is about to be asked to confirm.
408
446
 
409
447
  HOW A DETECTED STACK IS PROPOSED, and why it is not preselected. Detection
410
448
  moves its match to the FRONT of the option list; it never fills `preselect`.
@@ -448,10 +486,10 @@ def cmd_list(args):
448
486
  preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
449
487
  preselect_source = "argument" if preselect else ""
450
488
 
451
- # Candidate SOURCE PATHS for the follow-up question, best first. Labelled with why
489
+ # Candidate LANE ROOT FOLDERS for the follow-up question, best first. Labelled with why
452
490
  # each is offered, because the operator picking from this list is choosing the
453
491
  # subtree a generator will be allowed to write into. Free text is always
454
- # accepted — a repo that matches no profile still has a source path.
492
+ # accepted — a repo that matches no profile still has a lane root folder.
455
493
  #
456
494
  # WITH --profile the stack is already chosen, and the list answers for it alone:
457
495
  # where THAT stack's evidence sits, then its own default, then where another
@@ -460,7 +498,7 @@ def cmd_list(args):
460
498
  # operator did not choose.
461
499
  #
462
500
  # WITHOUT --profile the list is built before any choice, and a default root is
463
- # usually shared (six frontend profiles default to web/), so its label names every
501
+ # usually shared (every frontend profile defaults to frontend/), so its label names every
464
502
  # profile that proposes it rather than whichever one sorts first.
465
503
  chosen = None
466
504
  if args.profile:
@@ -541,16 +579,16 @@ def strip_root_label(value):
541
579
  return (value or "").split(ROOT_LABEL_SEP, 1)[0].strip()
542
580
 
543
581
 
544
- # The repository root as a source path. A single-module project — a Maven, Cargo, Go
582
+ # The repository root as a lane root folder. A single-module project — a Maven, Cargo, Go
545
583
  # or Node repo whose manifest sits at the top — has no subdirectory to name, and the
546
- # empty string cannot carry it: `verify` reads an empty root as "no source path
584
+ # empty string cannot carry it: `verify` reads an empty root as "no lane root folder
547
585
  # declared". "./" is the one spelling that survives being normalised twice, which is
548
586
  # what makes it usable as a STORED value rather than only as typed input.
549
587
  REPO_ROOT = "./"
550
588
 
551
589
 
552
590
  def norm_root(root):
553
- """Canonicalise a source path, IDEMPOTENTLY.
591
+ """Canonicalise a lane root folder, IDEMPOTENTLY.
554
592
 
555
593
  Idempotence is a correctness requirement, not tidiness: `declare` normalises what it
556
594
  writes and `verify` normalises what it reads, so a value that changes on the second
@@ -575,7 +613,7 @@ def norm_root(root):
575
613
 
576
614
 
577
615
  def root_on_disk(repo, root):
578
- """Does the lane's source path exist as a directory?
616
+ """Does the lane root folder exist as a directory?
579
617
 
580
618
  Reported everywhere a root is reported and enforced only by `verify`. A false
581
619
  here is an ordinary state before any code is written and a hard error once a
@@ -588,14 +626,15 @@ def root_on_disk(repo, root):
588
626
  def lane_workdir(repo, lane):
589
627
  """The directory a lane's commands run in.
590
628
 
591
- `run_from: "root"` runs them in the lane's own source path; anything else — including
629
+ `run_from: "root"` runs them in the lane root folder; anything else — including
592
630
  the key being ABSENT, which is the shape of every lane written before this key
593
- existed — runs them at the repository root. That default is not laziness: a .NET
594
- solution file, a go.mod or a package.json at the top is what `dotnet build`,
595
- `go build ./...` and `npm run build` need to find, and those profiles declare a `root`
596
- that is their SOURCE directory rather than their build directory. The two coincide for
597
- a stack whose manifest lives inside the subtree — a Maven wrapper at
598
- backend/orderservice/mvnw cannot be reached from the repo root at all.
631
+ existed — runs them at the repository root. That default is not laziness: in a
632
+ classic repository a .NET solution file, a go.mod or a package.json at the top is what
633
+ `dotnet build`, `go build ./...` and `npm run build` need to find, while the lane is
634
+ the SOURCE directory under it. The two coincide for a stack whose build file lives
635
+ inside the subtree — a Maven wrapper at backend/orderservice/mvnw cannot be reached
636
+ from the repo root at all — and for every new project, which is one self-contained
637
+ folder per lane.
599
638
 
600
639
  Falls back to the repo root when the declared root is not on disk, so the phase
601
640
  reports the command's own failure instead of a chdir error.
@@ -608,30 +647,148 @@ def lane_workdir(repo, lane):
608
647
  return repo
609
648
 
610
649
 
611
- def lane_command(lane, phase):
612
- """The command for one phase, with `{{root}}` standing for the lane's root.
650
+ def build_files_at(repo, root, build_files):
651
+ """Where a stack's build file already sits for a lane at `root`: "root" when one is
652
+ directly inside the lane root folder, "repo" when one is only at the repository root,
653
+ "" when neither — nothing is on disk yet, so the caller falls back to the profile.
654
+
655
+ This is what lets one profile serve both kinds of repository. A new project is one
656
+ self-contained folder per lane (`backend/api.sln`), and its commands run inside it; a
657
+ classic .NET repository keeps `api.sln` at the top with the code under `src/`, and its
658
+ commands have to run at the top. Reading the answer off the disk at declare time gives
659
+ each its own, where a fixed profile default would break one of them.
660
+ """
661
+ if not build_files:
662
+ return ""
663
+ r = norm_root(root)
664
+ lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
665
+
666
+ def has(d):
667
+ return any(os.path.isfile(p) for pat in build_files
668
+ for p in glob.glob(os.path.join(d, pat)))
669
+
670
+ if os.path.isdir(lane_dir) and has(lane_dir):
671
+ return "root"
672
+ if has(repo):
673
+ return "repo"
674
+ return ""
675
+
676
+
677
+ # Folders a runnable project is never looked for in: build output, dependencies, tests.
678
+ HOST_SKIP_DIRS = {"bin", "obj", "node_modules", ".git", ".vs", "packages", "tests", "test"}
679
+
613
680
 
614
- A profile command sometimes has to NAME the source path — `dotnet run --project
615
- src/`, `python -m compileall app`. Written literally, that is the profile's
616
- default root frozen into the command, and it stays frozen when the lane declares
617
- a different one: a service at `Services/Api/` runs the project at `src/`, or
618
- fails looking for it. The placeholder lets the profile say "the root this lane
619
- declares" and be right on every layout.
681
+ def lane_host(repo, lane):
682
+ """The lane's runnable project folder, which `{{host}}` in a command stands for.
683
+
684
+ A lane root folder often holds several projects and only one of them is the thing to
685
+ run — a .NET solution's Aspire AppHost, or its API when there is no AppHost. The
686
+ profile's `host_patterns` say where that project sits, best first, as paths relative
687
+ to the lane root folder (`**/` for any depth, case ignored). The first pattern that
688
+ matches exactly ONE file names the project; its folder is returned as seen from where
689
+ the lane's commands run. None when the profile has no patterns, the lane root folder is
690
+ not on disk, or no pattern has a single match — the caller then uses the lane root folder.
691
+ """
692
+ if not repo:
693
+ return None
694
+ patterns = [p.lower() for p in profile_of(repo, lane).get("host_patterns") or []]
695
+ r = norm_root(lane.get("root"))
696
+ lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
697
+ if not patterns or not os.path.isdir(lane_dir):
698
+ return None
699
+ leaves = {p.rsplit("/", 1)[-1] for p in patterns}
700
+ files = []
701
+ for dirpath, dirnames, filenames in os.walk(lane_dir):
702
+ dirnames[:] = [d for d in dirnames if d.lower() not in HOST_SKIP_DIRS]
703
+ for name in filenames:
704
+ if any(fnmatch.fnmatchcase(name.lower(), leaf) for leaf in leaves):
705
+ rel = os.path.relpath(os.path.join(dirpath, name), lane_dir).replace("\\", "/")
706
+ files.append(rel)
707
+ for pat in patterns:
708
+ bare = pat[3:] if pat.startswith("**/") else pat
709
+ hits = [f for f in files
710
+ if fnmatch.fnmatchcase(f.lower(), pat) or fnmatch.fnmatchcase(f.lower(), bare)]
711
+ if len(hits) == 1:
712
+ project_dir = os.path.join(lane_dir, os.path.dirname(hits[0]))
713
+ rel = os.path.relpath(project_dir, lane_workdir(repo, lane)).replace("\\", "/")
714
+ return rel
715
+ return None
716
+
717
+
718
+ def lane_command(lane, phase, repo=None):
719
+ """The command for one phase, with `{{root}}` standing for the lane root folder and
720
+ `{{host}}` for the lane's runnable project inside it (see lane_host).
721
+
722
+ A profile command sometimes has to NAME the lane root folder — `python -m
723
+ compileall app`. Written literally, that is the profile's default root frozen into
724
+ the command, and it stays frozen when the lane declares a different one: a service
725
+ at `Services/Api/` compiles the code at `app/`, or fails looking for it. The
726
+ placeholder lets the profile say "the root this lane declares" and be right on
727
+ every layout.
620
728
 
621
729
  Expanded at RUN time, not at declare time, so editing `root` in stacks.json by
622
730
  hand keeps the command consistent with it. A command with no placeholder is
623
731
  returned untouched, so every literal command already on disk is unaffected.
732
+
733
+ The root is named as seen from where the command RUNS: a `run_from: "root"` lane
734
+ runs inside its root, so there `{{root}}` is `.` — the repository-relative path
735
+ would point at `backend/backend`.
736
+
737
+ `{{host}}` exists because the lane root folder is not always runnable: `dotnet run
738
+ --project <folder>` needs exactly one project in that folder, and a solution has
739
+ several. It is looked up when the command is read, so a project scaffolded after the
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.
624
745
  """
625
- 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("\\", "/"))
755
+ if "{{host}}" in cmd:
756
+ cmd = cmd.replace("{{host}}", lane_host(repo, lane) or "{{root}}")
626
757
  if "{{root}}" not in cmd:
627
758
  return cmd
628
759
  r = norm_root(lane.get("root"))
629
760
  # `./` is the repo root: a bare `./` reads as noise in a command line, and the
630
761
  # only sensible substitution for "the root, which is here" is the cwd itself.
631
- sub = "." if (r == REPO_ROOT or not r) else r.rstrip("/")
762
+ runs_inside = (lane.get("run_from") or "repo") == "root"
763
+ sub = "." if (r == REPO_ROOT or not r or runs_inside) else r.rstrip("/")
632
764
  return cmd.replace("{{root}}", sub)
633
765
 
634
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
+
635
792
  def roots_overlap(a, b):
636
793
  """Roots own their subtree, so containment either way is an overlap.
637
794
 
@@ -654,7 +811,7 @@ def lane_test_path(repo, lane, prof=None):
654
811
 
655
812
  Most backend stacks keep tests BESIDE the source rather than in it: .NET's `tests/`
656
813
  next to `src/`, Maven's `src/test/java/` next to `src/main/java/`, pytest's `tests/`
657
- next to `app/`. A lane that owned only its source path could not hold its own tests.
814
+ next to `app/`. A lane that owned only its lane root folder could not hold its own tests.
658
815
 
659
816
  Answered at READ time, like `ecosystem`, so a lane declared before this existed has
660
817
  one without being re-declared:
@@ -662,8 +819,11 @@ def lane_test_path(repo, lane, prof=None):
662
819
  does not describe;
663
820
  - else the profile's `test_root`, which pairs with the profile's default `root`. A
664
821
  lane at that root takes it as is; a lane whose root ends with it takes it under
665
- the same prefix (`backend/src/` ⇒ `backend/tests/`);
666
- - else the tests live inside the source path. That is right for every frontend
822
+ the same prefix (`apps/backend/` ⇒ `apps/backend/tests/`);
823
+ - else the profile's `conventional_test_root`, which pairs the same way with its
824
+ `conventional_root` — the classic layout an existing repository has (`src/` ⇒
825
+ `tests/`), so a lane declared at it keeps the test path it always had;
826
+ - else the tests live inside the lane root folder. That is right for every frontend
667
827
  profile, for Go's colocated `_test.go` files, and for a Maven lane rooted at the
668
828
  directory holding its pom.xml.
669
829
  """
@@ -676,24 +836,25 @@ def lane_test_path(repo, lane, prof=None):
676
836
  if own:
677
837
  return own
678
838
  prof = profile_of(repo, lane) if prof is None else prof
679
- default_root = norm_root(prof.get("root"))
680
- default_tests = norm_root(prof.get("test_root"))
681
- if not default_root or not default_tests or REPO_ROOT in (default_root, default_tests):
682
- return root
683
- if root == default_root:
684
- return default_tests
685
- if root.endswith("/" + default_root):
686
- return root[: -len(default_root)] + default_tests
839
+ for key_root, key_tests in (("root", "test_root"),
840
+ ("conventional_root", "conventional_test_root")):
841
+ base, tests = norm_root(prof.get(key_root)), norm_root(prof.get(key_tests))
842
+ if not base or not tests or REPO_ROOT in (base, tests):
843
+ continue
844
+ if root == base:
845
+ return tests
846
+ if root.endswith("/" + base):
847
+ return root[: -len(base)] + tests
687
848
  return root
688
849
 
689
850
 
690
851
  def lane_owned_paths(repo, lane, prof=None):
691
- """Every subtree a lane owns, labelled: its source path, and its test path when that
852
+ """Every subtree a lane owns, labelled: its lane root folder, and its test path when that
692
853
  is a different directory. Ownership checks compare these, so a test path can never
693
854
  land inside another lane's subtree."""
694
855
  root = norm_root(lane.get("root"))
695
856
  tests = lane_test_path(repo, lane, prof)
696
- owned = [("source path", root)] if root else []
857
+ owned = [("lane root folder", root)] if root else []
697
858
  if tests and tests != root:
698
859
  owned.append(("test path", tests))
699
860
  return owned
@@ -768,7 +929,7 @@ def cmd_declare(args):
768
929
  lane_id = args.id or DEFAULT_LANE_ID.get(kind, kind)
769
930
  root = norm_root(args.root or (prof or {}).get("root") or "")
770
931
  if not root:
771
- fail("lane-declare: lane '%s' has no source path. A lane without a root owns "
932
+ fail("lane-declare: lane '%s' has no lane root folder. A lane without a root owns "
772
933
  "nothing, so nothing can be built, run or written into it. Pass "
773
934
  "--source-path <dir> (the profile declares no default)." % lane_id)
774
935
 
@@ -777,7 +938,7 @@ def cmd_declare(args):
777
938
  # is about to read the code out of it.
778
939
  exists = root_on_disk(repo, root)
779
940
  if args.require_existing_root and not exists:
780
- fail("lane-declare: source path '%s' for lane '%s' does not exist under %s. "
941
+ fail("lane-declare: lane root folder '%s' for lane '%s' does not exist under %s. "
781
942
  "Point the lane at the directory that actually holds this lane's source, "
782
943
  "or re-run /appweaver select-stack to correct it."
783
944
  % (root, lane_id, repo))
@@ -802,8 +963,12 @@ def cmd_declare(args):
802
963
  lane["migrate"] = migrate
803
964
  # WHERE this lane's commands run — see lane_workdir. Recorded only when it is not
804
965
  # the default, so a lane reads the same as it always did unless its stack needs the
805
- # other answer.
806
- run_from = getattr(args, "run_from", "") or (prof or {}).get("run_from", "")
966
+ # other answer. An explicit --run-from wins; else the stack's build file decides when
967
+ # it is on disk (build_files_at); else — a greenfield lane, nothing written yet — the
968
+ # profile's own answer, which for a new project is inside the lane.
969
+ run_from = (getattr(args, "run_from", "")
970
+ or build_files_at(repo, root, (prof or {}).get("build_files") or [])
971
+ or (prof or {}).get("run_from", ""))
807
972
  if run_from == "root":
808
973
  lane["run_from"] = "root"
809
974
  # What the code was found to be, when no shipped profile names it.
@@ -824,7 +989,7 @@ def cmd_declare(args):
824
989
  for theirs_label, theirs in lane_owned_paths(repo, other):
825
990
  if roots_overlap(mine, theirs):
826
991
  fail("lane-declare: %s '%s' for lane '%s' overlaps lane '%s''s %s '%s'. "
827
- "A lane owns its source path and its test path, and no two lanes "
992
+ "A lane owns its lane root folder and its test path, and no two lanes "
828
993
  "may share a subtree."
829
994
  % (mine_label, mine, lane_id, other.get("id"), theirs_label, theirs))
830
995
 
@@ -880,6 +1045,53 @@ def write_stacks(repo, data):
880
1045
  fh.write("\n")
881
1046
 
882
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
+
883
1095
  def profile_of(repo, lane):
884
1096
  """The stack profile a lane names, or {} when it names none the kit ships."""
885
1097
  if not lane:
@@ -893,7 +1105,7 @@ def profile_of(repo, lane):
893
1105
  def kind_block(repo, lanes, kind):
894
1106
  """Every field a caller needs about ONE kind, flattened and prefixed.
895
1107
 
896
- A workflow that wants the service source path binds `$.backend_source_path`
1108
+ A workflow that wants the service lane root folder binds `$.backend_source_path`
897
1109
  and is done — no `--kind` argument, no second call, and no branch on which
898
1110
  lane came back. Both kinds are always present in the payload, so the two-lane
899
1111
  steps (a gate that needs backend, a report that names both) resolve one script
@@ -927,14 +1139,27 @@ def kind_block(repo, lanes, kind):
927
1139
  kind + "_root": root,
928
1140
  kind + "_source_path": root,
929
1141
  kind + "_root_exists": root_on_disk(repo, root),
930
- # Where the lane's paired tests go. Equal to the source path when the stack keeps
1142
+ # Where the lane's paired tests go. Equal to the lane root folder when the stack keeps
931
1143
  # its tests inside it; see lane_test_path.
932
1144
  kind + "_test_path": test_path,
933
1145
  kind + "_test_path_exists": root_on_disk(repo, test_path),
934
- kind + "_build": lane_command(lane, "build"),
935
- kind + "_test": lane_command(lane, "test"),
936
- kind + "_run": lane_command(lane, "run"),
937
- kind + "_migrate": lane_command(lane, "migrate") or prof.get("migrate", ""),
1146
+ # Where the lane's commands run, as DECLARED — `./` for the repository root.
1147
+ # Read from the declaration rather than from lane_workdir, which falls back to the
1148
+ # repo root while the lane root folder is not on disk yet; a phase that creates the
1149
+ # project has to be told where its build will run before that path exists.
1150
+ kind + "_workdir": (norm_root(root) or REPO_ROOT)
1151
+ if lane and (lane.get("run_from") or "repo") == "root"
1152
+ else (REPO_ROOT if lane else ""),
1153
+ kind + "_build": lane_command(lane, "build", repo),
1154
+ kind + "_test": lane_command(lane, "test", repo),
1155
+ kind + "_run": lane_command(lane, "run", repo),
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),
938
1163
  }
939
1164
 
940
1165
 
@@ -956,7 +1181,7 @@ def lane_options(repo, lanes):
956
1181
  title = (prof.get("title", "") or lane.get("stack", "")
957
1182
  or lane.get("profile", "") or "no stack declared")
958
1183
  root = norm_root(lane.get("root"))
959
- where = "the repo root" if root == REPO_ROOT else (root or "no source path")
1184
+ where = "the repo root" if root == REPO_ROOT else (root or "no lane root folder")
960
1185
  label = "%s %s at %s" % (lane.get("kind", "lane"), title, where)
961
1186
  tests = lane_test_path(repo, lane, prof)
962
1187
  if tests and tests != root:
@@ -1018,6 +1243,7 @@ def cmd_resolve(args):
1018
1243
  "lane_id": "", "root": "", "source_path": "", "test_path": "", "root_exists": False,
1019
1244
  "build": "", "test": "", "run": "", "migrate": "",
1020
1245
  "language": "", "ecosystem": "",
1246
+ "contract_doc": "", "contract_doc_exists": False,
1021
1247
  "path": STACKS_REL,
1022
1248
  }
1023
1249
  payload.update(kind_block(args.repo, [], "backend"))
@@ -1029,12 +1255,13 @@ def cmd_resolve(args):
1029
1255
  lanes = data.get("lanes") or []
1030
1256
  match = {}
1031
1257
  # The id may arrive as a whole option line — `user_select` hands back the string it
1032
- # displayed, label and all — so strip it the way a source path is stripped.
1258
+ # displayed, label and all — so strip it the way a lane root folder is stripped.
1033
1259
  wanted = strip_root_label(args.id) if args.id else ""
1034
1260
  if wanted:
1035
1261
  match = next((l for l in lanes if l.get("id") == wanted), {})
1036
1262
  elif args.kind:
1037
1263
  match = next((l for l in lanes if l.get("kind") == args.kind), {})
1264
+ doc = contract_doc(args.repo, lanes)
1038
1265
 
1039
1266
  # `declared` is about LANES and `exists` about the file — they part company on
1040
1267
  # the shipped-but-unfilled seed, which is on disk and declares nothing.
@@ -1052,13 +1279,16 @@ def cmd_resolve(args):
1052
1279
  "source_path": match.get("root", ""),
1053
1280
  "test_path": lane_test_path(args.repo, match),
1054
1281
  "root_exists": root_on_disk(args.repo, match.get("root", "")),
1055
- "build": lane_command(match, "build"),
1056
- "test": lane_command(match, "test"),
1057
- "run": lane_command(match, "run"),
1058
- "migrate": lane_command(match, "migrate") or profile_of(args.repo, match).get("migrate", ""),
1282
+ "build": lane_command(match, "build", args.repo),
1283
+ "test": lane_command(match, "test", args.repo),
1284
+ "run": lane_command(match, "run", args.repo),
1285
+ "migrate": lane_command(match, "migrate", args.repo) or profile_of(args.repo, match).get("migrate", ""),
1059
1286
  "language": profile_of(args.repo, match).get("language", ""),
1060
1287
  "ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
1061
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)),
1062
1292
  "path": STACKS_REL,
1063
1293
  }
1064
1294
  payload.update(kind_block(args.repo, lanes, "backend"))
@@ -1077,7 +1307,7 @@ def cmd_resolve(args):
1077
1307
  # only for the kinds a caller asks for with --require, because "no frontend
1078
1308
  # lane" is the correct and common state and must never fail a backend build.
1079
1309
  #
1080
- # BROKEN — a lane IS declared and its source path is empty or is not a directory.
1310
+ # BROKEN — a lane IS declared and its lane root folder is empty or is not a directory.
1081
1311
  # Checked for EVERY declared lane whether or not the caller asked about it. A
1082
1312
  # stack named against a path that is not there is a mistake in every phase, and
1083
1313
  # letting it through means discovering it as an empty build or a generator
@@ -1106,7 +1336,7 @@ def cmd_verify(args):
1106
1336
  issues = []
1107
1337
 
1108
1338
  # An empty file and an absent one are the SAME state. The kit ships
1109
- # stacks.json holding `{"lanes": []}` so a project can fill it in by hand, so
1339
+ # stacks.json with an empty `lanes` array so a project can fill it in by hand, so
1110
1340
  # the file being there says nothing about whether a lane was declared — only
1111
1341
  # its contents do. Both cases report one issue naming which of the two it is.
1112
1342
  if not lanes:
@@ -1130,13 +1360,13 @@ def cmd_verify(args):
1130
1360
  root = lane.get("root", "")
1131
1361
  if not root:
1132
1362
  issues.append(
1133
- "lane '%s' (%s, stack %s) declares no source path. A lane owns a "
1363
+ "lane '%s' (%s, stack %s) declares no lane root folder. A lane owns a "
1134
1364
  "subtree; without one nothing can be built, run or written into it. "
1135
- "Re-run `/appweaver select-stack` and give it a source path."
1365
+ "Re-run `/appweaver select-stack` and give it a lane root folder."
1136
1366
  % (lane_id, kind, lane.get("profile", "?")))
1137
1367
  elif not args.allow_missing_root and not root_on_disk(repo, root):
1138
1368
  issues.append(
1139
- "lane '%s' (%s, stack %s) declares source path '%s', which is not a "
1369
+ "lane '%s' (%s, stack %s) declares lane root folder '%s', which is not a "
1140
1370
  "directory in this repository. Re-run `/appweaver select-stack` and "
1141
1371
  "point the lane at the directory that holds its source."
1142
1372
  % (lane_id, kind, lane.get("profile", "?"), root))
@@ -1144,7 +1374,7 @@ def cmd_verify(args):
1144
1374
  # hand-edited file — a supported way to fill this in, and the one path that
1145
1375
  # does not pass the writer. The invariant is worth a second check anywhere:
1146
1376
  # two lanes sharing a subtree means two generators can write one file. A lane
1147
- # owns its test path as well as its source path, so both are compared.
1377
+ # owns its test path as well as its lane root folder, so both are compared.
1148
1378
  for i, first in enumerate(lanes):
1149
1379
  for second in lanes[i + 1:]:
1150
1380
  clash = next(((fl, fp, sl, sp)
@@ -1154,7 +1384,7 @@ def cmd_verify(args):
1154
1384
  if clash:
1155
1385
  issues.append(
1156
1386
  "lanes '%s' and '%s' claim overlapping subtrees: %s '%s' and %s '%s'. "
1157
- "A lane owns its source path and its test path, and they must be "
1387
+ "A lane owns its lane root folder and its test path, and they must be "
1158
1388
  "disjoint from every other lane's, or two generators can write the "
1159
1389
  "same file. Give each lane paths outside the other's."
1160
1390
  % (first.get("id", "?"), second.get("id", "?"),
@@ -1191,7 +1421,7 @@ def cmd_verify(args):
1191
1421
  # ────────────────────────────── run-phase ──────────────────────────────────
1192
1422
  # Execute a phase's project-supplied command for EVERY declared lane.
1193
1423
  #
1194
- # This is the payoff of declaring a source path. Without a declaration, `build`
1424
+ # This is the payoff of declaring a lane root folder. Without a declaration, `build`
1195
1425
  # sniffs: it looks for a .sln, then for a package.json, and takes the first hit.
1196
1426
  # On a single-lane repo that is right. On a two-lane repo it is wrong in the worst
1197
1427
  # way available — it builds the backend, reports green, and never mentions that the
@@ -1256,21 +1486,21 @@ def cmd_run_phase(args):
1256
1486
  testlog = _testlog()
1257
1487
  ok, ran = True, False
1258
1488
  for lane in lanes:
1259
- cmd = lane_command(lane, args.phase)
1489
+ cmd = lane_command(lane, args.phase, repo)
1260
1490
  lane_id, root = lane.get("id", "?"), lane.get("root", "")
1261
1491
  if not cmd:
1262
1492
  results.append({"lane": lane_id, "root": root, "cmd": "",
1263
1493
  "ran": False, "ok": True, "skipped": "no %s command" % args.phase})
1264
1494
  continue
1265
- # The source path has to be there before a command is run against it.
1495
+ # The lane root folder has to be there before a command is run against it.
1266
1496
  # Reported per lane rather than raised, so a two-lane run still reports
1267
1497
  # what the other lane did.
1268
1498
  if not root_on_disk(repo, root):
1269
1499
  ok = False
1270
1500
  results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
1271
- "error": "source path '%s' is not a directory" % root})
1272
- tails.append("[%s] source path '%s' is not a directory" % (lane_id, root))
1273
- excerpts.append("[%s] source path '%s' is not a directory — nothing ran"
1501
+ "error": "lane root folder '%s' is not a directory" % root})
1502
+ tails.append("[%s] lane root folder '%s' is not a directory" % (lane_id, root))
1503
+ excerpts.append("[%s] lane root folder '%s' is not a directory — nothing ran"
1274
1504
  % (lane_id, root))
1275
1505
  failing.append(lane_id)
1276
1506
  continue
@@ -1337,6 +1567,329 @@ def cmd_run_phase(args):
1337
1567
  })
1338
1568
 
1339
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
+
1340
1893
  # ──────────────────────────────── probe ────────────────────────────────────
1341
1894
 
1342
1895
  def profile_terms(prof):
@@ -1449,14 +2002,14 @@ def main():
1449
2002
  "evidence there, so a service profile cannot relocate onto the UI's files")
1450
2003
  p.set_defaults(func=cmd_detect)
1451
2004
 
1452
- 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")
1453
2006
  p.add_argument("--profile", required=True,
1454
2007
  help="profile id, a select_options label, 'none', or 'detected' "
1455
2008
  "(a stack the kit ships no profile for — needs --stack and --root)")
1456
2009
  p.add_argument("--kind", choices=["backend", "frontend"])
1457
2010
  p.add_argument("--id", help="lane id (default: service for backend, web for frontend)")
1458
2011
  p.add_argument("--root", "--source-path", dest="root",
1459
- help="the lane's SOURCE PATH — the subtree it owns, overriding the "
2012
+ help="the LANE ROOT FOLDER — the subtree it owns, overriding the "
1460
2013
  "profile's default root. The two spellings are one field")
1461
2014
  p.add_argument("--run-from", dest="run_from", choices=["repo", "root"],
1462
2015
  help="where this lane's build/test/run commands execute: 'repo' (default) "
@@ -1466,7 +2019,7 @@ def main():
1466
2019
  "subtree (a Maven wrapper at backend/<app>/mvnw). Defaults to the "
1467
2020
  "profile's own setting")
1468
2021
  p.add_argument("--require-existing-root", dest="require_existing_root", action="store_true",
1469
- help="refuse a source path that is not a directory. For a caller about "
2022
+ help="refuse a lane root folder that is not a directory. For a caller about "
1470
2023
  "to READ the subtree (onboard); greenfield declares a path that "
1471
2024
  "implement has not created yet, and leaves this off")
1472
2025
  p.add_argument("--stack", default="",
@@ -1498,7 +2051,7 @@ def main():
1498
2051
  help="a lane kind this phase cannot run without; repeatable. Omit to "
1499
2052
  "check only that whatever IS declared is sound")
1500
2053
  p.add_argument("--allow-missing-root", dest="allow_missing_root", action="store_true",
1501
- help="do not require the source paths to exist on disk yet — the "
2054
+ help="do not require the lane root folders to exist on disk yet — the "
1502
2055
  "greenfield state, where select-stack declares a path implement "
1503
2056
  "will create")
1504
2057
  p.add_argument("--command", default="",
@@ -1506,12 +2059,36 @@ def main():
1506
2059
  p.set_defaults(func=cmd_verify)
1507
2060
 
1508
2061
  p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
1509
- p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate"],
1510
- 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")
1511
2065
  p.add_argument("--kind", choices=["backend", "frontend"],
1512
2066
  help="restrict to one lane kind (default: every declared lane)")
1513
2067
  p.set_defaults(func=cmd_run_phase)
1514
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
+
1515
2092
  p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
1516
2093
  p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
1517
2094
  p.add_argument("--hint", default="", help="free prose to scan for a stack name")