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
@@ -3,35 +3,99 @@
3
3
  aw-foundation — the part of scaffolding that depends on the LANE MODEL.
4
4
 
5
5
  A foundation pack is a tree of files and an unpacker that writes it under one output
6
- directory. That output directory is now a LANE'S SOURCE PATH, so everything a pack
7
- carries lands inside the subtree its lane owns, and nothing a scaffold produces can
8
- appear outside a declared lane.
9
-
10
- A lane owns TWO subtrees, though — its source path and its test path — and for most
11
- backend stacks they are siblings rather than nested: .NET keeps `tests/` beside `src/`,
12
- not inside it. A pack that carries its own test project therefore has one directory
13
- that does NOT belong under the source path, and unpacking it verbatim would bury the
14
- tests at `src/tests/`, where the lane does not own them, `dotnet test` does not look for
15
- them, and every later phase that resolves the lane's test path finds nothing.
16
-
17
- So the test tree is LIFTED: whatever the pack placed at `<source path>/<test dir>` is
18
- merged into the lane's declared test path. The directory name comes from the lane's own
19
- test path rather than from a list here, which is what makes a lane with a custom
20
- `test_root` work and a stack whose tests live inside its source a no-op — the two paths
21
- are then the same subtree and there is nothing to lift.
6
+ directory. That output directory is the LANE ROOT FOLDER, and the tree
7
+ lands there exactly as it is packed — build files, source and tests together — so
8
+ nothing a scaffold produces can appear outside a declared lane. The lane runs its
9
+ commands inside that folder and keeps its tests inside it (`lane-files` reports a lane
10
+ that does not, and foundation does not scaffold into it).
11
+
12
+ `redirect-tests` LIFTS a test tree for a lane whose test path lies OUTSIDE its lane root
13
+ folder: whatever the pack placed at `<lane root folder>/<test dir>` is merged into the lane's
14
+ declared test path. The directory name comes from the lane's own test path rather than
15
+ from a list here. For every lane foundation scaffolds the test path is inside the lane
16
+ root folder, so it reports that the pack's layout is already right and moves nothing.
17
+
18
+ It also answers WHAT a lane can be scaffolded from. A directory under
19
+ foundations/<ecosystem>/ offers two kinds of entry:
20
+
21
+ pack <Name>/<Name>.pack.txt a verified, byte-for-byte project, unpacked as-is
22
+ spec <Name>/<Stem>.spec.txt a description of how to build the project, which an
23
+ agent follows in the lane (one entry per spec file)
24
+
25
+ Either file may open with `# key: value` header lines. Three keys narrow the entry to the
26
+ lanes it fits, because an ecosystem is shared by stacks that cannot use each other's
27
+ scaffold — Next.js, Angular and the Express backend are all `node`, and Blazor and the
28
+ ASP.NET service are both `dotnet`:
29
+
30
+ # kind: backend|frontend absent = backend, which is what every pack shipped before
31
+ the key existed scaffolds
32
+ # profiles: nextjs, ... stack-profile ids; absent = any profile of that kind
33
+ # title: <one line> the label shown in the pick list
34
+
35
+ A pack may have a RULE FILE beside it, `<pack name>.rules.json` (`VerticalSlice.rules.json`
36
+ for `VerticalSlice.pack.txt`, `VerticalSlice.Reference.rules.json` for its reference sample).
37
+ Every file in a pack is unpacked into the lane root folder as packed, and one that is already
38
+ there is replaced; the rule file names the files and folders that are treated otherwise:
39
+
40
+ {"format": "aw-spec-pack-rules/v1",
41
+ "rules": [{"path": "src/<Sentinel>.Api/AppDbContext.cs", "action": "merge",
42
+ "instruction": "how to combine the existing file with the pack's"},
43
+ {"path": "src/<Sentinel>.Api/Features/", "action": "replace"}]}
44
+
45
+ A path is written as the pack holds it, sentinel and all, and is renamed the way the pack's
46
+ files are. One ending in `/` covers every pack file under that folder; a rule for the file
47
+ itself wins over its folder's. `instruction` may be a string or a list of lines, and a
48
+ `note` on a rule is for the reader only. `rules` works out, before the unpack, which pack
49
+ files land on a file already in the lane and are to be merged, and puts those existing
50
+ files aside under the state folder so the merge can read them once the pack's version is on
51
+ disk; `rules-done` removes them afterwards.
22
52
 
23
53
  Called as a CLI by both the bash and the PowerShell actions, so the two answer from one
24
54
  implementation rather than from two that have to be kept in step.
25
55
 
26
56
  Usage:
27
- aw-foundation.py redirect-tests --out <lane source path> --test-path <lane test path>
57
+ aw-foundation.py redirect-tests --out <lane root folder> --test-path <lane test path>
28
58
  [--repo <root>] [--force]
29
59
  -> {"moved": n, "conflicts": [...], "from": "...", "to": "...", "message": "..."}
60
+ aw-foundation.py list [--kind backend|frontend] [--profile <id>] [--ecosystem <e>]...
61
+ [--args "<Foundation> <App>"] [--lane-id <id>]
62
+ -> {"options": [...], "entries": [...], "count": n, "foundations": [...],
63
+ "preselect_option": "...", "preselect_app": "...", "rejected_out": "...", ...}
64
+ aw-foundation.py choice --selection <option|name|n> [same filters as list]
65
+ -> {"ok": bool, "route": "pack|spec|manual|skip", "name": "...", "file": "...", ...}
66
+ aw-foundation.py spec-capture --lane <id> [--kind <k>] (--file <path> | --input <path|text>)
67
+ [--app <App>]
68
+ -> {"ok": bool, "spec_path": ".appweaver/foundation/<id>.spec.md", "source": "...",
69
+ "contract_run": "...", "contract": "...", "client": "...", "settings_text": "...", ...}
70
+ aw-foundation.py lane-files --source <path> [--test <path>] [--workdir <path>] [--profile <id>]
71
+ -> {"has_files": bool, "count": n, "sample": [...], "generated": [...],
72
+ "root_files": [...], "folders": [...], "workdir": "...", "summary": "...",
73
+ "self_contained": bool, "shape_reason": "..."}
74
+ aw-foundation.py snapshot --out <repo-relative file>
75
+ -> {"ok": true, "snapshot": "...", "entries": n}
76
+ aw-foundation.py outside --snapshot <file> --lane <lane root folder> [--undo]
77
+ -> {"clean": bool, "created": [...], "changed": [...], "deleted": [...],
78
+ "removed": [...], "summary": "..."}
79
+ aw-foundation.py rules --name <Foundation> --app <App> --out <lane root folder>
80
+ [--reference] [--state <repo-relative folder>]
81
+ -> {"ok": bool, "reason": "...", "rules_file": "...", "rules": n,
82
+ "merges": [{"target", "existing", "instruction", "rule"}], "merge_count": n,
83
+ "stale": [...], "note": "..."}
84
+ aw-foundation.py rules-done [--state <repo-relative folder>] -> {"ok": true, "removed": bool}
85
+ aw-foundation.py pack-lane --name <Foundation> --app <App>
86
+ -> {"found": bool, "file": "...", "contract_run": "...", "contract": "...",
87
+ "client": "...", "reason": "..."}
88
+
89
+ A pack may also carry LANE SETTINGS, `<pack name>.lane.json` beside it: values the lane
90
+ records in stacks.json once the pack is unpacked (see pack_lane).
30
91
  """
31
92
 
32
93
  import argparse
94
+ import datetime
95
+ import glob
33
96
  import json
34
97
  import os
98
+ import re
35
99
  import shutil
36
100
  import sys
37
101
 
@@ -40,7 +104,7 @@ def norm(path):
40
104
  """A repo-relative directory, '/'-separated and '/'-terminated; "" when absent.
41
105
 
42
106
  Mirrors aw-stack's `norm_root` for the shapes this reads, so the paths compared here
43
- are the same strings the lane model stores. "./" — the repo root as a source path —
107
+ are the same strings the lane model stores. "./" — the repo root as a lane root folder —
44
108
  normalises to "" so that joining it yields the repo itself.
45
109
  """
46
110
  p = (path or "").replace("\\", "/").strip()
@@ -104,7 +168,7 @@ def redirect_tests(repo, out, test_path, force=False):
104
168
  # tests it carries are inside the subtree they belong in, and moving them would
105
169
  # take them out of it.
106
170
  if out_rel and (test_rel == out_rel or test_rel.startswith(out_rel)):
107
- return skip("this lane keeps its tests inside its source path (%s), "
171
+ return skip("this lane keeps its tests inside its lane root folder (%s), "
108
172
  "so the pack's layout is already right" % out_rel)
109
173
 
110
174
  # WHAT the pack would have called its test tree, had it placed one: the last segment
@@ -133,18 +197,819 @@ def redirect_tests(repo, out, test_path, force=False):
133
197
  "redirected": bool(moved), "message": msg}
134
198
 
135
199
 
200
+ # ─────────────────────────── what a lane can be built from ───────────────────────────
201
+
202
+ # The option label separator every pick list in the kit uses ("<value> — <why>"), so a
203
+ # selection read back from the launcher splits the same way a lane option does.
204
+ LABEL_SEP = " — "
205
+ MANUAL = "[manual spec]"
206
+ SKIP = "[skip]"
207
+ HEADER_KEYS = ("kind", "profiles", "title", "foundation", "sentinel", "files",
208
+ "contract_run", "contract", "client")
209
+ # A key may be written with hyphens (`contract-run`); it is read as the underscored name.
210
+ HEADER_RE = re.compile(r"^#\s*([a-z][a-z_-]*)\s*:\s*(.*?)\s*$")
211
+
212
+
213
+ HEADER_LINES = 40
214
+
215
+
216
+ def read_header_text(text):
217
+ """The `# key: value` lines a pack or spec opens with — known keys only, first wins.
218
+
219
+ Leading blank lines are skipped; the first line that is not a `#` line ends the
220
+ header, so a `# key:` line further down the body is content, not header.
221
+ """
222
+ head = {}
223
+ for i, line in enumerate(text.splitlines()):
224
+ if i >= HEADER_LINES:
225
+ break
226
+ s = line.strip()
227
+ if not s and not head:
228
+ continue
229
+ if not s.startswith("#"):
230
+ break
231
+ m = HEADER_RE.match(s)
232
+ key = m.group(1).replace("-", "_") if m else ""
233
+ if key in HEADER_KEYS and key not in head:
234
+ head[key] = m.group(2)
235
+ return head
236
+
237
+
238
+ def read_header(path):
239
+ """read_header_text over a file's first lines only — a pack runs to tens of thousands
240
+ of lines and its header is the first dozen."""
241
+ lines = []
242
+ try:
243
+ with open(path, encoding="utf-8", errors="replace") as fh:
244
+ for i, line in enumerate(fh):
245
+ if i >= HEADER_LINES:
246
+ break
247
+ lines.append(line)
248
+ except OSError:
249
+ return {}
250
+ return read_header_text("".join(lines))
251
+
252
+
253
+ def scan_entries(repo):
254
+ """Every pack and spec under foundations/<ecosystem>/<Name>/, unfiltered.
255
+
256
+ A directory holding neither (components/) is not a foundation and yields nothing. A
257
+ Reference pack is never an entry of its own: it rides on the primary pack it extends.
258
+ """
259
+ root = os.path.join(repo, "foundations")
260
+ out = []
261
+ if not os.path.isdir(root):
262
+ return out
263
+ for eco in sorted(os.listdir(root)):
264
+ edir = os.path.join(root, eco)
265
+ if not os.path.isdir(edir):
266
+ continue
267
+ for name in sorted(os.listdir(edir)):
268
+ fdir = os.path.join(edir, name)
269
+ if not os.path.isdir(fdir):
270
+ continue
271
+ rel_dir = "foundations/%s/%s" % (eco, name)
272
+ pack = os.path.join(fdir, name + ".pack.txt")
273
+ if os.path.isfile(pack):
274
+ h = read_header(pack)
275
+ out.append(_entry(name, "pack", eco, "%s/%s.pack.txt" % (rel_dir, name), h,
276
+ has_reference=os.path.isfile(
277
+ os.path.join(fdir, name + ".Reference.pack.txt"))))
278
+ for f in sorted(os.listdir(fdir)):
279
+ if f.endswith(".spec.txt") and os.path.isfile(os.path.join(fdir, f)):
280
+ h = read_header(os.path.join(fdir, f))
281
+ out.append(_entry(f[:-len(".spec.txt")], "spec", eco,
282
+ "%s/%s" % (rel_dir, f), h))
283
+ return out
284
+
285
+
286
+ def _entry(name, route, eco, rel, head, has_reference=False):
287
+ profiles = [p.strip() for p in re.split(r"[,\s]+", head.get("profiles", "")) if p.strip()]
288
+ return {"name": name, "route": route, "ecosystem": eco, "file": rel,
289
+ "kind": (head.get("kind") or "backend").strip().lower(),
290
+ "profiles": profiles, "title": head.get("title", ""),
291
+ "files": head.get("files", ""), "has_reference": has_reference}
292
+
293
+
294
+ def entry_fits(e, kind, profile, ecosystems):
295
+ if ecosystems is not None and e["ecosystem"] not in ecosystems:
296
+ return False
297
+ if kind and e["kind"] != kind:
298
+ return False
299
+ if profile and e["profiles"] and profile not in e["profiles"]:
300
+ return False
301
+ return True
302
+
303
+
304
+ def entry_option(e):
305
+ if e["title"]:
306
+ why = e["title"]
307
+ elif e["route"] == "pack":
308
+ why = "unpack a verified, compiling project" + (
309
+ " (%s files)" % e["files"] if e["files"] else "")
310
+ else:
311
+ why = "build the project this spec describes (%s)" % e["file"]
312
+ return "%s [%s]%s%s" % (e["name"], e["route"], LABEL_SEP, why)
313
+
314
+
315
+ def build_list(repo, kind="", profile="", ecosystems=None, raw="", lane_id=""):
316
+ entries = scan_entries(repo)
317
+ kept = [e for e in entries if entry_fits(e, kind, profile, ecosystems)]
318
+ # Packs before specs for the same name: the verified bundle is the safer default.
319
+ kept.sort(key=lambda e: (e["name"].lower(), 0 if e["route"] == "pack" else 1))
320
+ lane = ("the %s lane" % lane_id) if lane_id else "this lane"
321
+ manual = MANUAL + LABEL_SEP + "give the spec yourself: a file path, or the spec text"
322
+ skip = SKIP + LABEL_SEP + "leave %s as it is" % lane
323
+ # A lane that already holds files was asked about overwriting before this list is
324
+ # built (lane-files), so SKIP never needs to come first to protect it.
325
+ options = [entry_option(e) for e in kept] + [manual, skip]
326
+
327
+ pre_name = pre_app = rejected = ""
328
+ toks = (raw or "").split()
329
+ if toks:
330
+ pre_name = toks[0]
331
+ if len(toks) > 1:
332
+ pre_app = toks[1]
333
+ if len(toks) > 2:
334
+ rejected = toks[2]
335
+ match = next((e for e in kept if e["name"] == pre_name), None)
336
+
337
+ return {
338
+ "options": options,
339
+ "entries": kept,
340
+ "count": len(kept),
341
+ "foundations": [e["name"] for e in kept if e["route"] == "pack"],
342
+ "specs": [e["name"] for e in kept if e["route"] == "spec"],
343
+ "all_foundations": ["%s/%s [%s]" % (e["ecosystem"], e["name"], e["route"])
344
+ for e in entries],
345
+ "all_count": len(entries),
346
+ "ecosystems": sorted(set(e["ecosystem"] for e in entries)),
347
+ "lane_ecosystems": list(ecosystems or []),
348
+ "filtered": ecosystems is not None,
349
+ "kind": kind,
350
+ "profile": profile,
351
+ "preselect_name": match["name"] if match else "",
352
+ "preselect_option": entry_option(match) if match else "",
353
+ "preselect_app": pre_app,
354
+ "rejected_out": rejected,
355
+ }
356
+
357
+
358
+ def resolve_choice(repo, selection, kind="", profile="", ecosystems=None):
359
+ """Map what the launcher wrote back to one entry.
360
+
361
+ Tolerant on purpose: the selection comes back as text the operator or the launching
362
+ agent wrote, so the full option, the part before the label, a bare name, `skip`,
363
+ `manual` and a 1-based position in the list all resolve. A bare name that is both a
364
+ pack and a spec resolves to the pack.
365
+ """
366
+ # Same filters as the list the operator saw, so a numbered answer counts the same rows.
367
+ listing = build_list(repo, kind, profile, ecosystems)
368
+ options, kept = listing["options"], listing["entries"]
369
+ sel = (selection or "").strip()
370
+ head = sel.split(LABEL_SEP, 1)[0].strip()
371
+ head = re.split(r"\s+—\s+", head, maxsplit=1)[0].strip()
372
+
373
+ def result(route, e=None, ok=True, reason=""):
374
+ return {"ok": ok, "route": route, "name": e["name"] if e else "",
375
+ "file": e["file"] if e else "", "ecosystem": e["ecosystem"] if e else "",
376
+ "title": e["title"] if e else "", "selection": sel, "reason": reason}
377
+
378
+ if head.isdigit() and 1 <= int(head) <= len(options):
379
+ head = options[int(head) - 1].split(LABEL_SEP, 1)[0].strip()
380
+ low = head.lower()
381
+ if low in (SKIP, "skip"):
382
+ return result("skip")
383
+ if low in (MANUAL, "manual", "manual spec", "[manual]"):
384
+ return result("manual")
385
+ m = re.match(r"^(.+?)\s*\[(pack|spec)\]$", head)
386
+ if m:
387
+ e = next((x for x in kept if x["name"] == m.group(1) and x["route"] == m.group(2)), None)
388
+ else:
389
+ e = next((x for x in kept if x["name"] == head and x["route"] == "pack"), None) \
390
+ or next((x for x in kept if x["name"] == head), None)
391
+ if e:
392
+ return result(e["route"], e)
393
+ return result("", ok=False, reason="`%s` is not one of the options offered for this lane"
394
+ % (sel or "(empty)"))
395
+
396
+
397
+ SPEC_LIMIT = 200_000
398
+
399
+
400
+ SETTING_LABELS = (("contract_run", "start command"), ("contract", "document URL"),
401
+ ("client", "client command"))
402
+
403
+
404
+ def capture_spec(repo, lane, kind="", file="", text="", app=""):
405
+ """Copy the spec a lane is being built from to .appweaver/foundation/<lane>.spec.md.
406
+
407
+ The copy is the record of what the project was built from: a bundle spec can change
408
+ after the build and typed text exists nowhere else. `--input` is read as a path when
409
+ it names a file, and as the spec itself otherwise — except a single word that is
410
+ shaped like a path (a separator, or a file extension), which is a path that was
411
+ mistyped: capturing it as a fifteen-character spec would build a project from it.
412
+
413
+ Also returns the lane settings the spec's header gives — `# contract-run:`,
414
+ `# contract:` and `# client:`, with `<App>` replaced by `app` — which foundation
415
+ records on the lane once the project is built. They are read here, from the spec as
416
+ given, because the copy opens with a comment and has no header left to read.
417
+ """
418
+ def fail(why):
419
+ out = {"ok": False, "spec_path": "", "source": "", "chars": 0, "title": "",
420
+ "reason": why, "settings_text": "none", "placeholder": "{{contract}}"}
421
+ out.update({k: "" for k in LANE_KEYS})
422
+ return out
423
+
424
+ source, body = "", ""
425
+ cand = (file or "").strip()
426
+ typed = (text or "").strip()
427
+ if not cand and typed and "\n" not in typed and len(typed) < 512:
428
+ maybe = typed.strip("'\"` ")
429
+ p = maybe if os.path.isabs(maybe) else os.path.join(repo, maybe)
430
+ if os.path.isfile(p):
431
+ cand = maybe
432
+ elif os.path.isdir(p):
433
+ return fail("`%s` is a directory — give the path of the spec file inside it" % maybe)
434
+ elif " " not in maybe and re.search(r"[/\\]|\.(md|txt|markdown)$", maybe, re.I):
435
+ return fail("spec file not found: %s" % maybe)
436
+ if cand:
437
+ p = cand if os.path.isabs(cand) else os.path.join(repo, cand)
438
+ if not os.path.isfile(p):
439
+ return fail("spec file not found: %s" % cand)
440
+ if os.path.getsize(p) > SPEC_LIMIT:
441
+ return fail("spec file %s is larger than %d bytes" % (cand, SPEC_LIMIT))
442
+ with open(p, encoding="utf-8", errors="replace") as fh:
443
+ body = fh.read()
444
+ source = os.path.relpath(p, repo).replace("\\", "/") if not os.path.isabs(cand) else cand
445
+ elif typed:
446
+ body, source = typed, "text typed at the prompt"
447
+ if len(body) > SPEC_LIMIT:
448
+ return fail("the spec text is larger than %d characters" % SPEC_LIMIT)
449
+ if not body.strip():
450
+ return fail("no spec was given — enter a file path or the spec text")
451
+
452
+ head = read_header_text(body)
453
+ title = head.get("title", "")
454
+ if not title:
455
+ for line in body.splitlines():
456
+ s = line.strip()
457
+ if s and not HEADER_RE.match(s):
458
+ title = s.lstrip("#").strip()[:100]
459
+ break
460
+
461
+ lane_slug = re.sub(r"[^A-Za-z0-9._-]+", "-", lane or kind or "lane").strip("-") or "lane"
462
+ rel = ".appweaver/foundation/%s.spec.md" % lane_slug
463
+ dest = os.path.join(repo, rel)
464
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
465
+ stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
466
+ with open(dest, "w", encoding="utf-8") as fh:
467
+ fh.write("<!-- /appweaver foundation: the spec given for the %s `%s`.\n"
468
+ " source: %s · captured %s -->\n\n"
469
+ % ((kind + " lane") if kind else "lane", lane_slug, source, stamp))
470
+ fh.write(body if body.endswith("\n") else body + "\n")
471
+ settings = {k: head.get(k, "").replace("<App>", app) if app else head.get(k, "")
472
+ for k in LANE_KEYS}
473
+ text_parts = ["%s `%s`" % (label, settings[k]) for k, label in SETTING_LABELS if settings[k]]
474
+ out = {"ok": True, "spec_path": rel, "source": source, "chars": len(body),
475
+ "title": title, "reason": "",
476
+ # One line for a prompt, and the placeholder a client command names the API
477
+ # document by, spelled out — a workflow cannot write it literally.
478
+ "settings_text": "; ".join(text_parts) or "none",
479
+ "placeholder": "{{contract}}"}
480
+ out.update(settings)
481
+ return out
482
+
483
+
484
+ # ───────────────────────────── what a lane already holds ─────────────────────────────
485
+
486
+ # Directories that hold what a build or an install produced rather than code someone
487
+ # wrote. Walking node_modules alone can take longer than the rest of the run, so they are
488
+ # named, never walked — but one being there still means the lane is not empty.
489
+ GENERATED_DIRS = {"node_modules", ".next", "dist", "build", "out", "coverage",
490
+ "bin", "obj", "target", ".git"}
491
+ # Files that keep an otherwise empty directory in git, or that an OS drops on its own.
492
+ IGNORED_FILES = {".gitkeep", ".DS_Store", "Thumbs.db"}
493
+ SAMPLE = 5
494
+
495
+
496
+ def _profile(repo, profile_id):
497
+ """The stack profile whose `id` is profile_id — matched on the id, not the file name."""
498
+ base = os.path.join(repo, ".appweaver", "stack-profiles")
499
+ for path in sorted(glob.glob(os.path.join(base, "*.json"))):
500
+ try:
501
+ with open(path, encoding="utf-8") as fh:
502
+ prof = json.load(fh)
503
+ except (ValueError, OSError):
504
+ continue
505
+ if prof.get("id") == profile_id:
506
+ return prof
507
+ return {}
508
+
509
+
510
+ def _inside(path, folder):
511
+ """True when the normalised directory `path` is `folder` or below it."""
512
+ return path == folder or path.startswith(folder)
513
+
514
+
515
+ def _capped(items):
516
+ return ", ".join(items[:SAMPLE]) + (" and %d more" % (len(items) - SAMPLE)
517
+ if len(items) > SAMPLE else "")
518
+
519
+
520
+ def lane_files(repo, source, test="", workdir="", profile=""):
521
+ """What a lane already holds — asked before anything is scaffolded into it.
522
+
523
+ Walks the lane root folder and, when it is a different subtree, the test path. A path that
524
+ is the repository root itself is not walked: it would count every other lane and the
525
+ kit. The lane's build files are looked for where its commands run when that is outside
526
+ the lane — a .NET lane rooted at src/ keeps its solution file at the repository root —
527
+ using the profile's `detect` entries that name a file there (the ones with no `/`).
528
+ """
529
+ folders = []
530
+ for f in (norm(source), norm(test)):
531
+ if f and not any(_inside(f, g) for g in folders):
532
+ folders = [g for g in folders if not _inside(g, f)] + [f]
533
+
534
+ count, sample, generated = 0, [], []
535
+ for folder in folders:
536
+ top = os.path.join(repo, folder)
537
+ if not os.path.isdir(top):
538
+ continue
539
+ for dirpath, dirnames, filenames in os.walk(top):
540
+ rel_dir = os.path.relpath(dirpath, repo).replace("\\", "/")
541
+ walk = []
542
+ for d in sorted(dirnames):
543
+ if d in GENERATED_DIRS:
544
+ generated.append("%s/%s/" % (rel_dir, d))
545
+ else:
546
+ walk.append(d)
547
+ dirnames[:] = walk
548
+ for name in sorted(filenames):
549
+ if name in IGNORED_FILES:
550
+ continue
551
+ count += 1
552
+ if len(sample) < SAMPLE:
553
+ sample.append("%s/%s" % (rel_dir, name))
554
+
555
+ wd = norm(workdir)
556
+ root_files = []
557
+ if not any(_inside(wd, f) for f in folders):
558
+ for pat in _profile(repo, profile).get("detect", []) if profile else []:
559
+ if "/" in pat:
560
+ continue
561
+ for hit in sorted(glob.glob(os.path.join(repo, wd, pat))):
562
+ if os.path.isfile(hit):
563
+ root_files.append(os.path.relpath(hit, repo).replace("\\", "/"))
564
+
565
+ parts = []
566
+ if count:
567
+ parts.append("%s: %d file(s), e.g. %s" % (", ".join(folders), count, _capped(sample)))
568
+ if generated:
569
+ parts.append("build output / dependencies (not counted): %s" % _capped(generated))
570
+ if root_files:
571
+ parts.append("%s: %s" % ("repo root" if not wd else wd, _capped(root_files)))
572
+ contained, shape_reason = lane_shape(source, test, workdir)
573
+ return {"has_files": bool(count or generated or root_files), "count": count,
574
+ "sample": sample, "generated": generated[:SAMPLE], "root_files": root_files,
575
+ "folders": folders, "workdir": wd or "./", "summary": "; ".join(parts),
576
+ "self_contained": contained, "shape_reason": shape_reason}
577
+
578
+
579
+ def lane_shape(source, test="", workdir=""):
580
+ """Can a foundation be built into this lane? (self_contained, reason)
581
+
582
+ A foundation is a complete project — its build files, its source and its tests — and
583
+ it lands inside the lane root folder exactly as a pack holds it (a spec build is asked
584
+ for the same). So the lane's commands have to run inside that folder, or `dotnet
585
+ build` looks for a solution file at the repository root that is not there; and its
586
+ tests have to sit inside it too. A lane declared the classic way — code in `src/`,
587
+ build files and `tests/` at the top — is right for existing code but cannot take a
588
+ foundation, and is reported rather than scaffolded into a project that does not build.
589
+ """
590
+ src, tst, wd = norm(source), norm(test), norm(workdir)
591
+ where = src or "./"
592
+ if wd != src:
593
+ return False, ("the lane's commands run at %s, outside its lane root folder %s — a foundation "
594
+ "builds a complete project (build files, source and tests) inside the "
595
+ "lane root folder, so its commands have to run there. Re-declare the lane "
596
+ "with /appweaver select-stack so they run inside %s (run_from: root)"
597
+ % (wd or "the repository root", where, where))
598
+ if tst and src and not tst.startswith(src):
599
+ return False, ("the lane keeps its tests at %s, outside its lane root folder %s — a foundation "
600
+ "puts the project's tests inside the lane root folder. Re-declare the lane "
601
+ "with /appweaver select-stack with its tests inside %s" % (tst, where, where))
602
+ return True, ""
603
+
604
+
605
+ # What the engine itself writes while a build runs: the checkpoint state the /appweaver
606
+ # launcher keeps, with its payload and response sidecars, and the mock transport's output.
607
+ # Never recorded, so never reported and never removed.
608
+ ENGINE_DIRS = (".appweaver/orchestrator/.state/", ".appweaver/orchestrator/.demo-out/")
609
+ # The kit's own folders. A spec build may not write in them even for a lane whose root
610
+ # folder is the repository itself.
611
+ KIT_DIRS = (".appweaver/", "guardrails/", "foundations/", "specs/")
612
+ # Files an operating system drops on its own while a build runs.
613
+ OS_FILES = {".DS_Store", "Thumbs.db"}
614
+
615
+
616
+ def _tree(repo):
617
+ """Every file and folder in the repo: {path: [size, mtime_ns]} for a file, {path/: None}
618
+ for a folder. A GENERATED_DIRS folder is one entry and is not walked, so a stray
619
+ node_modules/ still shows up without its contents being read."""
620
+ entries = {}
621
+ for dirpath, dirnames, filenames in os.walk(repo):
622
+ rel = os.path.relpath(dirpath, repo).replace("\\", "/")
623
+ rel = "" if rel == "." else rel + "/"
624
+ walk = []
625
+ for d in dirnames:
626
+ path = rel + d + "/"
627
+ if any(path.startswith(e) for e in ENGINE_DIRS):
628
+ continue
629
+ entries[path] = None
630
+ if d not in GENERATED_DIRS:
631
+ walk.append(d)
632
+ dirnames[:] = walk
633
+ for name in filenames:
634
+ if name in OS_FILES:
635
+ continue
636
+ try:
637
+ st = os.stat(os.path.join(dirpath, name))
638
+ except OSError:
639
+ continue
640
+ entries[rel + name] = [st.st_size, st.st_mtime_ns]
641
+ return entries
642
+
643
+
644
+ def snapshot(repo, out):
645
+ """Record the repo before a spec build, for `outside` to compare against afterwards."""
646
+ entries = _tree(repo)
647
+ path = os.path.join(repo, out)
648
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
649
+ with open(path, "w", encoding="utf-8") as fh:
650
+ json.dump({"entries": entries}, fh, separators=(",", ":"))
651
+ return {"ok": True, "snapshot": out, "entries": len(entries)}
652
+
653
+
654
+ def _topmost(paths):
655
+ """Drop every path that sits inside another folder in the list."""
656
+ folders = [p for p in paths if p.endswith("/")]
657
+ return sorted(p for p in paths if not any(p != f and p.startswith(f) for f in folders))
658
+
659
+
660
+ def outside(repo, snap, lane, undo=False):
661
+ """What a spec build did OUTSIDE its lane root folder since `snapshot` ran.
662
+
663
+ Outside means anywhere but the lane root folder, and always the kit's own folders. With
664
+ `undo`, everything the build CREATED out there is removed again — a new folder as a
665
+ whole, since all of it is new. What it changed or deleted is only listed: there is no
666
+ copy to put back. The snapshot file is removed once read.
667
+ """
668
+ snap_path = os.path.join(repo, snap)
669
+ try:
670
+ with open(snap_path, encoding="utf-8") as fh:
671
+ before = json.load(fh)["entries"]
672
+ except (OSError, ValueError, KeyError):
673
+ return {"clean": False, "created": [], "changed": [], "deleted": [], "removed": [],
674
+ "summary": "no snapshot was taken before the build (%s), so what it wrote "
675
+ "outside the lane root folder cannot be checked" % snap}
676
+ after = _tree(repo)
677
+ lane_dir = norm(lane)
678
+
679
+ def out_of_lane(p):
680
+ return any(p.startswith(k) for k in KIT_DIRS) or not p.startswith(lane_dir)
681
+
682
+ created = _topmost([p for p in after if p not in before and out_of_lane(p)])
683
+ deleted = _topmost([p for p in before if p not in after and out_of_lane(p)])
684
+ changed = sorted(p for p in after if p in before and before[p] is not None
685
+ and after[p] != before[p] and out_of_lane(p))
686
+
687
+ removed = []
688
+ if undo:
689
+ for p in created:
690
+ full = os.path.join(repo, p)
691
+ try:
692
+ if p.endswith("/"):
693
+ shutil.rmtree(full)
694
+ else:
695
+ os.remove(full)
696
+ removed.append(p)
697
+ except OSError:
698
+ pass
699
+ try:
700
+ os.remove(snap_path)
701
+ except OSError:
702
+ pass
703
+
704
+ parts = []
705
+ if created:
706
+ parts.append("created %s%s" % (_capped(created),
707
+ " (removed again)" if removed == created else
708
+ " (removed again: %s)" % _capped(removed) if removed else ""))
709
+ if changed:
710
+ parts.append("changed %s (restore them yourself)" % _capped(changed))
711
+ if deleted:
712
+ parts.append("deleted %s (restore them yourself)" % _capped(deleted))
713
+ return {"clean": not (created or changed or deleted), "created": created,
714
+ "changed": changed, "deleted": deleted, "removed": removed,
715
+ "summary": "; ".join(parts)}
716
+
717
+
718
+ # ──────────────────────────────── a pack's rule file ────────────────────────────────
719
+
720
+ RULES_FORMAT = "aw-spec-pack-rules/v1"
721
+ RULE_ACTIONS = ("merge", "replace")
722
+ MERGE_STATE = ".appweaver/orchestrator/.state/foundation-merge/"
723
+ PACK_FILE_RE = re.compile(r'^<<<FILE path="([^"]+)"( encoding="([^"]+)")?>>>\s*$')
724
+ PACK_END = "<<<ENDFILE>>>"
725
+
726
+
727
+ def _pack_path(repo, name, reference=False):
728
+ """foundations/<ecosystem>/<name>/<name>[.Reference].pack.txt, repo-relative; "" when
729
+ there is none."""
730
+ base = name + (".Reference" if reference else "")
731
+ for hit in sorted(glob.glob(os.path.join(repo, "foundations", "*", name, base + ".pack.txt"))):
732
+ return os.path.relpath(hit, repo).replace("\\", "/")
733
+ return ""
734
+
735
+
736
+ def _pack_paths(path):
737
+ """The path of every file block in a pack, read the way the unpackers read it: a
738
+ `<<<FILE` line only opens a block outside one."""
739
+ paths, inside = [], False
740
+ with open(path, encoding="utf-8", errors="replace") as fh:
741
+ for line in fh:
742
+ line = line.rstrip("\r\n")
743
+ if inside:
744
+ inside = line != PACK_END
745
+ continue
746
+ m = PACK_FILE_RE.match(line)
747
+ if m:
748
+ paths.append(m.group(1))
749
+ inside = True
750
+ return paths
751
+
752
+
753
+ def _rename_forms(sentinel, app):
754
+ """The replacement contract in a pack's header, as the unpackers apply it: the dotted,
755
+ underscored, lower-hyphen and lower-dot forms, no-ops dropped, longest first."""
756
+ s_low, a_low = sentinel.lower(), app.lower()
757
+ forms, seen = [], set()
758
+ for src, dst in ((sentinel, app),
759
+ (sentinel.replace(".", "_"), app.replace(".", "_")),
760
+ (s_low.replace(".", "-"), a_low.replace(".", "-")),
761
+ (s_low, a_low)):
762
+ if src and src != dst and src not in seen:
763
+ seen.add(src)
764
+ forms.append((src, dst))
765
+ return sorted(forms, key=lambda f: -len(f[0]))
766
+
767
+
768
+ def _renamed(path, forms):
769
+ for src, dst in forms:
770
+ path = path.replace(src, dst)
771
+ return path
772
+
773
+
774
+ def load_rules(path):
775
+ """(rules, error) from a rule file. No file is no rules, not an error."""
776
+ if not os.path.isfile(path):
777
+ return [], ""
778
+ try:
779
+ with open(path, encoding="utf-8") as fh:
780
+ data = json.load(fh)
781
+ except ValueError as e:
782
+ return [], "is not valid JSON (%s)" % e
783
+ except OSError as e:
784
+ return [], "cannot be read (%s)" % e
785
+ if not isinstance(data, dict) or not isinstance(data.get("rules"), list):
786
+ return [], 'needs a "rules" list'
787
+ fmt = data.get("format", RULES_FORMAT)
788
+ if fmt != RULES_FORMAT:
789
+ return [], 'has format "%s"; this kit reads "%s"' % (fmt, RULES_FORMAT)
790
+ rules = []
791
+ for i, r in enumerate(data["rules"], 1):
792
+ if not isinstance(r, dict):
793
+ return [], "has rule %d that is not an object" % i
794
+ p = str(r.get("path", "")).replace("\\", "/").strip()
795
+ while p.startswith("./"):
796
+ p = p[2:]
797
+ p = p.lstrip("/")
798
+ action = str(r.get("action", "")).strip().lower()
799
+ instr = r.get("instruction", "")
800
+ if isinstance(instr, list):
801
+ instr = "\n".join(str(x) for x in instr)
802
+ instr = str(instr or "").strip()
803
+ if not p:
804
+ return [], "has rule %d with no path" % i
805
+ if action not in RULE_ACTIONS:
806
+ return [], 'has rule %d (%s) whose action is "%s"; it must be "merge" or "replace"' % (
807
+ i, p, action)
808
+ if action == "merge" and not instr:
809
+ return [], "has rule %d (%s) that merges with no instruction for how" % (i, p)
810
+ rules.append({"path": p, "action": action, "instruction": instr})
811
+ return rules, ""
812
+
813
+
814
+ def _state_dir(state):
815
+ """The folder merges are staged in — only ever below the engine's state folder, since
816
+ it is emptied before and removed after."""
817
+ s = norm(state) or MERGE_STATE
818
+ return s if s.startswith(ENGINE_DIRS[0]) and s != ENGINE_DIRS[0] else ""
819
+
820
+
821
+ def _rule_for(path, rules):
822
+ """The rule for one pack file: its own, else the deepest folder rule above it."""
823
+ exact = next((r for r in rules if r["path"] == path), None)
824
+ if exact:
825
+ return exact
826
+ folders = [r for r in rules if r["path"].endswith("/") and path.startswith(r["path"])]
827
+ return max(folders, key=lambda r: len(r["path"])) if folders else None
828
+
829
+
830
+ def pack_rules(repo, name, app, out, reference=False, state=MERGE_STATE):
831
+ """What a pack's rule file asks of THIS unpack, worked out before it runs.
832
+
833
+ A pack file that will land on a file already in the lane and that a `merge` rule
834
+ covers is a merge: the file there now is copied under `state`, where the merge reads
835
+ it once the pack's version has been written over it. Everything else — a `replace`
836
+ rule, no rule, or nothing there yet — is simply written as packed. A rule whose path
837
+ the pack does not hold is reported, so a rule file left behind by a re-pack is seen.
838
+ """
839
+ pack = _pack_path(repo, name, reference)
840
+ label = "%s%s" % (name, " reference sample" if reference else " pack")
841
+ result = {"ok": True, "reason": "", "rules_file": "", "rules": 0, "merges": [],
842
+ "merge_count": 0, "stale": [], "note": ""}
843
+ if not pack:
844
+ return dict(result, ok=False, reason="the %s was not found" % label)
845
+ rules_rel = pack[:-len(".pack.txt")] + ".rules.json"
846
+ rules, err = load_rules(os.path.join(repo, rules_rel))
847
+ if err:
848
+ return dict(result, ok=False, rules_file=rules_rel,
849
+ reason="the rule file %s %s" % (rules_rel, err))
850
+ if not rules:
851
+ return result
852
+
853
+ stash = _state_dir(state)
854
+ if not stash:
855
+ return dict(result, ok=False, rules_file=rules_rel,
856
+ reason="--state %s is not a folder under %s" % (state, ENGINE_DIRS[0]))
857
+ forms = _rename_forms(read_header(os.path.join(repo, pack)).get("sentinel", ""), app)
858
+ lane = norm(out)
859
+ shutil.rmtree(os.path.join(repo, stash), ignore_errors=True)
860
+
861
+ merges, used = [], set()
862
+ for packed in _pack_paths(os.path.join(repo, pack)):
863
+ rule = _rule_for(packed, rules)
864
+ if not rule:
865
+ continue
866
+ used.add(rule["path"])
867
+ rel = _renamed(packed, forms)
868
+ target = lane + rel
869
+ if rule["action"] != "merge" or not os.path.isfile(os.path.join(repo, target)):
870
+ continue
871
+ existing = stash + rel
872
+ os.makedirs(os.path.dirname(os.path.join(repo, existing)), exist_ok=True)
873
+ shutil.copy2(os.path.join(repo, target), os.path.join(repo, existing))
874
+ merges.append({"target": target, "existing": existing,
875
+ "instruction": rule["instruction"], "rule": rule["path"]})
876
+ stale = [r["path"] for r in rules if r["path"] not in used]
877
+
878
+ parts = []
879
+ if merges:
880
+ parts.append("merged %d existing file(s) as %s asks: %s"
881
+ % (len(merges), rules_rel, _capped([m["target"] for m in merges])))
882
+ if stale:
883
+ parts.append("%s names path(s) the pack does not hold: %s" % (rules_rel, _capped(stale)))
884
+ return dict(result, rules_file=rules_rel, rules=len(rules), merges=merges,
885
+ merge_count=len(merges), stale=stale,
886
+ note="".join("; " + p for p in parts))
887
+
888
+
889
+ LANE_FORMAT = "aw-spec-pack-lane/v1"
890
+ LANE_KEYS = ("contract_run", "contract", "client")
891
+
892
+
893
+ def pack_lane(repo, name, app):
894
+ """The lane settings a pack carries in `<pack name>.lane.json` beside it, with the
895
+ sentinel renamed as in the pack's own files.
896
+
897
+ These are what only the pack knows about the project it scaffolds — chiefly how to
898
+ start its service on its own to export the API document, which for a solution with
899
+ an orchestration host is not the lane's `run` command. A pack with no such file has
900
+ nothing to add, and the lane keeps its stack profile's values.
901
+
902
+ {"format": "aw-spec-pack-lane/v1",
903
+ "contract_run": "dotnet run --project src/<Sentinel>.Service -- --urls http://localhost:5000",
904
+ "contract": "http://localhost:5000/swagger/v1/swagger.json"}
905
+ """
906
+ pack = _pack_path(repo, name)
907
+ result = {"found": False, "file": "", "reason": ""}
908
+ result.update({k: "" for k in LANE_KEYS})
909
+ if not pack:
910
+ return dict(result, reason="the %s pack was not found" % name)
911
+ rel = pack[:-len(".pack.txt")] + ".lane.json"
912
+ path = os.path.join(repo, rel)
913
+ if not os.path.isfile(path):
914
+ return dict(result, reason="the %s pack carries no lane settings (%s)" % (name, rel))
915
+ try:
916
+ with open(path, encoding="utf-8") as fh:
917
+ data = json.load(fh)
918
+ except (ValueError, OSError) as e:
919
+ return dict(result, file=rel, reason="%s cannot be read (%s)" % (rel, e))
920
+ if not isinstance(data, dict) or data.get("format", LANE_FORMAT) != LANE_FORMAT:
921
+ return dict(result, file=rel, reason='%s is not an "%s" file' % (rel, LANE_FORMAT))
922
+ forms = _rename_forms(read_header(os.path.join(repo, pack)).get("sentinel", ""), app)
923
+ values = {k: _renamed(str(data.get(k) or "").strip(), forms) for k in LANE_KEYS}
924
+ return dict(result, found=any(values.values()), file=rel, **values)
925
+
926
+
927
+ def rules_done(repo, state=MERGE_STATE):
928
+ """Remove the files `rules` put aside, once the merges have read them."""
929
+ stash = _state_dir(state)
930
+ if not stash:
931
+ return {"ok": False, "removed": False,
932
+ "reason": "--state %s is not a folder under %s" % (state, ENGINE_DIRS[0])}
933
+ full = os.path.join(repo, stash)
934
+ there = os.path.isdir(full)
935
+ shutil.rmtree(full, ignore_errors=True)
936
+ return {"ok": True, "removed": there}
937
+
938
+
939
+ def _filters(p):
940
+ p.add_argument("--kind", default="", choices=["", "backend", "frontend"])
941
+ p.add_argument("--profile", default="")
942
+ # Repeatable. Absent = unfiltered; passed EMPTY = filtered to nothing, which is what a
943
+ # lane with no known ecosystem gets — no bundle is known to fit it.
944
+ p.add_argument("--ecosystem", action="append", default=None)
945
+
946
+
136
947
  def main():
137
948
  ap = argparse.ArgumentParser(description="lane-aware scaffolding helpers")
138
949
  ap.add_argument("--repo", default=os.getcwd())
139
950
  sub = ap.add_subparsers(dest="cmd", required=True)
140
951
  r = sub.add_parser("redirect-tests")
141
- r.add_argument("--out", required=True, help="the lane's source path, where the pack was unpacked")
952
+ r.add_argument("--out", required=True, help="the lane root folder, where the pack was unpacked")
142
953
  r.add_argument("--test-path", default="", help="the lane's declared test path")
143
954
  r.add_argument("--force", action="store_true")
955
+ ls = sub.add_parser("list")
956
+ _filters(ls)
957
+ ls.add_argument("--args", default="", help="fast path: '<Foundation> <App>'")
958
+ ls.add_argument("--lane-id", default="")
959
+ ch = sub.add_parser("choice")
960
+ _filters(ch)
961
+ ch.add_argument("--selection", default="")
962
+ sc = sub.add_parser("spec-capture")
963
+ sc.add_argument("--lane", default="")
964
+ sc.add_argument("--kind", default="")
965
+ sc.add_argument("--file", default="")
966
+ sc.add_argument("--input", default="")
967
+ sc.add_argument("--app", default="", help="the app name `<App>` in the header stands for")
968
+ lf = sub.add_parser("lane-files")
969
+ lf.add_argument("--source", default="", help="the lane root folder")
970
+ lf.add_argument("--test", default="", help="the lane's test path")
971
+ lf.add_argument("--workdir", default="", help="where the lane's commands run")
972
+ lf.add_argument("--profile", default="", help="stack-profile id, for its build files")
973
+ sn = sub.add_parser("snapshot")
974
+ sn.add_argument("--out", required=True, help="repo-relative file to record the repo in")
975
+ os_ = sub.add_parser("outside")
976
+ os_.add_argument("--snapshot", required=True, help="the file `snapshot` wrote")
977
+ os_.add_argument("--lane", default="", help="the lane root folder")
978
+ os_.add_argument("--undo", action="store_true", help="remove what the build created outside it")
979
+ ru = sub.add_parser("rules")
980
+ ru.add_argument("--name", required=True, help="the foundation")
981
+ ru.add_argument("--app", required=True, help="the app name the sentinel is renamed to")
982
+ ru.add_argument("--out", default="", help="the lane root folder the pack unpacks into")
983
+ ru.add_argument("--reference", action="store_true", help="the pack's reference sample")
984
+ ru.add_argument("--state", default=MERGE_STATE, help="where existing files are put aside")
985
+ rd = sub.add_parser("rules-done")
986
+ rd.add_argument("--state", default=MERGE_STATE)
987
+ pl = sub.add_parser("pack-lane")
988
+ pl.add_argument("--name", required=True, help="the foundation")
989
+ pl.add_argument("--app", required=True, help="the app name the sentinel is renamed to")
144
990
  args = ap.parse_args()
145
991
 
146
992
  if args.cmd == "redirect-tests":
147
993
  emit(redirect_tests(args.repo, args.out, args.test_path, args.force))
994
+ elif args.cmd == "list":
995
+ emit(build_list(args.repo, args.kind, args.profile, args.ecosystem, args.args,
996
+ args.lane_id))
997
+ elif args.cmd == "choice":
998
+ emit(resolve_choice(args.repo, args.selection, args.kind, args.profile, args.ecosystem))
999
+ elif args.cmd == "spec-capture":
1000
+ emit(capture_spec(args.repo, args.lane, args.kind, args.file, args.input, args.app))
1001
+ elif args.cmd == "lane-files":
1002
+ emit(lane_files(args.repo, args.source, args.test, args.workdir, args.profile))
1003
+ elif args.cmd == "snapshot":
1004
+ emit(snapshot(args.repo, args.out))
1005
+ elif args.cmd == "outside":
1006
+ emit(outside(args.repo, args.snapshot, args.lane, args.undo))
1007
+ elif args.cmd == "rules":
1008
+ emit(pack_rules(args.repo, args.name, args.app, args.out, args.reference, args.state))
1009
+ elif args.cmd == "rules-done":
1010
+ emit(rules_done(args.repo, args.state))
1011
+ elif args.cmd == "pack-lane":
1012
+ emit(pack_lane(args.repo, args.name, args.app))
148
1013
 
149
1014
 
150
1015
  if __name__ == "__main__":