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