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.
- package/assets/.agents/skills/appweaver/SKILL.md +7 -47
- package/assets/.appweaver/ORCH-DIALECT.md +13 -9
- package/assets/.appweaver/RULE-PARTITION.md +3 -3
- package/assets/.appweaver/STACK-PROFILES.md +188 -101
- package/assets/.appweaver/domain.json +1 -1
- package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +38 -10
- package/assets/.appweaver/orchestrator/LAUNCHER.md +88 -0
- package/assets/.appweaver/orchestrator/README.md +120 -53
- package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +1 -1
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +6 -4
- package/assets/.appweaver/orchestrator/modules/foundation-lane.orch.yaml +237 -0
- package/assets/.appweaver/orchestrator/modules/foundation-pack.orch.yaml +349 -0
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +381 -0
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/orchestrator.py +162 -63
- package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +2 -2
- package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +2 -2
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +5 -5
- package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +14 -14
- package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +2 -2
- package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +3 -3
- package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +41 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +13 -13
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice-delta.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +61 -12
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +172 -293
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +29 -28
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +53 -53
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +4 -4
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-bounded-context.orch.yaml +26 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +21 -15
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +14 -14
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-model.orch.yaml +3 -3
- package/assets/.appweaver/orchestrator/workflows/aw-spec-ui-prototype.orch.yaml +6 -6
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +284 -128
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +224 -70
- package/assets/.appweaver/scripts/python/aw-foundation.py +809 -20
- package/assets/.appweaver/scripts/python/aw-inventory.py +28 -21
- package/assets/.appweaver/scripts/python/aw-plan-coverage.py +2 -2
- package/assets/.appweaver/scripts/python/aw-rev-constitution.py +6 -6
- package/assets/.appweaver/scripts/python/aw-stack.py +195 -89
- package/assets/.appweaver/stack-profiles/angular.json +2 -1
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +2 -1
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +16 -3
- package/assets/.appweaver/stack-profiles/go-chi.json +6 -1
- package/assets/.appweaver/stack-profiles/java-spring.json +12 -5
- package/assets/.appweaver/stack-profiles/nextjs.json +2 -1
- package/assets/.appweaver/stack-profiles/node-express.json +8 -2
- package/assets/.appweaver/stack-profiles/python-fastapi.json +9 -2
- package/assets/.appweaver/stack-profiles/react-vite.json +2 -1
- package/assets/.appweaver/stack-profiles/sveltekit.json +2 -1
- package/assets/.appweaver/stack-profiles/vue-vite.json +2 -1
- package/assets/.appweaver/stacks.json +37 -1
- package/assets/.appweaver/templates/constitution-ui-template.md +2 -2
- package/assets/.claude/skills/appweaver/SKILL.md +7 -46
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.rules.json +31 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.rules.json +16 -0
- package/assets/foundations/foundation.spec.template.txt +73 -0
- package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +1 -1
- package/assets/foundations/java/Hexagonal/unpack-foundation.sh +4 -5
- package/assets/foundations/node/NextAppRouter/NextAppRouter.spec.txt +68 -0
- package/assets/guardrails/README.md +31 -17
- package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain-rule-bindings.md +5 -5
- package/assets/guardrails/{projects/dotnet-default-project → project-specific}/tech-rule-bindings.md +151 -114
- package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +58 -4
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/framework-api-map.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
- package/assets/guardrails/technology/nextjs/api-client.md +1 -1
- package/assets/guardrails/technology/nextjs/component-architecture.md +1 -1
- package/assets/guardrails/technology/nextjs/component-conventions.md +2 -2
- package/assets/guardrails/technology/nextjs/design-system.md +1 -1
- package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
- package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
- package/assets/guardrails/technology/nextjs/ui-constitution.md +3 -3
- package/package.json +1 -1
- package/assets/.agents/skills/appweaver-demo/SKILL.md +0 -59
- package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +0 -6
- package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +0 -100
- package/assets/.claude/skills/appweaver-demo/SKILL.md +0 -59
- package/assets/.kilocode/agent.json +0 -28
- package/assets/.kilocode/workflows/appweaver-demo.md +0 -59
- package/assets/.kilocode/workflows/appweaver.md +0 -55
- /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/domain/domain-requirements.md +0 -0
- /package/assets/guardrails/{projects/dotnet-default-project → project-specific}/frontend/ui-requirements.md +0 -0
- /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
|
|
7
|
-
|
|
8
|
-
appear outside a declared lane.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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__":
|