aw-spec-cli 0.1.26 → 0.1.27

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 (98) 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 +3 -3
  4. package/assets/.appweaver/STACK-PROFILES.md +188 -101
  5. package/assets/.appweaver/domain.json +1 -1
  6. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +38 -10
  7. package/assets/.appweaver/orchestrator/LAUNCHER.md +88 -0
  8. package/assets/.appweaver/orchestrator/README.md +120 -53
  9. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +1 -1
  10. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +6 -4
  11. package/assets/.appweaver/orchestrator/modules/foundation-lane.orch.yaml +237 -0
  12. package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +349 -0
  13. package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +381 -0
  14. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +4 -4
  15. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +3 -3
  16. package/assets/.appweaver/orchestrator/orchestrator.py +162 -63
  17. package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +2 -2
  18. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +2 -2
  19. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +5 -5
  20. package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +1 -1
  21. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +14 -14
  22. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +2 -2
  23. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +1 -1
  24. package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +3 -3
  25. package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +41 -3
  26. package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +1 -1
  27. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +13 -13
  28. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +3 -3
  29. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +3 -3
  30. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
  31. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +2 -2
  32. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +61 -12
  33. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +172 -293
  34. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +29 -28
  35. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +53 -53
  36. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
  37. package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +3 -3
  38. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +4 -4
  39. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +26 -2
  40. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +21 -15
  41. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +14 -14
  42. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +2 -2
  43. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +3 -3
  44. package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +6 -6
  45. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +284 -128
  46. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +224 -70
  47. package/assets/.appweaver/scripts/python/aw-foundation.py +809 -20
  48. package/assets/.appweaver/scripts/python/aw-inventory.py +28 -21
  49. package/assets/.appweaver/scripts/python/aw-plan-coverage.py +2 -2
  50. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +6 -6
  51. package/assets/.appweaver/scripts/python/aw-stack.py +195 -89
  52. package/assets/.appweaver/stack-profiles/angular.json +2 -1
  53. package/assets/.appweaver/stack-profiles/blazor-wasm.json +2 -1
  54. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +16 -3
  55. package/assets/.appweaver/stack-profiles/go-chi.json +6 -1
  56. package/assets/.appweaver/stack-profiles/java-spring.json +12 -5
  57. package/assets/.appweaver/stack-profiles/nextjs.json +2 -1
  58. package/assets/.appweaver/stack-profiles/node-express.json +8 -2
  59. package/assets/.appweaver/stack-profiles/python-fastapi.json +9 -2
  60. package/assets/.appweaver/stack-profiles/react-vite.json +2 -1
  61. package/assets/.appweaver/stack-profiles/sveltekit.json +2 -1
  62. package/assets/.appweaver/stack-profiles/vue-vite.json +2 -1
  63. package/assets/.appweaver/stacks.json +37 -1
  64. package/assets/.appweaver/templates/constitution-ui-template.md +2 -2
  65. package/assets/.claude/skills/appweaver/SKILL.md +7 -46
  66. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.rules.json +31 -0
  67. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.rules.json +16 -0
  68. package/assets/foundations/foundation.spec.template.txt +73 -0
  69. package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +1 -1
  70. package/assets/foundations/java/Hexagonal/unpack-foundation.sh +4 -5
  71. package/assets/foundations/node/NextAppRouter/NextAppRouter.spec.txt +68 -0
  72. package/assets/guardrails/README.md +31 -17
  73. package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain-rule-bindings.md +5 -5
  74. package/assets/guardrails/{projects/dotnet-default-project → project-specific}/tech-rule-bindings.md +151 -114
  75. package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +58 -4
  76. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
  77. package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
  78. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
  79. package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +1 -1
  80. package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
  81. package/assets/guardrails/technology/nextjs/api-client.md +1 -1
  82. package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
  83. package/assets/guardrails/technology/nextjs/component-conventions.md +2 -2
  84. package/assets/guardrails/technology/nextjs/design-system.md +1 -1
  85. package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
  86. package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
  87. package/assets/guardrails/technology/nextjs/ui-constitution.md +3 -3
  88. package/package.json +1 -1
  89. package/assets/.agents/skills/appweaver-demo/SKILL.md +0 -59
  90. package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +0 -6
  91. package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +0 -100
  92. package/assets/.claude/skills/appweaver-demo/SKILL.md +0 -59
  93. package/assets/.kilocode/agent.json +0 -28
  94. package/assets/.kilocode/workflows/appweaver-demo.md +0 -59
  95. package/assets/.kilocode/workflows/appweaver.md +0 -55
  96. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain/domain-requirements.md +0 -0
  97. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/frontend/ui-requirements.md +0 -0
  98. /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/service/technical-requirements.md +0 -0
@@ -21,23 +21,26 @@ stays optional in the schema for a future caller that proposes a lane without a
21
21
  person in the loop; a reader that branches on it keeps working either way.
22
22
 
23
23
  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
24
+ and it is supported: the kit ships the file with an empty `lanes` array for exactly
25
+ that, plus a `_lane_format` key holding one sample lane of each kind with every field
26
+ 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
27
  `verify` re-checks the roots-disjoint invariant rather than trusting that
27
28
  `declare` already did. `resolve` can never write, which is why these are two
28
29
  subcommands and not one with a mode flag.
29
30
 
30
- A lane's `root` IS its source path — the subtree the lane owns and the only place
31
+ A lane's `root` IS the lane root folder — the subtree the lane owns and the only place
31
32
  its implement phase may write. The two words name one field, and `--source-path`
32
33
  is accepted wherever `--root` is.
33
34
 
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.
35
+ WHERE THE COMMANDS RUN is a separate question, because the answer can be a different
36
+ directory: a classic .NET repository keeps its solution file at the top while the
37
+ lane owns `src/`. `run_from` carries that answer — "repo" (the default, and the shape
38
+ of every lane written before the key existed) or "root". A stack whose build file
39
+ lives INSIDE its subtree needs "root" and cannot work without it: a Maven wrapper at
40
+ backend/<app>/mvnw is unreachable from the repository root. A NEW project is one
41
+ self-contained folder per lane — every profile defaults to `backend/` or `frontend/`
42
+ with `run_from: "root"` — while `declare` answers for code that already exists from
43
+ where its build file actually sits (see `build_files_at`).
41
44
 
42
45
  WHERE THE PATH IS CHECKED AGAINST THE DISK. `declare` records a path; it does not
43
46
  require the path to exist, because in a greenfield project it does not exist yet —
@@ -270,6 +273,11 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
270
273
  files_for_prof = scoped if prof.get("kind") != "frontend" else files
271
274
  evidence, matched, anchored_hits = [], 0, 0
272
275
  prof_root = norm_root(prof.get("root") or "")
276
+ # The `detect` patterns are written against the layout an EXISTING repository of
277
+ # this stack usually has (`src/**/*.csproj`, `web/next.config.*`), which is the
278
+ # profile's `conventional_root` — not its `root`, the folder a NEW project gets.
279
+ conv_root = norm_root(prof.get("conventional_root") or "")
280
+ bases = [b for b in (prof_root, conv_root) if b and b != REPO_ROOT]
273
281
  for pat in patterns:
274
282
  rx = _glob_rx(pat)
275
283
  rx_any = _glob_rx("**/" + pat)
@@ -278,8 +286,8 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
278
286
  anchored_hits += 1
279
287
  else:
280
288
  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:
289
+ base = next((b for b in bases if pat.startswith(b)), "")
290
+ if not hits and base and os.path.basename(pat) not in ambiguous:
283
291
  # RELOCATED, with the profile's own root segment substituted rather than
284
292
  # prefixed. Prefixing alone only ever finds the profile's own folder name
285
293
  # deeper in the tree — `apps/portal/web/angular.json` — so a UI in
@@ -288,7 +296,7 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
288
296
  # Restricted to names only one kind claims, so substitution widens where a
289
297
  # stack may live without widening what counts as evidence for it.
290
298
  hits = [f for f in files_for_prof
291
- if _glob_rx("**/" + pat[len(prof_root):]).match(f)]
299
+ if _glob_rx("**/" + pat[len(base):]).match(f)]
292
300
  if not hits:
293
301
  continue
294
302
  matched += 1
@@ -298,20 +306,21 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
298
306
  dirs = sorted({os.path.dirname(f) for f in evidence}, key=lambda d: (d.count("/"), len(d)))
299
307
  # WHERE THE SOURCE LIVES, which is not always where the evidence sits. A manifest
300
308
  # 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
309
+ # not the subtree: the code it describes is under one of the profile's roots. So
310
+ # repo-root evidence defers to that root when the directory is really there, and only
303
311
  # falls back to the repo root itself when it is not, which is the case for the
304
312
  # single-package layouts (`create-next-app`, a UI checked out at the top level)
305
- # whose files genuinely are at the top.
313
+ # whose files genuinely are at the top. The conventional root is tried first:
314
+ # repo-root evidence (`App.sln`) is the classic layout, whose code sits under
315
+ # `src/` — not under the `backend/` a new project would get.
306
316
  prof_default = norm_root(prof.get("root") or "")
307
317
  if not dirs:
308
318
  root = prof_default
309
319
  elif dirs[0]:
310
320
  root = norm_root(dirs[0])
311
- elif prof_default and os.path.isdir(os.path.join(repo, prof_default)):
312
- root = prof_default
313
321
  else:
314
- root = REPO_ROOT
322
+ root = next((r for r in (conv_root, prof_default)
323
+ if r and os.path.isdir(os.path.join(repo, r))), REPO_ROOT)
315
324
  out.append({
316
325
  "id": prof["id"],
317
326
  "kind": prof.get("kind", ""),
@@ -404,7 +413,7 @@ def cmd_list(args):
404
413
  repository looks like. Two subcommands would walk the file tree twice and add
405
414
  two steps to a workflow whose whole value is being short. One call returns the
406
415
  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.
416
+ in — which is the lane root folder the operator is about to be asked to confirm.
408
417
 
409
418
  HOW A DETECTED STACK IS PROPOSED, and why it is not preselected. Detection
410
419
  moves its match to the FRONT of the option list; it never fills `preselect`.
@@ -448,10 +457,10 @@ def cmd_list(args):
448
457
  preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
449
458
  preselect_source = "argument" if preselect else ""
450
459
 
451
- # Candidate SOURCE PATHS for the follow-up question, best first. Labelled with why
460
+ # Candidate LANE ROOT FOLDERS for the follow-up question, best first. Labelled with why
452
461
  # each is offered, because the operator picking from this list is choosing the
453
462
  # 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.
463
+ # accepted — a repo that matches no profile still has a lane root folder.
455
464
  #
456
465
  # WITH --profile the stack is already chosen, and the list answers for it alone:
457
466
  # where THAT stack's evidence sits, then its own default, then where another
@@ -460,7 +469,7 @@ def cmd_list(args):
460
469
  # operator did not choose.
461
470
  #
462
471
  # 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
472
+ # usually shared (every frontend profile defaults to frontend/), so its label names every
464
473
  # profile that proposes it rather than whichever one sorts first.
465
474
  chosen = None
466
475
  if args.profile:
@@ -541,16 +550,16 @@ def strip_root_label(value):
541
550
  return (value or "").split(ROOT_LABEL_SEP, 1)[0].strip()
542
551
 
543
552
 
544
- # The repository root as a source path. A single-module project — a Maven, Cargo, Go
553
+ # The repository root as a lane root folder. A single-module project — a Maven, Cargo, Go
545
554
  # 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
555
+ # empty string cannot carry it: `verify` reads an empty root as "no lane root folder
547
556
  # declared". "./" is the one spelling that survives being normalised twice, which is
548
557
  # what makes it usable as a STORED value rather than only as typed input.
549
558
  REPO_ROOT = "./"
550
559
 
551
560
 
552
561
  def norm_root(root):
553
- """Canonicalise a source path, IDEMPOTENTLY.
562
+ """Canonicalise a lane root folder, IDEMPOTENTLY.
554
563
 
555
564
  Idempotence is a correctness requirement, not tidiness: `declare` normalises what it
556
565
  writes and `verify` normalises what it reads, so a value that changes on the second
@@ -575,7 +584,7 @@ def norm_root(root):
575
584
 
576
585
 
577
586
  def root_on_disk(repo, root):
578
- """Does the lane's source path exist as a directory?
587
+ """Does the lane root folder exist as a directory?
579
588
 
580
589
  Reported everywhere a root is reported and enforced only by `verify`. A false
581
590
  here is an ordinary state before any code is written and a hard error once a
@@ -588,14 +597,15 @@ def root_on_disk(repo, root):
588
597
  def lane_workdir(repo, lane):
589
598
  """The directory a lane's commands run in.
590
599
 
591
- `run_from: "root"` runs them in the lane's own source path; anything else — including
600
+ `run_from: "root"` runs them in the lane root folder; anything else — including
592
601
  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.
602
+ existed — runs them at the repository root. That default is not laziness: in a
603
+ classic repository a .NET solution file, a go.mod or a package.json at the top is what
604
+ `dotnet build`, `go build ./...` and `npm run build` need to find, while the lane is
605
+ the SOURCE directory under it. The two coincide for a stack whose build file lives
606
+ inside the subtree — a Maven wrapper at backend/orderservice/mvnw cannot be reached
607
+ from the repo root at all — and for every new project, which is one self-contained
608
+ folder per lane.
599
609
 
600
610
  Falls back to the repo root when the declared root is not on disk, so the phase
601
611
  reports the command's own failure instead of a chdir error.
@@ -608,27 +618,108 @@ def lane_workdir(repo, lane):
608
618
  return repo
609
619
 
610
620
 
611
- def lane_command(lane, phase):
612
- """The command for one phase, with `{{root}}` standing for the lane's root.
621
+ def build_files_at(repo, root, build_files):
622
+ """Where a stack's build file already sits for a lane at `root`: "root" when one is
623
+ directly inside the lane root folder, "repo" when one is only at the repository root,
624
+ "" when neither — nothing is on disk yet, so the caller falls back to the profile.
625
+
626
+ This is what lets one profile serve both kinds of repository. A new project is one
627
+ self-contained folder per lane (`backend/api.sln`), and its commands run inside it; a
628
+ classic .NET repository keeps `api.sln` at the top with the code under `src/`, and its
629
+ commands have to run at the top. Reading the answer off the disk at declare time gives
630
+ each its own, where a fixed profile default would break one of them.
631
+ """
632
+ if not build_files:
633
+ return ""
634
+ r = norm_root(root)
635
+ lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
636
+
637
+ def has(d):
638
+ return any(os.path.isfile(p) for pat in build_files
639
+ for p in glob.glob(os.path.join(d, pat)))
640
+
641
+ if os.path.isdir(lane_dir) and has(lane_dir):
642
+ return "root"
643
+ if has(repo):
644
+ return "repo"
645
+ return ""
646
+
647
+
648
+ # Folders a runnable project is never looked for in: build output, dependencies, tests.
649
+ HOST_SKIP_DIRS = {"bin", "obj", "node_modules", ".git", ".vs", "packages", "tests", "test"}
613
650
 
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.
651
+
652
+ def lane_host(repo, lane):
653
+ """The lane's runnable project folder, which `{{host}}` in a command stands for.
654
+
655
+ A lane root folder often holds several projects and only one of them is the thing to
656
+ run — a .NET solution's Aspire AppHost, or its API when there is no AppHost. The
657
+ profile's `host_patterns` say where that project sits, best first, as paths relative
658
+ to the lane root folder (`**/` for any depth, case ignored). The first pattern that
659
+ matches exactly ONE file names the project; its folder is returned as seen from where
660
+ the lane's commands run. None when the profile has no patterns, the lane root folder is
661
+ not on disk, or no pattern has a single match — the caller then uses the lane root folder.
662
+ """
663
+ if not repo:
664
+ return None
665
+ patterns = [p.lower() for p in profile_of(repo, lane).get("host_patterns") or []]
666
+ r = norm_root(lane.get("root"))
667
+ lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
668
+ if not patterns or not os.path.isdir(lane_dir):
669
+ return None
670
+ leaves = {p.rsplit("/", 1)[-1] for p in patterns}
671
+ files = []
672
+ for dirpath, dirnames, filenames in os.walk(lane_dir):
673
+ dirnames[:] = [d for d in dirnames if d.lower() not in HOST_SKIP_DIRS]
674
+ for name in filenames:
675
+ if any(fnmatch.fnmatchcase(name.lower(), leaf) for leaf in leaves):
676
+ rel = os.path.relpath(os.path.join(dirpath, name), lane_dir).replace("\\", "/")
677
+ files.append(rel)
678
+ for pat in patterns:
679
+ bare = pat[3:] if pat.startswith("**/") else pat
680
+ hits = [f for f in files
681
+ if fnmatch.fnmatchcase(f.lower(), pat) or fnmatch.fnmatchcase(f.lower(), bare)]
682
+ if len(hits) == 1:
683
+ project_dir = os.path.join(lane_dir, os.path.dirname(hits[0]))
684
+ rel = os.path.relpath(project_dir, lane_workdir(repo, lane)).replace("\\", "/")
685
+ return rel
686
+ return None
687
+
688
+
689
+ def lane_command(lane, phase, repo=None):
690
+ """The command for one phase, with `{{root}}` standing for the lane root folder and
691
+ `{{host}}` for the lane's runnable project inside it (see lane_host).
692
+
693
+ A profile command sometimes has to NAME the lane root folder — `python -m
694
+ compileall app`. Written literally, that is the profile's default root frozen into
695
+ the command, and it stays frozen when the lane declares a different one: a service
696
+ at `Services/Api/` compiles the code at `app/`, or fails looking for it. The
697
+ placeholder lets the profile say "the root this lane declares" and be right on
698
+ every layout.
620
699
 
621
700
  Expanded at RUN time, not at declare time, so editing `root` in stacks.json by
622
701
  hand keeps the command consistent with it. A command with no placeholder is
623
702
  returned untouched, so every literal command already on disk is unaffected.
703
+
704
+ The root is named as seen from where the command RUNS: a `run_from: "root"` lane
705
+ runs inside its root, so there `{{root}}` is `.` — the repository-relative path
706
+ would point at `backend/backend`.
707
+
708
+ `{{host}}` exists because the lane root folder is not always runnable: `dotnet run
709
+ --project <folder>` needs exactly one project in that folder, and a solution has
710
+ several. It is looked up when the command is read, so a project scaffolded after the
711
+ lane was declared is found; with no single match it falls back to `{{root}}`.
624
712
  """
625
713
  cmd = (lane.get(phase) or "").strip()
714
+ if "{{host}}" in cmd:
715
+ cmd = cmd.replace("{{host}}", lane_host(repo, lane) or "{{root}}")
626
716
  if "{{root}}" not in cmd:
627
717
  return cmd
628
718
  r = norm_root(lane.get("root"))
629
719
  # `./` is the repo root: a bare `./` reads as noise in a command line, and the
630
720
  # 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("/")
721
+ runs_inside = (lane.get("run_from") or "repo") == "root"
722
+ sub = "." if (r == REPO_ROOT or not r or runs_inside) else r.rstrip("/")
632
723
  return cmd.replace("{{root}}", sub)
633
724
 
634
725
 
@@ -654,7 +745,7 @@ def lane_test_path(repo, lane, prof=None):
654
745
 
655
746
  Most backend stacks keep tests BESIDE the source rather than in it: .NET's `tests/`
656
747
  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.
748
+ next to `app/`. A lane that owned only its lane root folder could not hold its own tests.
658
749
 
659
750
  Answered at READ time, like `ecosystem`, so a lane declared before this existed has
660
751
  one without being re-declared:
@@ -662,8 +753,11 @@ def lane_test_path(repo, lane, prof=None):
662
753
  does not describe;
663
754
  - else the profile's `test_root`, which pairs with the profile's default `root`. A
664
755
  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
756
+ the same prefix (`apps/backend/` ⇒ `apps/backend/tests/`);
757
+ - else the profile's `conventional_test_root`, which pairs the same way with its
758
+ `conventional_root` — the classic layout an existing repository has (`src/` ⇒
759
+ `tests/`), so a lane declared at it keeps the test path it always had;
760
+ - else the tests live inside the lane root folder. That is right for every frontend
667
761
  profile, for Go's colocated `_test.go` files, and for a Maven lane rooted at the
668
762
  directory holding its pom.xml.
669
763
  """
@@ -676,24 +770,25 @@ def lane_test_path(repo, lane, prof=None):
676
770
  if own:
677
771
  return own
678
772
  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
773
+ for key_root, key_tests in (("root", "test_root"),
774
+ ("conventional_root", "conventional_test_root")):
775
+ base, tests = norm_root(prof.get(key_root)), norm_root(prof.get(key_tests))
776
+ if not base or not tests or REPO_ROOT in (base, tests):
777
+ continue
778
+ if root == base:
779
+ return tests
780
+ if root.endswith("/" + base):
781
+ return root[: -len(base)] + tests
687
782
  return root
688
783
 
689
784
 
690
785
  def lane_owned_paths(repo, lane, prof=None):
691
- """Every subtree a lane owns, labelled: its source path, and its test path when that
786
+ """Every subtree a lane owns, labelled: its lane root folder, and its test path when that
692
787
  is a different directory. Ownership checks compare these, so a test path can never
693
788
  land inside another lane's subtree."""
694
789
  root = norm_root(lane.get("root"))
695
790
  tests = lane_test_path(repo, lane, prof)
696
- owned = [("source path", root)] if root else []
791
+ owned = [("lane root folder", root)] if root else []
697
792
  if tests and tests != root:
698
793
  owned.append(("test path", tests))
699
794
  return owned
@@ -768,7 +863,7 @@ def cmd_declare(args):
768
863
  lane_id = args.id or DEFAULT_LANE_ID.get(kind, kind)
769
864
  root = norm_root(args.root or (prof or {}).get("root") or "")
770
865
  if not root:
771
- fail("lane-declare: lane '%s' has no source path. A lane without a root owns "
866
+ fail("lane-declare: lane '%s' has no lane root folder. A lane without a root owns "
772
867
  "nothing, so nothing can be built, run or written into it. Pass "
773
868
  "--source-path <dir> (the profile declares no default)." % lane_id)
774
869
 
@@ -777,7 +872,7 @@ def cmd_declare(args):
777
872
  # is about to read the code out of it.
778
873
  exists = root_on_disk(repo, root)
779
874
  if args.require_existing_root and not exists:
780
- fail("lane-declare: source path '%s' for lane '%s' does not exist under %s. "
875
+ fail("lane-declare: lane root folder '%s' for lane '%s' does not exist under %s. "
781
876
  "Point the lane at the directory that actually holds this lane's source, "
782
877
  "or re-run /appweaver select-stack to correct it."
783
878
  % (root, lane_id, repo))
@@ -802,8 +897,12 @@ def cmd_declare(args):
802
897
  lane["migrate"] = migrate
803
898
  # WHERE this lane's commands run — see lane_workdir. Recorded only when it is not
804
899
  # 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", "")
900
+ # other answer. An explicit --run-from wins; else the stack's build file decides when
901
+ # it is on disk (build_files_at); else — a greenfield lane, nothing written yet — the
902
+ # profile's own answer, which for a new project is inside the lane.
903
+ run_from = (getattr(args, "run_from", "")
904
+ or build_files_at(repo, root, (prof or {}).get("build_files") or [])
905
+ or (prof or {}).get("run_from", ""))
807
906
  if run_from == "root":
808
907
  lane["run_from"] = "root"
809
908
  # What the code was found to be, when no shipped profile names it.
@@ -824,7 +923,7 @@ def cmd_declare(args):
824
923
  for theirs_label, theirs in lane_owned_paths(repo, other):
825
924
  if roots_overlap(mine, theirs):
826
925
  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 "
926
+ "A lane owns its lane root folder and its test path, and no two lanes "
828
927
  "may share a subtree."
829
928
  % (mine_label, mine, lane_id, other.get("id"), theirs_label, theirs))
830
929
 
@@ -893,7 +992,7 @@ def profile_of(repo, lane):
893
992
  def kind_block(repo, lanes, kind):
894
993
  """Every field a caller needs about ONE kind, flattened and prefixed.
895
994
 
896
- A workflow that wants the service source path binds `$.backend_source_path`
995
+ A workflow that wants the service lane root folder binds `$.backend_source_path`
897
996
  and is done — no `--kind` argument, no second call, and no branch on which
898
997
  lane came back. Both kinds are always present in the payload, so the two-lane
899
998
  steps (a gate that needs backend, a report that names both) resolve one script
@@ -927,14 +1026,21 @@ def kind_block(repo, lanes, kind):
927
1026
  kind + "_root": root,
928
1027
  kind + "_source_path": root,
929
1028
  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
1029
+ # Where the lane's paired tests go. Equal to the lane root folder when the stack keeps
931
1030
  # its tests inside it; see lane_test_path.
932
1031
  kind + "_test_path": test_path,
933
1032
  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", ""),
1033
+ # Where the lane's commands run, as DECLARED — `./` for the repository root.
1034
+ # Read from the declaration rather than from lane_workdir, which falls back to the
1035
+ # repo root while the lane root folder is not on disk yet; a phase that creates the
1036
+ # project has to be told where its build will run before that path exists.
1037
+ kind + "_workdir": (norm_root(root) or REPO_ROOT)
1038
+ if lane and (lane.get("run_from") or "repo") == "root"
1039
+ else (REPO_ROOT if lane else ""),
1040
+ kind + "_build": lane_command(lane, "build", repo),
1041
+ kind + "_test": lane_command(lane, "test", repo),
1042
+ kind + "_run": lane_command(lane, "run", repo),
1043
+ kind + "_migrate": lane_command(lane, "migrate", repo) or prof.get("migrate", ""),
938
1044
  }
939
1045
 
940
1046
 
@@ -956,7 +1062,7 @@ def lane_options(repo, lanes):
956
1062
  title = (prof.get("title", "") or lane.get("stack", "")
957
1063
  or lane.get("profile", "") or "no stack declared")
958
1064
  root = norm_root(lane.get("root"))
959
- where = "the repo root" if root == REPO_ROOT else (root or "no source path")
1065
+ where = "the repo root" if root == REPO_ROOT else (root or "no lane root folder")
960
1066
  label = "%s %s at %s" % (lane.get("kind", "lane"), title, where)
961
1067
  tests = lane_test_path(repo, lane, prof)
962
1068
  if tests and tests != root:
@@ -1029,7 +1135,7 @@ def cmd_resolve(args):
1029
1135
  lanes = data.get("lanes") or []
1030
1136
  match = {}
1031
1137
  # 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.
1138
+ # displayed, label and all — so strip it the way a lane root folder is stripped.
1033
1139
  wanted = strip_root_label(args.id) if args.id else ""
1034
1140
  if wanted:
1035
1141
  match = next((l for l in lanes if l.get("id") == wanted), {})
@@ -1052,10 +1158,10 @@ def cmd_resolve(args):
1052
1158
  "source_path": match.get("root", ""),
1053
1159
  "test_path": lane_test_path(args.repo, match),
1054
1160
  "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", ""),
1161
+ "build": lane_command(match, "build", args.repo),
1162
+ "test": lane_command(match, "test", args.repo),
1163
+ "run": lane_command(match, "run", args.repo),
1164
+ "migrate": lane_command(match, "migrate", args.repo) or profile_of(args.repo, match).get("migrate", ""),
1059
1165
  "language": profile_of(args.repo, match).get("language", ""),
1060
1166
  "ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
1061
1167
  "prototype": data.get("prototype", {}),
@@ -1077,7 +1183,7 @@ def cmd_resolve(args):
1077
1183
  # only for the kinds a caller asks for with --require, because "no frontend
1078
1184
  # lane" is the correct and common state and must never fail a backend build.
1079
1185
  #
1080
- # BROKEN — a lane IS declared and its source path is empty or is not a directory.
1186
+ # BROKEN — a lane IS declared and its lane root folder is empty or is not a directory.
1081
1187
  # Checked for EVERY declared lane whether or not the caller asked about it. A
1082
1188
  # stack named against a path that is not there is a mistake in every phase, and
1083
1189
  # letting it through means discovering it as an empty build or a generator
@@ -1106,7 +1212,7 @@ def cmd_verify(args):
1106
1212
  issues = []
1107
1213
 
1108
1214
  # 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
1215
+ # stacks.json with an empty `lanes` array so a project can fill it in by hand, so
1110
1216
  # the file being there says nothing about whether a lane was declared — only
1111
1217
  # its contents do. Both cases report one issue naming which of the two it is.
1112
1218
  if not lanes:
@@ -1130,13 +1236,13 @@ def cmd_verify(args):
1130
1236
  root = lane.get("root", "")
1131
1237
  if not root:
1132
1238
  issues.append(
1133
- "lane '%s' (%s, stack %s) declares no source path. A lane owns a "
1239
+ "lane '%s' (%s, stack %s) declares no lane root folder. A lane owns a "
1134
1240
  "subtree; without one nothing can be built, run or written into it. "
1135
- "Re-run `/appweaver select-stack` and give it a source path."
1241
+ "Re-run `/appweaver select-stack` and give it a lane root folder."
1136
1242
  % (lane_id, kind, lane.get("profile", "?")))
1137
1243
  elif not args.allow_missing_root and not root_on_disk(repo, root):
1138
1244
  issues.append(
1139
- "lane '%s' (%s, stack %s) declares source path '%s', which is not a "
1245
+ "lane '%s' (%s, stack %s) declares lane root folder '%s', which is not a "
1140
1246
  "directory in this repository. Re-run `/appweaver select-stack` and "
1141
1247
  "point the lane at the directory that holds its source."
1142
1248
  % (lane_id, kind, lane.get("profile", "?"), root))
@@ -1144,7 +1250,7 @@ def cmd_verify(args):
1144
1250
  # hand-edited file — a supported way to fill this in, and the one path that
1145
1251
  # does not pass the writer. The invariant is worth a second check anywhere:
1146
1252
  # 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.
1253
+ # owns its test path as well as its lane root folder, so both are compared.
1148
1254
  for i, first in enumerate(lanes):
1149
1255
  for second in lanes[i + 1:]:
1150
1256
  clash = next(((fl, fp, sl, sp)
@@ -1154,7 +1260,7 @@ def cmd_verify(args):
1154
1260
  if clash:
1155
1261
  issues.append(
1156
1262
  "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 "
1263
+ "A lane owns its lane root folder and its test path, and they must be "
1158
1264
  "disjoint from every other lane's, or two generators can write the "
1159
1265
  "same file. Give each lane paths outside the other's."
1160
1266
  % (first.get("id", "?"), second.get("id", "?"),
@@ -1191,7 +1297,7 @@ def cmd_verify(args):
1191
1297
  # ────────────────────────────── run-phase ──────────────────────────────────
1192
1298
  # Execute a phase's project-supplied command for EVERY declared lane.
1193
1299
  #
1194
- # This is the payoff of declaring a source path. Without a declaration, `build`
1300
+ # This is the payoff of declaring a lane root folder. Without a declaration, `build`
1195
1301
  # sniffs: it looks for a .sln, then for a package.json, and takes the first hit.
1196
1302
  # On a single-lane repo that is right. On a two-lane repo it is wrong in the worst
1197
1303
  # way available — it builds the backend, reports green, and never mentions that the
@@ -1256,21 +1362,21 @@ def cmd_run_phase(args):
1256
1362
  testlog = _testlog()
1257
1363
  ok, ran = True, False
1258
1364
  for lane in lanes:
1259
- cmd = lane_command(lane, args.phase)
1365
+ cmd = lane_command(lane, args.phase, repo)
1260
1366
  lane_id, root = lane.get("id", "?"), lane.get("root", "")
1261
1367
  if not cmd:
1262
1368
  results.append({"lane": lane_id, "root": root, "cmd": "",
1263
1369
  "ran": False, "ok": True, "skipped": "no %s command" % args.phase})
1264
1370
  continue
1265
- # The source path has to be there before a command is run against it.
1371
+ # The lane root folder has to be there before a command is run against it.
1266
1372
  # Reported per lane rather than raised, so a two-lane run still reports
1267
1373
  # what the other lane did.
1268
1374
  if not root_on_disk(repo, root):
1269
1375
  ok = False
1270
1376
  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"
1377
+ "error": "lane root folder '%s' is not a directory" % root})
1378
+ tails.append("[%s] lane root folder '%s' is not a directory" % (lane_id, root))
1379
+ excerpts.append("[%s] lane root folder '%s' is not a directory — nothing ran"
1274
1380
  % (lane_id, root))
1275
1381
  failing.append(lane_id)
1276
1382
  continue
@@ -1456,7 +1562,7 @@ def main():
1456
1562
  p.add_argument("--kind", choices=["backend", "frontend"])
1457
1563
  p.add_argument("--id", help="lane id (default: service for backend, web for frontend)")
1458
1564
  p.add_argument("--root", "--source-path", dest="root",
1459
- help="the lane's SOURCE PATH — the subtree it owns, overriding the "
1565
+ help="the LANE ROOT FOLDER — the subtree it owns, overriding the "
1460
1566
  "profile's default root. The two spellings are one field")
1461
1567
  p.add_argument("--run-from", dest="run_from", choices=["repo", "root"],
1462
1568
  help="where this lane's build/test/run commands execute: 'repo' (default) "
@@ -1466,7 +1572,7 @@ def main():
1466
1572
  "subtree (a Maven wrapper at backend/<app>/mvnw). Defaults to the "
1467
1573
  "profile's own setting")
1468
1574
  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 "
1575
+ help="refuse a lane root folder that is not a directory. For a caller about "
1470
1576
  "to READ the subtree (onboard); greenfield declares a path that "
1471
1577
  "implement has not created yet, and leaves this off")
1472
1578
  p.add_argument("--stack", default="",
@@ -1498,7 +1604,7 @@ def main():
1498
1604
  help="a lane kind this phase cannot run without; repeatable. Omit to "
1499
1605
  "check only that whatever IS declared is sound")
1500
1606
  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 "
1607
+ help="do not require the lane root folders to exist on disk yet — the "
1502
1608
  "greenfield state, where select-stack declares a path implement "
1503
1609
  "will create")
1504
1610
  p.add_argument("--command", default="",
@@ -4,8 +4,9 @@
4
4
  "title": "Angular",
5
5
  "language": "TypeScript",
6
6
  "ecosystem": "node",
7
- "root": "web/",
7
+ "root": "frontend/",
8
8
  "run_from": "root",
9
+ "conventional_root": "web/",
9
10
  "build": "npm run build",
10
11
  "test": "npm test",
11
12
  "run": "npm start",
@@ -4,8 +4,9 @@
4
4
  "title": "Blazor WebAssembly",
5
5
  "language": "C#",
6
6
  "ecosystem": "dotnet",
7
- "root": "web/",
7
+ "root": "frontend/",
8
8
  "run_from": "root",
9
+ "conventional_root": "web/",
9
10
  "build": "dotnet build",
10
11
  "test": "dotnet test",
11
12
  "run": "dotnet run",
@@ -4,11 +4,24 @@
4
4
  "title": "ASP.NET Core Web API",
5
5
  "language": "C#",
6
6
  "ecosystem": "dotnet",
7
- "root": "src/",
8
- "test_root": "tests/",
7
+ "root": "backend/",
8
+ "test_root": "backend/tests/",
9
+ "run_from": "root",
10
+ "conventional_root": "src/",
11
+ "conventional_test_root": "tests/",
12
+ "build_files": [
13
+ "*.sln",
14
+ "*.slnx",
15
+ "*.csproj"
16
+ ],
9
17
  "build": "dotnet build",
10
18
  "test": "dotnet test",
11
- "run": "dotnet run --project {{root}}",
19
+ "run": "dotnet run --project {{host}}",
20
+ "host_patterns": [
21
+ "**/*.AppHost.csproj",
22
+ "**/*.Api.csproj",
23
+ "**/*.Service.csproj"
24
+ ],
12
25
  "detect": [
13
26
  "*.sln",
14
27
  "src/**/*.csproj"