aw-spec-cli 0.1.26 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/.agents/skills/appweaver/SKILL.md +7 -47
- package/assets/.appweaver/ORCH-DIALECT.md +13 -9
- package/assets/.appweaver/RULE-PARTITION.md +11 -12
- package/assets/.appweaver/STACK-PROFILES.md +260 -108
- package/assets/.appweaver/domain.json +1 -1
- package/assets/.appweaver/interpreter/run-skill.py +28 -7
- 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/delta-plan.orch.yaml +17 -7
- 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 +392 -0
- package/assets/.appweaver/orchestrator/modules/foundation-spec.orch.yaml +548 -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/delta-design-template.skeleton.md +5 -4
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +3 -1
- 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 +21 -13
- package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +1 -1
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +28 -31
- 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/rev-templates/ui-delta-design-template.skeleton.md +4 -1
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +6 -4
- 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 +17 -16
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan-delta.orch.yaml +7 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-plan.orch.yaml +7 -1
- 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-deploy.orch.yaml +1 -1
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +172 -293
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +253 -44
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +72 -71
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +2 -2
- package/assets/.appweaver/orchestrator/workflows/aw-spec-plan.orch.yaml +38 -14
- 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 +3 -3
- 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 +371 -131
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +293 -76
- package/assets/.appweaver/scripts/python/aw-foundation.py +885 -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 +676 -99
- 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 +18 -3
- package/assets/.appweaver/stack-profiles/go-chi.json +6 -1
- package/assets/.appweaver/stack-profiles/java-spring.json +14 -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 +40 -1
- package/assets/.appweaver/templates/constitution-ui-template.md +2 -2
- package/assets/.appweaver/templates/delta-design-template.md +7 -5
- package/assets/.appweaver/templates/design-template.md +3 -0
- package/assets/.appweaver/templates/ui-delta-design-template.md +1 -1
- package/assets/.appweaver/templates/ui-design-template.md +6 -4
- package/assets/.claude/skills/appweaver/SKILL.md +7 -46
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.rules.json +31 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.lane.json +6 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.rules.json +16 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.lane.json +6 -0
- package/assets/foundations/foundation.spec.template.txt +98 -0
- package/assets/foundations/java/Hexagonal/Hexagonal.lane.json +6 -0
- package/assets/foundations/java/Hexagonal/Hexagonal.pack.txt +8 -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 +25 -13
- package/assets/guardrails/project-specific/tech-rule-bindings.md +296 -0
- package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +58 -4
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/eventing.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/modular-monolith.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/testing.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +1 -1
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +1 -1
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +1 -1
- package/assets/guardrails/technology/nextjs/api-client.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +1 -1
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +1 -1
- package/assets/guardrails/technology/nextjs/auth-session.md +1 -1
- package/assets/guardrails/technology/nextjs/client-security.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/error-handling.md +1 -1
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +1 -1
- package/assets/guardrails/technology/nextjs/frontend-testing.md +1 -1
- package/assets/guardrails/technology/nextjs/i18n.md +1 -1
- package/assets/guardrails/technology/nextjs/observability.md +1 -1
- package/assets/guardrails/technology/nextjs/performance-budgets.md +1 -1
- package/assets/guardrails/technology/nextjs/routing-navigation.md +1 -1
- package/assets/guardrails/technology/nextjs/state-management.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/tech-rule-bindings.md +0 -271
- /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
|
@@ -2,42 +2,66 @@
|
|
|
2
2
|
"""
|
|
3
3
|
aw-stack — the lane model's deterministic core.
|
|
4
4
|
|
|
5
|
-
Backs
|
|
5
|
+
Backs ten aw-spec-actions subcommands, called identically by the bash and the
|
|
6
6
|
powershell twin so the two stay in parity by construction rather than by review:
|
|
7
7
|
|
|
8
8
|
list enumerate .appweaver/stack-profiles/ — the candidate list
|
|
9
9
|
detect match the repo's own files against those profiles — the BROWNFIELD
|
|
10
10
|
candidate list, since an existing repo already answered the question
|
|
11
|
-
declare upsert a lane into .appweaver/stacks.json —
|
|
11
|
+
declare upsert a lane into .appweaver/stacks.json — the writer of WHOLE lanes
|
|
12
|
+
set change named keys on a lane already declared — the writer of SETTINGS
|
|
12
13
|
resolve read .appweaver/stacks.json — for every downstream reader; with
|
|
13
14
|
--feature-dir, narrowed to the lanes that feature has a surface in
|
|
14
15
|
verify is the declaration USABLE for the phase about to run — the gate
|
|
15
16
|
run-phase run a phase's command for EVERY declared lane, not just the first
|
|
16
17
|
probe is there evidence of a lane of this kind — read-only, never fatal
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
contract-export start the service lane, fetch its API document, stop it
|
|
19
|
+
contract-check the operation ids a frontend plan cites vs that document
|
|
20
|
+
|
|
21
|
+
This file is the only code that writes stacks.json, through two subcommands with
|
|
22
|
+
different jobs. `declare` creates or replaces a whole lane and has exactly one caller —
|
|
23
|
+
`select-stack`, where a person picks. `set` changes only the keys in CONTRACT_KEYS on a lane that is
|
|
24
|
+
already there, and never its root, profile or phase commands: `foundation` records the
|
|
25
|
+
API-export settings the pack it unpacked carries, and `implement` records the client
|
|
26
|
+
command a developer supplies. Re-declaring a lane to change one key would reset every
|
|
27
|
+
other command to the profile's default, which is why the two are separate. The `origin`
|
|
28
|
+
key stays optional in the schema for a future caller that proposes a lane without a
|
|
21
29
|
person in the loop; a reader that branches on it keeps working either way.
|
|
22
30
|
|
|
31
|
+
THE API CONTRACT BETWEEN THE LANES. The service lane publishes an API document and the
|
|
32
|
+
frontend lane generates its client from it. Three lane keys carry that, each read from
|
|
33
|
+
the lane first and from its stack profile when the lane does not set it, so a lane
|
|
34
|
+
declared before the keys existed answers them too:
|
|
35
|
+
|
|
36
|
+
contract_run (backend) the command that starts the service for the export
|
|
37
|
+
contract (backend) the URL the running service serves its document at
|
|
38
|
+
client (frontend) the command that generates the client; `{{contract}}` in
|
|
39
|
+
it is the exported document, as seen from where it runs
|
|
40
|
+
|
|
41
|
+
The document is written to CONTRACT_DOC under the service lane root folder, so it is
|
|
42
|
+
committed with the service that publishes it.
|
|
43
|
+
|
|
23
44
|
A person editing stacks.json in a text editor is the other way a lane arrives,
|
|
24
|
-
and it is supported: the kit ships the file
|
|
25
|
-
that
|
|
45
|
+
and it is supported: the kit ships the file with an empty `lanes` array for exactly
|
|
46
|
+
that, plus a `_lane_format` key holding one sample lane of each kind with every field
|
|
47
|
+
filled in. Nothing reads `_lane_format`; it is there to copy from. It is the one path that does not pass the writer's checks, which is why
|
|
26
48
|
`verify` re-checks the roots-disjoint invariant rather than trusting that
|
|
27
49
|
`declare` already did. `resolve` can never write, which is why these are two
|
|
28
50
|
subcommands and not one with a mode flag.
|
|
29
51
|
|
|
30
|
-
A lane's `root` IS
|
|
52
|
+
A lane's `root` IS the lane root folder — the subtree the lane owns and the only place
|
|
31
53
|
its implement phase may write. The two words name one field, and `--source-path`
|
|
32
54
|
is accepted wherever `--root` is.
|
|
33
55
|
|
|
34
|
-
WHERE THE COMMANDS RUN is a separate question, because
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
56
|
+
WHERE THE COMMANDS RUN is a separate question, because the answer can be a different
|
|
57
|
+
directory: a classic .NET repository keeps its solution file at the top while the
|
|
58
|
+
lane owns `src/`. `run_from` carries that answer — "repo" (the default, and the shape
|
|
59
|
+
of every lane written before the key existed) or "root". A stack whose build file
|
|
60
|
+
lives INSIDE its subtree needs "root" and cannot work without it: a Maven wrapper at
|
|
61
|
+
backend/<app>/mvnw is unreachable from the repository root. A NEW project is one
|
|
62
|
+
self-contained folder per lane — every profile defaults to `backend/` or `frontend/`
|
|
63
|
+
with `run_from: "root"` — while `declare` answers for code that already exists from
|
|
64
|
+
where its build file actually sits (see `build_files_at`).
|
|
41
65
|
|
|
42
66
|
WHERE THE PATH IS CHECKED AGAINST THE DISK. `declare` records a path; it does not
|
|
43
67
|
require the path to exist, because in a greenfield project it does not exist yet —
|
|
@@ -61,6 +85,14 @@ import sys
|
|
|
61
85
|
STACKS_REL = ".appweaver/stacks.json"
|
|
62
86
|
PROFILES_REL = ".appweaver/stack-profiles"
|
|
63
87
|
|
|
88
|
+
# Where the exported API document lands, relative to the service lane root folder.
|
|
89
|
+
CONTRACT_DOC = "contracts/openapi.json"
|
|
90
|
+
|
|
91
|
+
# The API-contract keys: the only ones `set` may change, and the ones read from the
|
|
92
|
+
# stack profile when the lane does not set them (lane_value). Everything else on a lane
|
|
93
|
+
# is the declaration itself and changes only through `declare`.
|
|
94
|
+
CONTRACT_KEYS = ("contract_run", "contract", "client")
|
|
95
|
+
|
|
64
96
|
# A lane id names the ROLE in the project; the profile names the technology.
|
|
65
97
|
# Keeping them separate is what lets a project re-stack a lane without renaming
|
|
66
98
|
# the paths, rules and constitution half that hang off the lane id.
|
|
@@ -270,6 +302,11 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
|
|
|
270
302
|
files_for_prof = scoped if prof.get("kind") != "frontend" else files
|
|
271
303
|
evidence, matched, anchored_hits = [], 0, 0
|
|
272
304
|
prof_root = norm_root(prof.get("root") or "")
|
|
305
|
+
# The `detect` patterns are written against the layout an EXISTING repository of
|
|
306
|
+
# this stack usually has (`src/**/*.csproj`, `web/next.config.*`), which is the
|
|
307
|
+
# profile's `conventional_root` — not its `root`, the folder a NEW project gets.
|
|
308
|
+
conv_root = norm_root(prof.get("conventional_root") or "")
|
|
309
|
+
bases = [b for b in (prof_root, conv_root) if b and b != REPO_ROOT]
|
|
273
310
|
for pat in patterns:
|
|
274
311
|
rx = _glob_rx(pat)
|
|
275
312
|
rx_any = _glob_rx("**/" + pat)
|
|
@@ -278,8 +315,8 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
|
|
|
278
315
|
anchored_hits += 1
|
|
279
316
|
else:
|
|
280
317
|
hits = [f for f in files_for_prof if rx_any.match(f)]
|
|
281
|
-
|
|
282
|
-
|
|
318
|
+
base = next((b for b in bases if pat.startswith(b)), "")
|
|
319
|
+
if not hits and base and os.path.basename(pat) not in ambiguous:
|
|
283
320
|
# RELOCATED, with the profile's own root segment substituted rather than
|
|
284
321
|
# prefixed. Prefixing alone only ever finds the profile's own folder name
|
|
285
322
|
# deeper in the tree — `apps/portal/web/angular.json` — so a UI in
|
|
@@ -288,7 +325,7 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
|
|
|
288
325
|
# Restricted to names only one kind claims, so substitution widens where a
|
|
289
326
|
# stack may live without widening what counts as evidence for it.
|
|
290
327
|
hits = [f for f in files_for_prof
|
|
291
|
-
if _glob_rx("**/" + pat[len(
|
|
328
|
+
if _glob_rx("**/" + pat[len(base):]).match(f)]
|
|
292
329
|
if not hits:
|
|
293
330
|
continue
|
|
294
331
|
matched += 1
|
|
@@ -298,20 +335,21 @@ def detect_profiles(repo, kind=None, exclude_roots=()):
|
|
|
298
335
|
dirs = sorted({os.path.dirname(f) for f in evidence}, key=lambda d: (d.count("/"), len(d)))
|
|
299
336
|
# WHERE THE SOURCE LIVES, which is not always where the evidence sits. A manifest
|
|
300
337
|
# at the repo root — `App.sln`, a workspace `package.json` — marks the repository,
|
|
301
|
-
# not the subtree: the code it describes is under the profile's
|
|
302
|
-
# root evidence defers to that root when the directory is really there, and only
|
|
338
|
+
# not the subtree: the code it describes is under one of the profile's roots. So
|
|
339
|
+
# repo-root evidence defers to that root when the directory is really there, and only
|
|
303
340
|
# falls back to the repo root itself when it is not, which is the case for the
|
|
304
341
|
# single-package layouts (`create-next-app`, a UI checked out at the top level)
|
|
305
|
-
# whose files genuinely are at the top.
|
|
342
|
+
# whose files genuinely are at the top. The conventional root is tried first:
|
|
343
|
+
# repo-root evidence (`App.sln`) is the classic layout, whose code sits under
|
|
344
|
+
# `src/` — not under the `backend/` a new project would get.
|
|
306
345
|
prof_default = norm_root(prof.get("root") or "")
|
|
307
346
|
if not dirs:
|
|
308
347
|
root = prof_default
|
|
309
348
|
elif dirs[0]:
|
|
310
349
|
root = norm_root(dirs[0])
|
|
311
|
-
elif prof_default and os.path.isdir(os.path.join(repo, prof_default)):
|
|
312
|
-
root = prof_default
|
|
313
350
|
else:
|
|
314
|
-
root =
|
|
351
|
+
root = next((r for r in (conv_root, prof_default)
|
|
352
|
+
if r and os.path.isdir(os.path.join(repo, r))), REPO_ROOT)
|
|
315
353
|
out.append({
|
|
316
354
|
"id": prof["id"],
|
|
317
355
|
"kind": prof.get("kind", ""),
|
|
@@ -404,7 +442,7 @@ def cmd_list(args):
|
|
|
404
442
|
repository looks like. Two subcommands would walk the file tree twice and add
|
|
405
443
|
two steps to a workflow whose whole value is being short. One call returns the
|
|
406
444
|
options, the profile the files point at, and the directory the evidence sits
|
|
407
|
-
in — which is the
|
|
445
|
+
in — which is the lane root folder the operator is about to be asked to confirm.
|
|
408
446
|
|
|
409
447
|
HOW A DETECTED STACK IS PROPOSED, and why it is not preselected. Detection
|
|
410
448
|
moves its match to the FRONT of the option list; it never fills `preselect`.
|
|
@@ -448,10 +486,10 @@ def cmd_list(args):
|
|
|
448
486
|
preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
|
|
449
487
|
preselect_source = "argument" if preselect else ""
|
|
450
488
|
|
|
451
|
-
# Candidate
|
|
489
|
+
# Candidate LANE ROOT FOLDERS for the follow-up question, best first. Labelled with why
|
|
452
490
|
# each is offered, because the operator picking from this list is choosing the
|
|
453
491
|
# subtree a generator will be allowed to write into. Free text is always
|
|
454
|
-
# accepted — a repo that matches no profile still has a
|
|
492
|
+
# accepted — a repo that matches no profile still has a lane root folder.
|
|
455
493
|
#
|
|
456
494
|
# WITH --profile the stack is already chosen, and the list answers for it alone:
|
|
457
495
|
# where THAT stack's evidence sits, then its own default, then where another
|
|
@@ -460,7 +498,7 @@ def cmd_list(args):
|
|
|
460
498
|
# operator did not choose.
|
|
461
499
|
#
|
|
462
500
|
# WITHOUT --profile the list is built before any choice, and a default root is
|
|
463
|
-
# usually shared (
|
|
501
|
+
# usually shared (every frontend profile defaults to frontend/), so its label names every
|
|
464
502
|
# profile that proposes it rather than whichever one sorts first.
|
|
465
503
|
chosen = None
|
|
466
504
|
if args.profile:
|
|
@@ -541,16 +579,16 @@ def strip_root_label(value):
|
|
|
541
579
|
return (value or "").split(ROOT_LABEL_SEP, 1)[0].strip()
|
|
542
580
|
|
|
543
581
|
|
|
544
|
-
# The repository root as a
|
|
582
|
+
# The repository root as a lane root folder. A single-module project — a Maven, Cargo, Go
|
|
545
583
|
# or Node repo whose manifest sits at the top — has no subdirectory to name, and the
|
|
546
|
-
# empty string cannot carry it: `verify` reads an empty root as "no
|
|
584
|
+
# empty string cannot carry it: `verify` reads an empty root as "no lane root folder
|
|
547
585
|
# declared". "./" is the one spelling that survives being normalised twice, which is
|
|
548
586
|
# what makes it usable as a STORED value rather than only as typed input.
|
|
549
587
|
REPO_ROOT = "./"
|
|
550
588
|
|
|
551
589
|
|
|
552
590
|
def norm_root(root):
|
|
553
|
-
"""Canonicalise a
|
|
591
|
+
"""Canonicalise a lane root folder, IDEMPOTENTLY.
|
|
554
592
|
|
|
555
593
|
Idempotence is a correctness requirement, not tidiness: `declare` normalises what it
|
|
556
594
|
writes and `verify` normalises what it reads, so a value that changes on the second
|
|
@@ -575,7 +613,7 @@ def norm_root(root):
|
|
|
575
613
|
|
|
576
614
|
|
|
577
615
|
def root_on_disk(repo, root):
|
|
578
|
-
"""Does the lane
|
|
616
|
+
"""Does the lane root folder exist as a directory?
|
|
579
617
|
|
|
580
618
|
Reported everywhere a root is reported and enforced only by `verify`. A false
|
|
581
619
|
here is an ordinary state before any code is written and a hard error once a
|
|
@@ -588,14 +626,15 @@ def root_on_disk(repo, root):
|
|
|
588
626
|
def lane_workdir(repo, lane):
|
|
589
627
|
"""The directory a lane's commands run in.
|
|
590
628
|
|
|
591
|
-
`run_from: "root"` runs them in the lane
|
|
629
|
+
`run_from: "root"` runs them in the lane root folder; anything else — including
|
|
592
630
|
the key being ABSENT, which is the shape of every lane written before this key
|
|
593
|
-
existed — runs them at the repository root. That default is not laziness: a
|
|
594
|
-
solution file, a go.mod or a package.json at the top is what
|
|
595
|
-
`go build ./...` and `npm run build` need to find,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
631
|
+
existed — runs them at the repository root. That default is not laziness: in a
|
|
632
|
+
classic repository a .NET solution file, a go.mod or a package.json at the top is what
|
|
633
|
+
`dotnet build`, `go build ./...` and `npm run build` need to find, while the lane is
|
|
634
|
+
the SOURCE directory under it. The two coincide for a stack whose build file lives
|
|
635
|
+
inside the subtree — a Maven wrapper at backend/orderservice/mvnw cannot be reached
|
|
636
|
+
from the repo root at all — and for every new project, which is one self-contained
|
|
637
|
+
folder per lane.
|
|
599
638
|
|
|
600
639
|
Falls back to the repo root when the declared root is not on disk, so the phase
|
|
601
640
|
reports the command's own failure instead of a chdir error.
|
|
@@ -608,30 +647,148 @@ def lane_workdir(repo, lane):
|
|
|
608
647
|
return repo
|
|
609
648
|
|
|
610
649
|
|
|
611
|
-
def
|
|
612
|
-
"""
|
|
650
|
+
def build_files_at(repo, root, build_files):
|
|
651
|
+
"""Where a stack's build file already sits for a lane at `root`: "root" when one is
|
|
652
|
+
directly inside the lane root folder, "repo" when one is only at the repository root,
|
|
653
|
+
"" when neither — nothing is on disk yet, so the caller falls back to the profile.
|
|
654
|
+
|
|
655
|
+
This is what lets one profile serve both kinds of repository. A new project is one
|
|
656
|
+
self-contained folder per lane (`backend/api.sln`), and its commands run inside it; a
|
|
657
|
+
classic .NET repository keeps `api.sln` at the top with the code under `src/`, and its
|
|
658
|
+
commands have to run at the top. Reading the answer off the disk at declare time gives
|
|
659
|
+
each its own, where a fixed profile default would break one of them.
|
|
660
|
+
"""
|
|
661
|
+
if not build_files:
|
|
662
|
+
return ""
|
|
663
|
+
r = norm_root(root)
|
|
664
|
+
lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
|
|
665
|
+
|
|
666
|
+
def has(d):
|
|
667
|
+
return any(os.path.isfile(p) for pat in build_files
|
|
668
|
+
for p in glob.glob(os.path.join(d, pat)))
|
|
669
|
+
|
|
670
|
+
if os.path.isdir(lane_dir) and has(lane_dir):
|
|
671
|
+
return "root"
|
|
672
|
+
if has(repo):
|
|
673
|
+
return "repo"
|
|
674
|
+
return ""
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# Folders a runnable project is never looked for in: build output, dependencies, tests.
|
|
678
|
+
HOST_SKIP_DIRS = {"bin", "obj", "node_modules", ".git", ".vs", "packages", "tests", "test"}
|
|
679
|
+
|
|
613
680
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
681
|
+
def lane_host(repo, lane):
|
|
682
|
+
"""The lane's runnable project folder, which `{{host}}` in a command stands for.
|
|
683
|
+
|
|
684
|
+
A lane root folder often holds several projects and only one of them is the thing to
|
|
685
|
+
run — a .NET solution's Aspire AppHost, or its API when there is no AppHost. The
|
|
686
|
+
profile's `host_patterns` say where that project sits, best first, as paths relative
|
|
687
|
+
to the lane root folder (`**/` for any depth, case ignored). The first pattern that
|
|
688
|
+
matches exactly ONE file names the project; its folder is returned as seen from where
|
|
689
|
+
the lane's commands run. None when the profile has no patterns, the lane root folder is
|
|
690
|
+
not on disk, or no pattern has a single match — the caller then uses the lane root folder.
|
|
691
|
+
"""
|
|
692
|
+
if not repo:
|
|
693
|
+
return None
|
|
694
|
+
patterns = [p.lower() for p in profile_of(repo, lane).get("host_patterns") or []]
|
|
695
|
+
r = norm_root(lane.get("root"))
|
|
696
|
+
lane_dir = repo if (not r or r == REPO_ROOT) else os.path.join(repo, r.rstrip("/"))
|
|
697
|
+
if not patterns or not os.path.isdir(lane_dir):
|
|
698
|
+
return None
|
|
699
|
+
leaves = {p.rsplit("/", 1)[-1] for p in patterns}
|
|
700
|
+
files = []
|
|
701
|
+
for dirpath, dirnames, filenames in os.walk(lane_dir):
|
|
702
|
+
dirnames[:] = [d for d in dirnames if d.lower() not in HOST_SKIP_DIRS]
|
|
703
|
+
for name in filenames:
|
|
704
|
+
if any(fnmatch.fnmatchcase(name.lower(), leaf) for leaf in leaves):
|
|
705
|
+
rel = os.path.relpath(os.path.join(dirpath, name), lane_dir).replace("\\", "/")
|
|
706
|
+
files.append(rel)
|
|
707
|
+
for pat in patterns:
|
|
708
|
+
bare = pat[3:] if pat.startswith("**/") else pat
|
|
709
|
+
hits = [f for f in files
|
|
710
|
+
if fnmatch.fnmatchcase(f.lower(), pat) or fnmatch.fnmatchcase(f.lower(), bare)]
|
|
711
|
+
if len(hits) == 1:
|
|
712
|
+
project_dir = os.path.join(lane_dir, os.path.dirname(hits[0]))
|
|
713
|
+
rel = os.path.relpath(project_dir, lane_workdir(repo, lane)).replace("\\", "/")
|
|
714
|
+
return rel
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def lane_command(lane, phase, repo=None):
|
|
719
|
+
"""The command for one phase, with `{{root}}` standing for the lane root folder and
|
|
720
|
+
`{{host}}` for the lane's runnable project inside it (see lane_host).
|
|
721
|
+
|
|
722
|
+
A profile command sometimes has to NAME the lane root folder — `python -m
|
|
723
|
+
compileall app`. Written literally, that is the profile's default root frozen into
|
|
724
|
+
the command, and it stays frozen when the lane declares a different one: a service
|
|
725
|
+
at `Services/Api/` compiles the code at `app/`, or fails looking for it. The
|
|
726
|
+
placeholder lets the profile say "the root this lane declares" and be right on
|
|
727
|
+
every layout.
|
|
620
728
|
|
|
621
729
|
Expanded at RUN time, not at declare time, so editing `root` in stacks.json by
|
|
622
730
|
hand keeps the command consistent with it. A command with no placeholder is
|
|
623
731
|
returned untouched, so every literal command already on disk is unaffected.
|
|
732
|
+
|
|
733
|
+
The root is named as seen from where the command RUNS: a `run_from: "root"` lane
|
|
734
|
+
runs inside its root, so there `{{root}}` is `.` — the repository-relative path
|
|
735
|
+
would point at `backend/backend`.
|
|
736
|
+
|
|
737
|
+
`{{host}}` exists because the lane root folder is not always runnable: `dotnet run
|
|
738
|
+
--project <folder>` needs exactly one project in that folder, and a solution has
|
|
739
|
+
several. It is looked up when the command is read, so a project scaffolded after the
|
|
740
|
+
lane was declared is found; with no single match it falls back to `{{root}}`.
|
|
741
|
+
|
|
742
|
+
`{{contract}}` is the service lane's exported API document (contract_doc), named as
|
|
743
|
+
seen from where THIS lane's command runs — what a frontend lane's `client` command
|
|
744
|
+
generates from. With no service lane it is left as written.
|
|
624
745
|
"""
|
|
625
|
-
|
|
746
|
+
if phase in CONTRACT_KEYS:
|
|
747
|
+
cmd = lane_value(repo, lane, phase)
|
|
748
|
+
else:
|
|
749
|
+
cmd = (lane.get(phase) or "").strip()
|
|
750
|
+
if "{{contract}}" in cmd and repo:
|
|
751
|
+
doc = contract_doc(repo)
|
|
752
|
+
if doc:
|
|
753
|
+
rel = os.path.relpath(os.path.join(repo, doc), lane_workdir(repo, lane))
|
|
754
|
+
cmd = cmd.replace("{{contract}}", rel.replace("\\", "/"))
|
|
755
|
+
if "{{host}}" in cmd:
|
|
756
|
+
cmd = cmd.replace("{{host}}", lane_host(repo, lane) or "{{root}}")
|
|
626
757
|
if "{{root}}" not in cmd:
|
|
627
758
|
return cmd
|
|
628
759
|
r = norm_root(lane.get("root"))
|
|
629
760
|
# `./` is the repo root: a bare `./` reads as noise in a command line, and the
|
|
630
761
|
# only sensible substitution for "the root, which is here" is the cwd itself.
|
|
631
|
-
|
|
762
|
+
runs_inside = (lane.get("run_from") or "repo") == "root"
|
|
763
|
+
sub = "." if (r == REPO_ROOT or not r or runs_inside) else r.rstrip("/")
|
|
632
764
|
return cmd.replace("{{root}}", sub)
|
|
633
765
|
|
|
634
766
|
|
|
767
|
+
def lane_value(repo, lane, key):
|
|
768
|
+
"""A lane's value for one key, or its stack profile's when the lane sets none.
|
|
769
|
+
|
|
770
|
+
Only CONTRACT_KEYS fall back. The phase commands are written onto the lane when it
|
|
771
|
+
is declared; these are read from the profile at run time, so a lane declared before
|
|
772
|
+
they existed answers them, and a value set on the lane — by a pack or a developer —
|
|
773
|
+
overrides the profile's.
|
|
774
|
+
"""
|
|
775
|
+
value = (lane.get(key) or "").strip() if lane else ""
|
|
776
|
+
if not value and lane and repo and key in CONTRACT_KEYS:
|
|
777
|
+
value = (profile_of(repo, lane).get(key) or "").strip()
|
|
778
|
+
return value
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def contract_doc(repo, lanes=None):
|
|
782
|
+
"""The service lane's exported API document, repo-relative; "" with no service lane."""
|
|
783
|
+
if lanes is None:
|
|
784
|
+
lanes = (read_stacks(repo) or {}).get("lanes") or []
|
|
785
|
+
lane = next((l for l in lanes if l.get("kind") == "backend"), {})
|
|
786
|
+
r = norm_root(lane.get("root"))
|
|
787
|
+
if not r:
|
|
788
|
+
return ""
|
|
789
|
+
return CONTRACT_DOC if r == REPO_ROOT else r + CONTRACT_DOC
|
|
790
|
+
|
|
791
|
+
|
|
635
792
|
def roots_overlap(a, b):
|
|
636
793
|
"""Roots own their subtree, so containment either way is an overlap.
|
|
637
794
|
|
|
@@ -654,7 +811,7 @@ def lane_test_path(repo, lane, prof=None):
|
|
|
654
811
|
|
|
655
812
|
Most backend stacks keep tests BESIDE the source rather than in it: .NET's `tests/`
|
|
656
813
|
next to `src/`, Maven's `src/test/java/` next to `src/main/java/`, pytest's `tests/`
|
|
657
|
-
next to `app/`. A lane that owned only its
|
|
814
|
+
next to `app/`. A lane that owned only its lane root folder could not hold its own tests.
|
|
658
815
|
|
|
659
816
|
Answered at READ time, like `ecosystem`, so a lane declared before this existed has
|
|
660
817
|
one without being re-declared:
|
|
@@ -662,8 +819,11 @@ def lane_test_path(repo, lane, prof=None):
|
|
|
662
819
|
does not describe;
|
|
663
820
|
- else the profile's `test_root`, which pairs with the profile's default `root`. A
|
|
664
821
|
lane at that root takes it as is; a lane whose root ends with it takes it under
|
|
665
|
-
the same prefix (`backend
|
|
666
|
-
- else the
|
|
822
|
+
the same prefix (`apps/backend/` ⇒ `apps/backend/tests/`);
|
|
823
|
+
- else the profile's `conventional_test_root`, which pairs the same way with its
|
|
824
|
+
`conventional_root` — the classic layout an existing repository has (`src/` ⇒
|
|
825
|
+
`tests/`), so a lane declared at it keeps the test path it always had;
|
|
826
|
+
- else the tests live inside the lane root folder. That is right for every frontend
|
|
667
827
|
profile, for Go's colocated `_test.go` files, and for a Maven lane rooted at the
|
|
668
828
|
directory holding its pom.xml.
|
|
669
829
|
"""
|
|
@@ -676,24 +836,25 @@ def lane_test_path(repo, lane, prof=None):
|
|
|
676
836
|
if own:
|
|
677
837
|
return own
|
|
678
838
|
prof = profile_of(repo, lane) if prof is None else prof
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
839
|
+
for key_root, key_tests in (("root", "test_root"),
|
|
840
|
+
("conventional_root", "conventional_test_root")):
|
|
841
|
+
base, tests = norm_root(prof.get(key_root)), norm_root(prof.get(key_tests))
|
|
842
|
+
if not base or not tests or REPO_ROOT in (base, tests):
|
|
843
|
+
continue
|
|
844
|
+
if root == base:
|
|
845
|
+
return tests
|
|
846
|
+
if root.endswith("/" + base):
|
|
847
|
+
return root[: -len(base)] + tests
|
|
687
848
|
return root
|
|
688
849
|
|
|
689
850
|
|
|
690
851
|
def lane_owned_paths(repo, lane, prof=None):
|
|
691
|
-
"""Every subtree a lane owns, labelled: its
|
|
852
|
+
"""Every subtree a lane owns, labelled: its lane root folder, and its test path when that
|
|
692
853
|
is a different directory. Ownership checks compare these, so a test path can never
|
|
693
854
|
land inside another lane's subtree."""
|
|
694
855
|
root = norm_root(lane.get("root"))
|
|
695
856
|
tests = lane_test_path(repo, lane, prof)
|
|
696
|
-
owned = [("
|
|
857
|
+
owned = [("lane root folder", root)] if root else []
|
|
697
858
|
if tests and tests != root:
|
|
698
859
|
owned.append(("test path", tests))
|
|
699
860
|
return owned
|
|
@@ -768,7 +929,7 @@ def cmd_declare(args):
|
|
|
768
929
|
lane_id = args.id or DEFAULT_LANE_ID.get(kind, kind)
|
|
769
930
|
root = norm_root(args.root or (prof or {}).get("root") or "")
|
|
770
931
|
if not root:
|
|
771
|
-
fail("lane-declare: lane '%s' has no
|
|
932
|
+
fail("lane-declare: lane '%s' has no lane root folder. A lane without a root owns "
|
|
772
933
|
"nothing, so nothing can be built, run or written into it. Pass "
|
|
773
934
|
"--source-path <dir> (the profile declares no default)." % lane_id)
|
|
774
935
|
|
|
@@ -777,7 +938,7 @@ def cmd_declare(args):
|
|
|
777
938
|
# is about to read the code out of it.
|
|
778
939
|
exists = root_on_disk(repo, root)
|
|
779
940
|
if args.require_existing_root and not exists:
|
|
780
|
-
fail("lane-declare:
|
|
941
|
+
fail("lane-declare: lane root folder '%s' for lane '%s' does not exist under %s. "
|
|
781
942
|
"Point the lane at the directory that actually holds this lane's source, "
|
|
782
943
|
"or re-run /appweaver select-stack to correct it."
|
|
783
944
|
% (root, lane_id, repo))
|
|
@@ -802,8 +963,12 @@ def cmd_declare(args):
|
|
|
802
963
|
lane["migrate"] = migrate
|
|
803
964
|
# WHERE this lane's commands run — see lane_workdir. Recorded only when it is not
|
|
804
965
|
# the default, so a lane reads the same as it always did unless its stack needs the
|
|
805
|
-
# other answer.
|
|
806
|
-
|
|
966
|
+
# other answer. An explicit --run-from wins; else the stack's build file decides when
|
|
967
|
+
# it is on disk (build_files_at); else — a greenfield lane, nothing written yet — the
|
|
968
|
+
# profile's own answer, which for a new project is inside the lane.
|
|
969
|
+
run_from = (getattr(args, "run_from", "")
|
|
970
|
+
or build_files_at(repo, root, (prof or {}).get("build_files") or [])
|
|
971
|
+
or (prof or {}).get("run_from", ""))
|
|
807
972
|
if run_from == "root":
|
|
808
973
|
lane["run_from"] = "root"
|
|
809
974
|
# What the code was found to be, when no shipped profile names it.
|
|
@@ -824,7 +989,7 @@ def cmd_declare(args):
|
|
|
824
989
|
for theirs_label, theirs in lane_owned_paths(repo, other):
|
|
825
990
|
if roots_overlap(mine, theirs):
|
|
826
991
|
fail("lane-declare: %s '%s' for lane '%s' overlaps lane '%s''s %s '%s'. "
|
|
827
|
-
"A lane owns its
|
|
992
|
+
"A lane owns its lane root folder and its test path, and no two lanes "
|
|
828
993
|
"may share a subtree."
|
|
829
994
|
% (mine_label, mine, lane_id, other.get("id"), theirs_label, theirs))
|
|
830
995
|
|
|
@@ -880,6 +1045,53 @@ def write_stacks(repo, data):
|
|
|
880
1045
|
fh.write("\n")
|
|
881
1046
|
|
|
882
1047
|
|
|
1048
|
+
def cmd_set(args):
|
|
1049
|
+
"""Change CONTRACT_KEYS on a lane that is already declared, leaving the rest of it
|
|
1050
|
+
as it is. `--set KEY=` with an empty value changes nothing, so a caller can pass every
|
|
1051
|
+
key it might have; `--unset KEY` removes one, so the lane reads its profile's again."""
|
|
1052
|
+
repo = args.repo
|
|
1053
|
+
data = read_stacks(repo) or {"lanes": []}
|
|
1054
|
+
lanes = data.get("lanes") or []
|
|
1055
|
+
wanted = strip_root_label(args.id) if args.id else ""
|
|
1056
|
+
lane = next((l for l in lanes if (l.get("id") == wanted if wanted
|
|
1057
|
+
else l.get("kind") == args.kind)), None)
|
|
1058
|
+
if lane is None:
|
|
1059
|
+
fail("lane-set: no lane %s is declared in %s"
|
|
1060
|
+
% ("'%s'" % wanted if wanted else "of kind '%s'" % (args.kind or "?"), STACKS_REL))
|
|
1061
|
+
|
|
1062
|
+
set_, removed = {}, []
|
|
1063
|
+
for item in args.set or []:
|
|
1064
|
+
key, sep, value = item.partition("=")
|
|
1065
|
+
key = key.strip()
|
|
1066
|
+
if not sep or key not in CONTRACT_KEYS:
|
|
1067
|
+
fail("lane-set: '%s' is not KEY=VALUE with KEY one of %s"
|
|
1068
|
+
% (item, ", ".join(CONTRACT_KEYS)))
|
|
1069
|
+
if value.strip():
|
|
1070
|
+
set_[key] = value.strip()
|
|
1071
|
+
for key in args.unset or []:
|
|
1072
|
+
if key not in CONTRACT_KEYS:
|
|
1073
|
+
fail("lane-set: --unset '%s' is not one of %s" % (key, ", ".join(CONTRACT_KEYS)))
|
|
1074
|
+
if key not in set_ and key in lane:
|
|
1075
|
+
removed.append(key)
|
|
1076
|
+
|
|
1077
|
+
lane.update(set_)
|
|
1078
|
+
for key in removed:
|
|
1079
|
+
del lane[key]
|
|
1080
|
+
if set_ or removed:
|
|
1081
|
+
data["lanes"] = lanes
|
|
1082
|
+
write_stacks(repo, data)
|
|
1083
|
+
emit({
|
|
1084
|
+
"ok": True,
|
|
1085
|
+
"lane_id": lane.get("id", ""),
|
|
1086
|
+
"kind": lane.get("kind", ""),
|
|
1087
|
+
"set": set_,
|
|
1088
|
+
"removed": removed,
|
|
1089
|
+
"lane": lane,
|
|
1090
|
+
"path": STACKS_REL,
|
|
1091
|
+
"written": bool(set_ or removed),
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
|
|
883
1095
|
def profile_of(repo, lane):
|
|
884
1096
|
"""The stack profile a lane names, or {} when it names none the kit ships."""
|
|
885
1097
|
if not lane:
|
|
@@ -893,7 +1105,7 @@ def profile_of(repo, lane):
|
|
|
893
1105
|
def kind_block(repo, lanes, kind):
|
|
894
1106
|
"""Every field a caller needs about ONE kind, flattened and prefixed.
|
|
895
1107
|
|
|
896
|
-
A workflow that wants the service
|
|
1108
|
+
A workflow that wants the service lane root folder binds `$.backend_source_path`
|
|
897
1109
|
and is done — no `--kind` argument, no second call, and no branch on which
|
|
898
1110
|
lane came back. Both kinds are always present in the payload, so the two-lane
|
|
899
1111
|
steps (a gate that needs backend, a report that names both) resolve one script
|
|
@@ -927,14 +1139,27 @@ def kind_block(repo, lanes, kind):
|
|
|
927
1139
|
kind + "_root": root,
|
|
928
1140
|
kind + "_source_path": root,
|
|
929
1141
|
kind + "_root_exists": root_on_disk(repo, root),
|
|
930
|
-
# Where the lane's paired tests go. Equal to the
|
|
1142
|
+
# Where the lane's paired tests go. Equal to the lane root folder when the stack keeps
|
|
931
1143
|
# its tests inside it; see lane_test_path.
|
|
932
1144
|
kind + "_test_path": test_path,
|
|
933
1145
|
kind + "_test_path_exists": root_on_disk(repo, test_path),
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1146
|
+
# Where the lane's commands run, as DECLARED — `./` for the repository root.
|
|
1147
|
+
# Read from the declaration rather than from lane_workdir, which falls back to the
|
|
1148
|
+
# repo root while the lane root folder is not on disk yet; a phase that creates the
|
|
1149
|
+
# project has to be told where its build will run before that path exists.
|
|
1150
|
+
kind + "_workdir": (norm_root(root) or REPO_ROOT)
|
|
1151
|
+
if lane and (lane.get("run_from") or "repo") == "root"
|
|
1152
|
+
else (REPO_ROOT if lane else ""),
|
|
1153
|
+
kind + "_build": lane_command(lane, "build", repo),
|
|
1154
|
+
kind + "_test": lane_command(lane, "test", repo),
|
|
1155
|
+
kind + "_run": lane_command(lane, "run", repo),
|
|
1156
|
+
kind + "_migrate": lane_command(lane, "migrate", repo) or prof.get("migrate", ""),
|
|
1157
|
+
# The API-contract settings, the lane's own or its profile's (lane_value). A
|
|
1158
|
+
# service lane uses the first two and a frontend lane the third; the others read
|
|
1159
|
+
# empty on each.
|
|
1160
|
+
kind + "_contract_run": lane_command(lane, "contract_run", repo),
|
|
1161
|
+
kind + "_contract": lane_value(repo, lane, "contract"),
|
|
1162
|
+
kind + "_client": lane_command(lane, "client", repo),
|
|
938
1163
|
}
|
|
939
1164
|
|
|
940
1165
|
|
|
@@ -956,7 +1181,7 @@ def lane_options(repo, lanes):
|
|
|
956
1181
|
title = (prof.get("title", "") or lane.get("stack", "")
|
|
957
1182
|
or lane.get("profile", "") or "no stack declared")
|
|
958
1183
|
root = norm_root(lane.get("root"))
|
|
959
|
-
where = "the repo root" if root == REPO_ROOT else (root or "no
|
|
1184
|
+
where = "the repo root" if root == REPO_ROOT else (root or "no lane root folder")
|
|
960
1185
|
label = "%s %s at %s" % (lane.get("kind", "lane"), title, where)
|
|
961
1186
|
tests = lane_test_path(repo, lane, prof)
|
|
962
1187
|
if tests and tests != root:
|
|
@@ -1018,6 +1243,7 @@ def cmd_resolve(args):
|
|
|
1018
1243
|
"lane_id": "", "root": "", "source_path": "", "test_path": "", "root_exists": False,
|
|
1019
1244
|
"build": "", "test": "", "run": "", "migrate": "",
|
|
1020
1245
|
"language": "", "ecosystem": "",
|
|
1246
|
+
"contract_doc": "", "contract_doc_exists": False,
|
|
1021
1247
|
"path": STACKS_REL,
|
|
1022
1248
|
}
|
|
1023
1249
|
payload.update(kind_block(args.repo, [], "backend"))
|
|
@@ -1029,12 +1255,13 @@ def cmd_resolve(args):
|
|
|
1029
1255
|
lanes = data.get("lanes") or []
|
|
1030
1256
|
match = {}
|
|
1031
1257
|
# The id may arrive as a whole option line — `user_select` hands back the string it
|
|
1032
|
-
# displayed, label and all — so strip it the way a
|
|
1258
|
+
# displayed, label and all — so strip it the way a lane root folder is stripped.
|
|
1033
1259
|
wanted = strip_root_label(args.id) if args.id else ""
|
|
1034
1260
|
if wanted:
|
|
1035
1261
|
match = next((l for l in lanes if l.get("id") == wanted), {})
|
|
1036
1262
|
elif args.kind:
|
|
1037
1263
|
match = next((l for l in lanes if l.get("kind") == args.kind), {})
|
|
1264
|
+
doc = contract_doc(args.repo, lanes)
|
|
1038
1265
|
|
|
1039
1266
|
# `declared` is about LANES and `exists` about the file — they part company on
|
|
1040
1267
|
# the shipped-but-unfilled seed, which is on disk and declares nothing.
|
|
@@ -1052,13 +1279,16 @@ def cmd_resolve(args):
|
|
|
1052
1279
|
"source_path": match.get("root", ""),
|
|
1053
1280
|
"test_path": lane_test_path(args.repo, match),
|
|
1054
1281
|
"root_exists": root_on_disk(args.repo, match.get("root", "")),
|
|
1055
|
-
"build": lane_command(match, "build"),
|
|
1056
|
-
"test": lane_command(match, "test"),
|
|
1057
|
-
"run": lane_command(match, "run"),
|
|
1058
|
-
"migrate": lane_command(match, "migrate") or profile_of(args.repo, match).get("migrate", ""),
|
|
1282
|
+
"build": lane_command(match, "build", args.repo),
|
|
1283
|
+
"test": lane_command(match, "test", args.repo),
|
|
1284
|
+
"run": lane_command(match, "run", args.repo),
|
|
1285
|
+
"migrate": lane_command(match, "migrate", args.repo) or profile_of(args.repo, match).get("migrate", ""),
|
|
1059
1286
|
"language": profile_of(args.repo, match).get("language", ""),
|
|
1060
1287
|
"ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
|
|
1061
1288
|
"prototype": data.get("prototype", {}),
|
|
1289
|
+
# The service lane's exported API document, and whether an export has written it.
|
|
1290
|
+
"contract_doc": doc,
|
|
1291
|
+
"contract_doc_exists": bool(doc) and os.path.isfile(os.path.join(args.repo, doc)),
|
|
1062
1292
|
"path": STACKS_REL,
|
|
1063
1293
|
}
|
|
1064
1294
|
payload.update(kind_block(args.repo, lanes, "backend"))
|
|
@@ -1077,7 +1307,7 @@ def cmd_resolve(args):
|
|
|
1077
1307
|
# only for the kinds a caller asks for with --require, because "no frontend
|
|
1078
1308
|
# lane" is the correct and common state and must never fail a backend build.
|
|
1079
1309
|
#
|
|
1080
|
-
# BROKEN — a lane IS declared and its
|
|
1310
|
+
# BROKEN — a lane IS declared and its lane root folder is empty or is not a directory.
|
|
1081
1311
|
# Checked for EVERY declared lane whether or not the caller asked about it. A
|
|
1082
1312
|
# stack named against a path that is not there is a mistake in every phase, and
|
|
1083
1313
|
# letting it through means discovering it as an empty build or a generator
|
|
@@ -1106,7 +1336,7 @@ def cmd_verify(args):
|
|
|
1106
1336
|
issues = []
|
|
1107
1337
|
|
|
1108
1338
|
# An empty file and an absent one are the SAME state. The kit ships
|
|
1109
|
-
# stacks.json
|
|
1339
|
+
# stacks.json with an empty `lanes` array so a project can fill it in by hand, so
|
|
1110
1340
|
# the file being there says nothing about whether a lane was declared — only
|
|
1111
1341
|
# its contents do. Both cases report one issue naming which of the two it is.
|
|
1112
1342
|
if not lanes:
|
|
@@ -1130,13 +1360,13 @@ def cmd_verify(args):
|
|
|
1130
1360
|
root = lane.get("root", "")
|
|
1131
1361
|
if not root:
|
|
1132
1362
|
issues.append(
|
|
1133
|
-
"lane '%s' (%s, stack %s) declares no
|
|
1363
|
+
"lane '%s' (%s, stack %s) declares no lane root folder. A lane owns a "
|
|
1134
1364
|
"subtree; without one nothing can be built, run or written into it. "
|
|
1135
|
-
"Re-run `/appweaver select-stack` and give it a
|
|
1365
|
+
"Re-run `/appweaver select-stack` and give it a lane root folder."
|
|
1136
1366
|
% (lane_id, kind, lane.get("profile", "?")))
|
|
1137
1367
|
elif not args.allow_missing_root and not root_on_disk(repo, root):
|
|
1138
1368
|
issues.append(
|
|
1139
|
-
"lane '%s' (%s, stack %s) declares
|
|
1369
|
+
"lane '%s' (%s, stack %s) declares lane root folder '%s', which is not a "
|
|
1140
1370
|
"directory in this repository. Re-run `/appweaver select-stack` and "
|
|
1141
1371
|
"point the lane at the directory that holds its source."
|
|
1142
1372
|
% (lane_id, kind, lane.get("profile", "?"), root))
|
|
@@ -1144,7 +1374,7 @@ def cmd_verify(args):
|
|
|
1144
1374
|
# hand-edited file — a supported way to fill this in, and the one path that
|
|
1145
1375
|
# does not pass the writer. The invariant is worth a second check anywhere:
|
|
1146
1376
|
# two lanes sharing a subtree means two generators can write one file. A lane
|
|
1147
|
-
# owns its test path as well as its
|
|
1377
|
+
# owns its test path as well as its lane root folder, so both are compared.
|
|
1148
1378
|
for i, first in enumerate(lanes):
|
|
1149
1379
|
for second in lanes[i + 1:]:
|
|
1150
1380
|
clash = next(((fl, fp, sl, sp)
|
|
@@ -1154,7 +1384,7 @@ def cmd_verify(args):
|
|
|
1154
1384
|
if clash:
|
|
1155
1385
|
issues.append(
|
|
1156
1386
|
"lanes '%s' and '%s' claim overlapping subtrees: %s '%s' and %s '%s'. "
|
|
1157
|
-
"A lane owns its
|
|
1387
|
+
"A lane owns its lane root folder and its test path, and they must be "
|
|
1158
1388
|
"disjoint from every other lane's, or two generators can write the "
|
|
1159
1389
|
"same file. Give each lane paths outside the other's."
|
|
1160
1390
|
% (first.get("id", "?"), second.get("id", "?"),
|
|
@@ -1191,7 +1421,7 @@ def cmd_verify(args):
|
|
|
1191
1421
|
# ────────────────────────────── run-phase ──────────────────────────────────
|
|
1192
1422
|
# Execute a phase's project-supplied command for EVERY declared lane.
|
|
1193
1423
|
#
|
|
1194
|
-
# This is the payoff of declaring a
|
|
1424
|
+
# This is the payoff of declaring a lane root folder. Without a declaration, `build`
|
|
1195
1425
|
# sniffs: it looks for a .sln, then for a package.json, and takes the first hit.
|
|
1196
1426
|
# On a single-lane repo that is right. On a two-lane repo it is wrong in the worst
|
|
1197
1427
|
# way available — it builds the backend, reports green, and never mentions that the
|
|
@@ -1256,21 +1486,21 @@ def cmd_run_phase(args):
|
|
|
1256
1486
|
testlog = _testlog()
|
|
1257
1487
|
ok, ran = True, False
|
|
1258
1488
|
for lane in lanes:
|
|
1259
|
-
cmd = lane_command(lane, args.phase)
|
|
1489
|
+
cmd = lane_command(lane, args.phase, repo)
|
|
1260
1490
|
lane_id, root = lane.get("id", "?"), lane.get("root", "")
|
|
1261
1491
|
if not cmd:
|
|
1262
1492
|
results.append({"lane": lane_id, "root": root, "cmd": "",
|
|
1263
1493
|
"ran": False, "ok": True, "skipped": "no %s command" % args.phase})
|
|
1264
1494
|
continue
|
|
1265
|
-
# The
|
|
1495
|
+
# The lane root folder has to be there before a command is run against it.
|
|
1266
1496
|
# Reported per lane rather than raised, so a two-lane run still reports
|
|
1267
1497
|
# what the other lane did.
|
|
1268
1498
|
if not root_on_disk(repo, root):
|
|
1269
1499
|
ok = False
|
|
1270
1500
|
results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
|
|
1271
|
-
"error": "
|
|
1272
|
-
tails.append("[%s]
|
|
1273
|
-
excerpts.append("[%s]
|
|
1501
|
+
"error": "lane root folder '%s' is not a directory" % root})
|
|
1502
|
+
tails.append("[%s] lane root folder '%s' is not a directory" % (lane_id, root))
|
|
1503
|
+
excerpts.append("[%s] lane root folder '%s' is not a directory — nothing ran"
|
|
1274
1504
|
% (lane_id, root))
|
|
1275
1505
|
failing.append(lane_id)
|
|
1276
1506
|
continue
|
|
@@ -1337,6 +1567,329 @@ def cmd_run_phase(args):
|
|
|
1337
1567
|
})
|
|
1338
1568
|
|
|
1339
1569
|
|
|
1570
|
+
# ─────────────────────────── contract-export ───────────────────────────────
|
|
1571
|
+
# The service lane's API document, taken from the service itself. A document served by a
|
|
1572
|
+
# running service is the one thing every web stack can produce the same way — Swagger,
|
|
1573
|
+
# springdoc, FastAPI and the rest all serve one — so the export starts the service with
|
|
1574
|
+
# the lane's `contract_run`, fetches `contract`, and stops it. How a stack (or the pack
|
|
1575
|
+
# the lane was scaffolded from) has to be started is the command's business, not this
|
|
1576
|
+
# code's.
|
|
1577
|
+
|
|
1578
|
+
CONTRACT_HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _fetch(url, timeout=5):
|
|
1582
|
+
"""(status, body bytes, error text). A local https service usually has a development
|
|
1583
|
+
certificate, so certificate checks are skipped for localhost only."""
|
|
1584
|
+
import ssl
|
|
1585
|
+
import urllib.error
|
|
1586
|
+
import urllib.parse
|
|
1587
|
+
import urllib.request
|
|
1588
|
+
ctx = None
|
|
1589
|
+
if url.lower().startswith("https://"):
|
|
1590
|
+
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
|
1591
|
+
if host in ("localhost", "127.0.0.1", "::1"):
|
|
1592
|
+
ctx = ssl._create_unverified_context()
|
|
1593
|
+
try:
|
|
1594
|
+
with urllib.request.urlopen(url, timeout=timeout, context=ctx) as resp:
|
|
1595
|
+
return resp.status, resp.read(), ""
|
|
1596
|
+
except urllib.error.HTTPError as e:
|
|
1597
|
+
return e.code, b"", "HTTP %d" % e.code
|
|
1598
|
+
except Exception as e: # refused, reset, timed out — the service is not up yet
|
|
1599
|
+
return 0, b"", str(getattr(e, "reason", e))
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def _api_document(body):
|
|
1603
|
+
"""The parsed document, or None when the body is not an OpenAPI/Swagger document."""
|
|
1604
|
+
try:
|
|
1605
|
+
doc = json.loads(body.decode("utf-8-sig"))
|
|
1606
|
+
except (ValueError, UnicodeDecodeError):
|
|
1607
|
+
return None
|
|
1608
|
+
if isinstance(doc, dict) and ("openapi" in doc or "swagger" in doc) \
|
|
1609
|
+
and isinstance(doc.get("paths"), dict):
|
|
1610
|
+
return doc
|
|
1611
|
+
return None
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _operations(doc):
|
|
1615
|
+
"""Every operation in a document: [{"id", "method", "path"}], in document order."""
|
|
1616
|
+
ops = []
|
|
1617
|
+
for path, item in (doc.get("paths") or {}).items():
|
|
1618
|
+
if not isinstance(item, dict):
|
|
1619
|
+
continue
|
|
1620
|
+
for method in CONTRACT_HTTP_METHODS:
|
|
1621
|
+
op = item.get(method)
|
|
1622
|
+
if isinstance(op, dict):
|
|
1623
|
+
ops.append({"id": str(op.get("operationId") or ""),
|
|
1624
|
+
"method": method.upper(), "path": path})
|
|
1625
|
+
return ops
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _start(cmd, cwd, log):
|
|
1629
|
+
import subprocess
|
|
1630
|
+
kw = {"shell": True, "cwd": cwd, "stdout": log, "stderr": subprocess.STDOUT}
|
|
1631
|
+
if os.name == "nt":
|
|
1632
|
+
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
1633
|
+
else:
|
|
1634
|
+
kw["start_new_session"] = True # its own process group, so the stop reaches children
|
|
1635
|
+
return subprocess.Popen(cmd, **kw)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _stop(proc):
|
|
1639
|
+
"""Stop the service and everything it started — `dotnet run` and `npm run` both run
|
|
1640
|
+
the real server as a child, which outlives a kill of the parent alone."""
|
|
1641
|
+
import signal
|
|
1642
|
+
import subprocess
|
|
1643
|
+
if proc.poll() is not None:
|
|
1644
|
+
return
|
|
1645
|
+
if os.name == "nt":
|
|
1646
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
1647
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
1648
|
+
else:
|
|
1649
|
+
try:
|
|
1650
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
1651
|
+
except OSError:
|
|
1652
|
+
pass
|
|
1653
|
+
try:
|
|
1654
|
+
proc.wait(timeout=15)
|
|
1655
|
+
except subprocess.TimeoutExpired:
|
|
1656
|
+
try:
|
|
1657
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
1658
|
+
except OSError:
|
|
1659
|
+
pass
|
|
1660
|
+
try:
|
|
1661
|
+
proc.wait(timeout=15)
|
|
1662
|
+
except Exception:
|
|
1663
|
+
pass
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def _tail(path, budget=LANE_TAIL):
|
|
1667
|
+
try:
|
|
1668
|
+
with open(path, "rb") as fh:
|
|
1669
|
+
return fh.read().decode("utf-8", "replace")[-budget:].strip()
|
|
1670
|
+
except OSError:
|
|
1671
|
+
return ""
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def cmd_contract_export(args):
|
|
1675
|
+
import tempfile
|
|
1676
|
+
import time
|
|
1677
|
+
|
|
1678
|
+
repo = args.repo
|
|
1679
|
+
lanes = (read_stacks(repo) or {}).get("lanes") or []
|
|
1680
|
+
lane = next((l for l in lanes if l.get("kind") == "backend"), {})
|
|
1681
|
+
doc_rel = contract_doc(repo, lanes)
|
|
1682
|
+
doc_abs = os.path.join(repo, doc_rel) if doc_rel else ""
|
|
1683
|
+
cmd = lane_command(lane, "contract_run", repo)
|
|
1684
|
+
url = lane_value(repo, lane, "contract")
|
|
1685
|
+
result = {
|
|
1686
|
+
"ok": False, "ran": False, "lane": lane.get("id", ""), "cmd": cmd, "url": url,
|
|
1687
|
+
"cwd": "", "doc": doc_rel, "doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
|
|
1688
|
+
"operations": 0, "unnamed": 0, "seconds": 0, "already_running": False,
|
|
1689
|
+
"reason": "", "log_tail": "",
|
|
1690
|
+
# The placeholder a `client` command names the document by, spelled out for a
|
|
1691
|
+
# prompt that asks for one — a workflow cannot write it literally, since the
|
|
1692
|
+
# engine would read it as a variable.
|
|
1693
|
+
"placeholder": "{{contract}}",
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
def done(**kw):
|
|
1697
|
+
result.update(kw)
|
|
1698
|
+
result["doc_exists"] = bool(doc_abs) and os.path.isfile(doc_abs)
|
|
1699
|
+
# One line on which document a reader now has, for a prompt to quote.
|
|
1700
|
+
if result["ok"]:
|
|
1701
|
+
result["status"] = "%s, exported from the built service in this run" % doc_rel
|
|
1702
|
+
elif result["doc_exists"]:
|
|
1703
|
+
result["status"] = ("%s, from an earlier export — this run's failed, so it may "
|
|
1704
|
+
"be older than the code" % doc_rel)
|
|
1705
|
+
else:
|
|
1706
|
+
result["status"] = ""
|
|
1707
|
+
emit(result)
|
|
1708
|
+
|
|
1709
|
+
if not lane:
|
|
1710
|
+
return done(reason="no service lane is declared in %s" % STACKS_REL)
|
|
1711
|
+
if not root_on_disk(repo, lane.get("root", "")):
|
|
1712
|
+
return done(reason="the service lane root folder '%s' is not on disk yet"
|
|
1713
|
+
% lane.get("root", ""))
|
|
1714
|
+
if not url:
|
|
1715
|
+
return done(reason="the service lane has no `contract` URL — set it in %s, or in "
|
|
1716
|
+
"its stack profile" % STACKS_REL)
|
|
1717
|
+
|
|
1718
|
+
# A service already answering at the URL is used as it is: a second instance could
|
|
1719
|
+
# not bind the port anyway. It may be running older code than this run built, which
|
|
1720
|
+
# the caller is told.
|
|
1721
|
+
status, body, _err = _fetch(url)
|
|
1722
|
+
proc, log_path, started, exited = None, "", time.time(), None
|
|
1723
|
+
if status != 200 or _api_document(body) is None:
|
|
1724
|
+
if not cmd:
|
|
1725
|
+
return done(reason="the service lane has no `contract_run` command to start it "
|
|
1726
|
+
"— set it in %s, or in its stack profile" % STACKS_REL)
|
|
1727
|
+
workdir = lane_workdir(repo, lane)
|
|
1728
|
+
result["cwd"] = os.path.relpath(workdir, repo).replace("\\", "/")
|
|
1729
|
+
fd, log_path = tempfile.mkstemp(prefix="aw-contract-", suffix=".log")
|
|
1730
|
+
log = os.fdopen(fd, "wb")
|
|
1731
|
+
try:
|
|
1732
|
+
proc = _start(cmd, workdir, log)
|
|
1733
|
+
result["ran"] = True
|
|
1734
|
+
deadline = started + args.timeout
|
|
1735
|
+
status, body = 0, b""
|
|
1736
|
+
while time.time() < deadline:
|
|
1737
|
+
if proc.poll() is not None:
|
|
1738
|
+
exited = proc.returncode
|
|
1739
|
+
break
|
|
1740
|
+
status, body, _err = _fetch(url)
|
|
1741
|
+
if status == 200 and _api_document(body) is not None:
|
|
1742
|
+
break
|
|
1743
|
+
time.sleep(2)
|
|
1744
|
+
finally:
|
|
1745
|
+
if proc is not None:
|
|
1746
|
+
_stop(proc)
|
|
1747
|
+
log.close()
|
|
1748
|
+
else:
|
|
1749
|
+
result["already_running"] = True
|
|
1750
|
+
|
|
1751
|
+
seconds = int(time.time() - started)
|
|
1752
|
+
tail = _tail(log_path) if log_path else ""
|
|
1753
|
+
if log_path:
|
|
1754
|
+
try:
|
|
1755
|
+
os.remove(log_path)
|
|
1756
|
+
except OSError:
|
|
1757
|
+
pass
|
|
1758
|
+
doc = _api_document(body) if status == 200 else None
|
|
1759
|
+
if doc is None:
|
|
1760
|
+
if exited is not None:
|
|
1761
|
+
why = "`%s` exited (code %s) before %s answered" % (cmd, exited, url)
|
|
1762
|
+
elif status == 200:
|
|
1763
|
+
why = "%s answered, but not with an OpenAPI or Swagger JSON document" % url
|
|
1764
|
+
elif status:
|
|
1765
|
+
why = "%s answered HTTP %d for %d s" % (url, status, seconds)
|
|
1766
|
+
else:
|
|
1767
|
+
why = "nothing answered at %s within %d s" % (url, args.timeout)
|
|
1768
|
+
return done(seconds=seconds, log_tail=tail,
|
|
1769
|
+
reason=why + (" — the last exported document is kept" if result["doc_exists"] else ""))
|
|
1770
|
+
|
|
1771
|
+
os.makedirs(os.path.dirname(doc_abs), exist_ok=True)
|
|
1772
|
+
with open(doc_abs, "w", encoding="utf-8") as fh:
|
|
1773
|
+
json.dump(doc, fh, indent=2)
|
|
1774
|
+
fh.write("\n")
|
|
1775
|
+
ops = _operations(doc)
|
|
1776
|
+
note = ("fetched from a service that was already running at %s — it may be older than "
|
|
1777
|
+
"the code this run built" % url) if result["already_running"] else ""
|
|
1778
|
+
done(ok=True, seconds=seconds, operations=len(ops),
|
|
1779
|
+
unnamed=sum(1 for o in ops if not o["id"]), reason=note, log_tail="")
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
# ─────────────────────────── contract-check ────────────────────────────────
|
|
1783
|
+
# Do the operation ids a frontend plan cites exist in the exported document? Read by
|
|
1784
|
+
# script, so the answer does not depend on a model reading two documents carefully.
|
|
1785
|
+
#
|
|
1786
|
+
# The ids are read from two table shapes, the two plan templates' own:
|
|
1787
|
+
# - the master plan's §5, a table with an `Operation id` column;
|
|
1788
|
+
# - a delta plan's ADDED/MODIFIED tables, where a row whose section is `Contract
|
|
1789
|
+
# Binding` names the operation id as its element's first `code` span.
|
|
1790
|
+
|
|
1791
|
+
_OP_ID = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
|
1792
|
+
_NOT_AN_ID = {"none", "tbd", "todo", "na"}
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _cells(line):
|
|
1796
|
+
s = line.strip()
|
|
1797
|
+
if not s.startswith("|"):
|
|
1798
|
+
return None
|
|
1799
|
+
return [c.strip() for c in s.strip("|").split("|")]
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _is_rule(cells):
|
|
1803
|
+
return bool(cells) and all(re.fullmatch(r":?-{3,}:?", c or "") for c in cells)
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
def _op_ids(text):
|
|
1807
|
+
"""The operation ids a cell names: `code` spans first, else bare words. Placeholders
|
|
1808
|
+
(`[OperationId]`), dashes and anything with a space are not ids."""
|
|
1809
|
+
spans = re.findall(r"`([^`]+)`", text)
|
|
1810
|
+
tokens = [t.strip() for t in (spans if spans else re.split(r"[,·;]\s*|\s+", text))]
|
|
1811
|
+
return [t for t in tokens if _OP_ID.match(t) and t.lower() not in _NOT_AN_ID]
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def plan_operation_ids(text):
|
|
1815
|
+
"""(cited ids in order, rows with a binding but no id)."""
|
|
1816
|
+
lines = text.splitlines()
|
|
1817
|
+
cited, uncited = [], 0
|
|
1818
|
+
i = 0
|
|
1819
|
+
while i < len(lines):
|
|
1820
|
+
header = _cells(lines[i])
|
|
1821
|
+
rule = _cells(lines[i + 1]) if i + 1 < len(lines) else None
|
|
1822
|
+
if header is None or rule is None or not _is_rule(rule):
|
|
1823
|
+
i += 1
|
|
1824
|
+
continue
|
|
1825
|
+
lowered = [h.lower() for h in header]
|
|
1826
|
+
id_col = next((n for n, h in enumerate(lowered) if h.replace("-", " ") == "operation id"), -1)
|
|
1827
|
+
section_col = next((n for n, h in enumerate(lowered) if h.startswith("section")), -1)
|
|
1828
|
+
i += 2
|
|
1829
|
+
while i < len(lines) and _cells(lines[i]) is not None:
|
|
1830
|
+
row = _cells(lines[i])
|
|
1831
|
+
i += 1
|
|
1832
|
+
if id_col >= 0 and id_col < len(row):
|
|
1833
|
+
ids = _op_ids(row[id_col])
|
|
1834
|
+
elif section_col >= 0 and section_col < len(row) \
|
|
1835
|
+
and "contract binding" in row[section_col].lower() and section_col + 1 < len(row):
|
|
1836
|
+
spans = re.findall(r"`([^`]+)`", row[section_col + 1])
|
|
1837
|
+
ids = [s for s in spans[:1] if _OP_ID.match(s)]
|
|
1838
|
+
else:
|
|
1839
|
+
continue
|
|
1840
|
+
if ids:
|
|
1841
|
+
cited.extend(x for x in ids if x not in cited)
|
|
1842
|
+
else:
|
|
1843
|
+
uncited += 1
|
|
1844
|
+
return cited, uncited
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def cmd_contract_check(args):
|
|
1848
|
+
repo = args.repo
|
|
1849
|
+
plan_abs = args.plan if os.path.isabs(args.plan) else os.path.join(repo, args.plan)
|
|
1850
|
+
doc_rel = args.doc or contract_doc(repo)
|
|
1851
|
+
doc_abs = (doc_rel if os.path.isabs(doc_rel) else os.path.join(repo, doc_rel)) if doc_rel else ""
|
|
1852
|
+
result = {"ok": False, "plan": args.plan, "doc": doc_rel,
|
|
1853
|
+
"doc_exists": bool(doc_abs) and os.path.isfile(doc_abs),
|
|
1854
|
+
"cited": 0, "ids": [], "missing": [], "uncited": 0,
|
|
1855
|
+
"doc_operations": 0, "doc_unnamed": 0, "reason": ""}
|
|
1856
|
+
if not os.path.isfile(plan_abs):
|
|
1857
|
+
result["reason"] = "the frontend plan %s is not on disk" % args.plan
|
|
1858
|
+
return emit(result)
|
|
1859
|
+
with open(plan_abs, encoding="utf-8", errors="replace") as fh:
|
|
1860
|
+
cited, uncited = plan_operation_ids(fh.read())
|
|
1861
|
+
result.update(cited=len(cited), ids=cited, uncited=uncited)
|
|
1862
|
+
if not result["doc_exists"]:
|
|
1863
|
+
result["reason"] = "no exported API document at %s" % (doc_rel or "(no service lane)")
|
|
1864
|
+
return emit(result)
|
|
1865
|
+
with open(doc_abs, "rb") as fh:
|
|
1866
|
+
doc = _api_document(fh.read())
|
|
1867
|
+
if doc is None:
|
|
1868
|
+
result["reason"] = "%s is not an OpenAPI or Swagger JSON document" % doc_rel
|
|
1869
|
+
return emit(result)
|
|
1870
|
+
ops = _operations(doc)
|
|
1871
|
+
known = {o["id"] for o in ops if o["id"]}
|
|
1872
|
+
lower = {k.lower(): k for k in known}
|
|
1873
|
+
missing = []
|
|
1874
|
+
for x in cited:
|
|
1875
|
+
if x not in known:
|
|
1876
|
+
near = lower.get(x.lower())
|
|
1877
|
+
missing.append(x + (" (the document has %s)" % near if near else ""))
|
|
1878
|
+
result.update(missing=missing, doc_operations=len(ops),
|
|
1879
|
+
doc_unnamed=sum(1 for o in ops if not o["id"]))
|
|
1880
|
+
parts = []
|
|
1881
|
+
if missing:
|
|
1882
|
+
parts.append("%d cited operation id(s) are not in %s: %s"
|
|
1883
|
+
% (len(missing), doc_rel, ", ".join(missing)))
|
|
1884
|
+
if not cited:
|
|
1885
|
+
parts.append("the plan cites no operation id")
|
|
1886
|
+
if uncited:
|
|
1887
|
+
parts.append("%d binding row(s) cite no operation id" % uncited)
|
|
1888
|
+
result["reason"] = "; ".join(parts)
|
|
1889
|
+
result["ok"] = bool(cited) and not missing
|
|
1890
|
+
emit(result)
|
|
1891
|
+
|
|
1892
|
+
|
|
1340
1893
|
# ──────────────────────────────── probe ────────────────────────────────────
|
|
1341
1894
|
|
|
1342
1895
|
def profile_terms(prof):
|
|
@@ -1449,14 +2002,14 @@ def main():
|
|
|
1449
2002
|
"evidence there, so a service profile cannot relocate onto the UI's files")
|
|
1450
2003
|
p.set_defaults(func=cmd_detect)
|
|
1451
2004
|
|
|
1452
|
-
p = sub.add_parser("declare", help="upsert a lane into stacks.json
|
|
2005
|
+
p = sub.add_parser("declare", help="upsert a whole lane into stacks.json")
|
|
1453
2006
|
p.add_argument("--profile", required=True,
|
|
1454
2007
|
help="profile id, a select_options label, 'none', or 'detected' "
|
|
1455
2008
|
"(a stack the kit ships no profile for — needs --stack and --root)")
|
|
1456
2009
|
p.add_argument("--kind", choices=["backend", "frontend"])
|
|
1457
2010
|
p.add_argument("--id", help="lane id (default: service for backend, web for frontend)")
|
|
1458
2011
|
p.add_argument("--root", "--source-path", dest="root",
|
|
1459
|
-
help="the
|
|
2012
|
+
help="the LANE ROOT FOLDER — the subtree it owns, overriding the "
|
|
1460
2013
|
"profile's default root. The two spellings are one field")
|
|
1461
2014
|
p.add_argument("--run-from", dest="run_from", choices=["repo", "root"],
|
|
1462
2015
|
help="where this lane's build/test/run commands execute: 'repo' (default) "
|
|
@@ -1466,7 +2019,7 @@ def main():
|
|
|
1466
2019
|
"subtree (a Maven wrapper at backend/<app>/mvnw). Defaults to the "
|
|
1467
2020
|
"profile's own setting")
|
|
1468
2021
|
p.add_argument("--require-existing-root", dest="require_existing_root", action="store_true",
|
|
1469
|
-
help="refuse a
|
|
2022
|
+
help="refuse a lane root folder that is not a directory. For a caller about "
|
|
1470
2023
|
"to READ the subtree (onboard); greenfield declares a path that "
|
|
1471
2024
|
"implement has not created yet, and leaves this off")
|
|
1472
2025
|
p.add_argument("--stack", default="",
|
|
@@ -1498,7 +2051,7 @@ def main():
|
|
|
1498
2051
|
help="a lane kind this phase cannot run without; repeatable. Omit to "
|
|
1499
2052
|
"check only that whatever IS declared is sound")
|
|
1500
2053
|
p.add_argument("--allow-missing-root", dest="allow_missing_root", action="store_true",
|
|
1501
|
-
help="do not require the
|
|
2054
|
+
help="do not require the lane root folders to exist on disk yet — the "
|
|
1502
2055
|
"greenfield state, where select-stack declares a path implement "
|
|
1503
2056
|
"will create")
|
|
1504
2057
|
p.add_argument("--command", default="",
|
|
@@ -1506,12 +2059,36 @@ def main():
|
|
|
1506
2059
|
p.set_defaults(func=cmd_verify)
|
|
1507
2060
|
|
|
1508
2061
|
p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
|
|
1509
|
-
p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate"],
|
|
1510
|
-
help="which project-supplied command to run"
|
|
2062
|
+
p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate", "client"],
|
|
2063
|
+
help="which project-supplied command to run; `client` is a frontend lane's "
|
|
2064
|
+
"client generator, with {{contract}} expanded")
|
|
1511
2065
|
p.add_argument("--kind", choices=["backend", "frontend"],
|
|
1512
2066
|
help="restrict to one lane kind (default: every declared lane)")
|
|
1513
2067
|
p.set_defaults(func=cmd_run_phase)
|
|
1514
2068
|
|
|
2069
|
+
p = sub.add_parser("set", help="change API-contract keys on a declared lane")
|
|
2070
|
+
p.add_argument("--id", default="", help="the lane id")
|
|
2071
|
+
p.add_argument("--kind", choices=["backend", "frontend"],
|
|
2072
|
+
help="the lane of this kind, when --id is not given")
|
|
2073
|
+
p.add_argument("--set", action="append", default=[], metavar="KEY=VALUE",
|
|
2074
|
+
help="repeatable; KEY is one of %s, and an empty VALUE changes nothing"
|
|
2075
|
+
% ", ".join(CONTRACT_KEYS))
|
|
2076
|
+
p.add_argument("--unset", action="append", default=[], metavar="KEY",
|
|
2077
|
+
help="repeatable; remove KEY, so the lane reads its profile's value again")
|
|
2078
|
+
p.set_defaults(func=cmd_set)
|
|
2079
|
+
|
|
2080
|
+
p = sub.add_parser("contract-export",
|
|
2081
|
+
help="start the service lane, save its API document, stop it")
|
|
2082
|
+
p.add_argument("--timeout", type=int, default=180,
|
|
2083
|
+
help="seconds to wait for the document after starting the service")
|
|
2084
|
+
p.set_defaults(func=cmd_contract_export)
|
|
2085
|
+
|
|
2086
|
+
p = sub.add_parser("contract-check",
|
|
2087
|
+
help="the operation ids a frontend plan cites vs the exported document")
|
|
2088
|
+
p.add_argument("--plan", required=True, help="the frontend plan (master or delta)")
|
|
2089
|
+
p.add_argument("--doc", default="", help="the API document (default: the exported one)")
|
|
2090
|
+
p.set_defaults(func=cmd_contract_check)
|
|
2091
|
+
|
|
1515
2092
|
p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
|
|
1516
2093
|
p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
|
|
1517
2094
|
p.add_argument("--hint", default="", help="free prose to scan for a stack name")
|