aw-spec-cli 0.1.8 → 0.1.9
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/.appweaver/DESIGN-FORMAT.md +200 -0
- package/assets/.appweaver/ORCH-DIALECT.md +156 -0
- package/assets/.appweaver/RULE-PARTITION.md +175 -0
- package/assets/.appweaver/STACK-PROFILES.md +461 -0
- package/assets/.appweaver/ai-rules.md +180 -0
- package/assets/.appweaver/domain.json +1 -0
- package/assets/.appweaver/extensions/.registry +19 -0
- package/assets/.appweaver/extensions/agent-context/README.md +57 -0
- package/assets/.appweaver/extensions/agent-context/agent-context-config.yml +4 -0
- package/assets/.appweaver/extensions/agent-context/commands/aw-spec.agent-context.update.md +26 -0
- package/assets/.appweaver/extensions/agent-context/extension.yml +37 -0
- package/assets/.appweaver/extensions/agent-context/scripts/bash/update-agent-context.sh +201 -0
- package/assets/.appweaver/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +237 -0
- package/assets/.appweaver/extensions.yml +16 -0
- package/assets/.appweaver/feature.json +3 -0
- package/assets/.appweaver/init-options.json +9 -0
- package/assets/.appweaver/integration.json +15 -0
- package/assets/.appweaver/integrations/aw-spec.manifest.json +9 -0
- package/assets/.appweaver/interpreter/run-skill.py +1176 -0
- package/assets/.appweaver/memory/constitution.md +50 -0
- package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +6 -0
- package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +94 -0
- package/assets/.appweaver/orchestrator/README.md +300 -0
- package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +74 -0
- package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +74 -0
- package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +105 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +101 -0
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +90 -0
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +126 -0
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +123 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +1721 -0
- package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +40 -0
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +101 -0
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +245 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +63 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +59 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
- package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +63 -0
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +116 -0
- package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
- package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +125 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +123 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +294 -0
- package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +160 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +270 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +934 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +251 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +265 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +437 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +197 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +475 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +727 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +100 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +462 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +265 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +253 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +267 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +287 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +238 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1822 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +194 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +83 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +296 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +439 -0
- package/assets/.appweaver/scripts/bash/aw-inventory.sh +17 -0
- package/assets/.appweaver/scripts/bash/aw-parity.sh +13 -0
- package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +13 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2626 -0
- package/assets/.appweaver/scripts/bash/aw-xref.sh +15 -0
- package/assets/.appweaver/scripts/bash/common.sh +533 -0
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +17 -0
- package/assets/.appweaver/scripts/powershell/aw-parity.ps1 +13 -0
- package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +13 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2232 -0
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +15 -0
- package/assets/.appweaver/scripts/python/aw-component-catalog.py +121 -0
- package/assets/.appweaver/scripts/python/aw-inventory.py +1180 -0
- package/assets/.appweaver/scripts/python/aw-parity.py +380 -0
- package/assets/.appweaver/scripts/python/aw-rev-constitution.py +329 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +1251 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +1330 -0
- package/assets/.appweaver/scripts/python/validate_okf.py +352 -0
- package/assets/.appweaver/stack-profiles/angular.json +16 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +17 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +15 -0
- package/assets/.appweaver/stack-profiles/go-chi.json +14 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +16 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +16 -0
- package/assets/.appweaver/stack-profiles/node-express.json +15 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +17 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +16 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +17 -0
- package/assets/.appweaver/stacks.json +3 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +116 -0
- package/assets/.appweaver/templates/constitution-technical-template.md +108 -0
- package/assets/.appweaver/templates/constitution-ui-template.md +116 -0
- package/assets/.appweaver/templates/delta-design-template.md +69 -0
- package/assets/.appweaver/templates/delta-spec-template.md +63 -0
- package/assets/.appweaver/templates/design-template.md +383 -0
- package/assets/.appweaver/templates/okf-concept-template.md +114 -0
- package/assets/.appweaver/templates/parity-manifest-template.json +49 -0
- package/assets/.appweaver/templates/source-map-template.md +85 -0
- package/assets/.appweaver/templates/spec-template.md +309 -0
- package/assets/.appweaver/templates/ui-design-template.md +273 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.pack.txt +25248 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +5726 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-foundation.ps1 +145 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-foundation.sh +196 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.ps1 +157 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.sh +209 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.ps1 +126 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.sh +217 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.ps1 +129 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.sh +218 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.pack.txt +19318 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2833 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-foundation.ps1 +145 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-foundation.sh +196 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.ps1 +157 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.sh +209 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.ps1 +126 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.sh +217 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.ps1 +129 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.sh +218 -0
- package/assets/foundations/dotnet/components/Email/README.md +119 -0
- package/assets/foundations/dotnet/components/Email/ReferenceEndpoints/SendEmailEndpoint.cs +84 -0
- package/assets/foundations/dotnet/components/Email/integration.md +300 -0
- package/assets/foundations/dotnet/components/Jobs/README.md +113 -0
- package/assets/foundations/dotnet/components/Jobs/integration.md +259 -0
- package/assets/foundations/dotnet/components/QrGenerator/README.md +85 -0
- package/assets/foundations/dotnet/components/QrGenerator/ReferenceEndpoints/GenerateQrEndpoint.cs +57 -0
- package/assets/foundations/dotnet/components/QrGenerator/integration.md +230 -0
- package/assets/foundations/dotnet/components/README.md +74 -0
- package/assets/guardrails/README.md +222 -0
- package/assets/guardrails/domain/healthcare/ai-rules.domain.md +34 -0
- package/assets/guardrails/domain/healthcare/api-versioning.domain.md +17 -0
- package/assets/guardrails/domain/healthcare/auditing.domain.md +97 -0
- package/assets/guardrails/domain/healthcare/authorization.domain.md +26 -0
- package/assets/guardrails/domain/healthcare/clinical-terms.md +20 -0
- package/assets/guardrails/domain/healthcare/coding-standards.domain.md +26 -0
- package/assets/guardrails/domain/healthcare/consent.md +19 -0
- package/assets/guardrails/domain/healthcare/cqrs.domain.md +12 -0
- package/assets/guardrails/domain/healthcare/data-classification.domain.md +28 -0
- package/assets/guardrails/domain/healthcare/dependency-policy.domain.md +11 -0
- package/assets/guardrails/domain/healthcare/dicom.md +15 -0
- package/assets/guardrails/domain/healthcare/domain-constitution.md +119 -0
- package/assets/guardrails/domain/healthcare/domain-driven-design.domain.md +28 -0
- package/assets/guardrails/domain/healthcare/eventing.domain.md +18 -0
- package/assets/guardrails/domain/healthcare/fhir.md +36 -0
- package/assets/guardrails/domain/healthcare/folder-structure.domain.md +25 -0
- package/assets/guardrails/domain/healthcare/hipaa.md +34 -0
- package/assets/guardrails/domain/healthcare/hl7.md +15 -0
- package/assets/guardrails/domain/healthcare/ihe-conformance.md +31 -0
- package/assets/guardrails/domain/healthcare/integration-terms.md +21 -0
- package/assets/guardrails/domain/healthcare/persistence.domain.md +20 -0
- package/assets/guardrails/domain/healthcare/terminology.md +30 -0
- package/assets/guardrails/domain/healthcare/testing.domain.md +15 -0
- package/assets/guardrails/domain/healthcare/ux/accessibility-standards.md +36 -0
- package/assets/guardrails/domain/healthcare/ux/content-design.md +32 -0
- package/assets/guardrails/domain/healthcare/ux/interaction-states.md +44 -0
- package/assets/guardrails/domain/healthcare/ux/journey-mapping.md +40 -0
- package/assets/guardrails/domain/healthcare/vertical-slices.domain.md +18 -0
- package/assets/guardrails/domain/healthcare/zero-trust.domain.md +12 -0
- package/assets/guardrails/domain/pharma/ai-rules.domain.md +32 -0
- package/assets/guardrails/domain/pharma/api-versioning.domain.md +20 -0
- package/assets/guardrails/domain/pharma/atc.md +22 -0
- package/assets/guardrails/domain/pharma/auditing.domain.md +90 -0
- package/assets/guardrails/domain/pharma/authorization.domain.md +25 -0
- package/assets/guardrails/domain/pharma/coding-standards.domain.md +28 -0
- package/assets/guardrails/domain/pharma/cqrs.domain.md +16 -0
- package/assets/guardrails/domain/pharma/data-classification.domain.md +24 -0
- package/assets/guardrails/domain/pharma/data-provenance.md +21 -0
- package/assets/guardrails/domain/pharma/dependency-policy.domain.md +11 -0
- package/assets/guardrails/domain/pharma/domain-constitution.md +113 -0
- package/assets/guardrails/domain/pharma/domain-driven-design.domain.md +36 -0
- package/assets/guardrails/domain/pharma/domain-requirements.sample.md +103 -0
- package/assets/guardrails/domain/pharma/eventing.domain.md +18 -0
- package/assets/guardrails/domain/pharma/fhir.md +31 -0
- package/assets/guardrails/domain/pharma/folder-structure.domain.md +26 -0
- package/assets/guardrails/domain/pharma/hl7.md +17 -0
- package/assets/guardrails/domain/pharma/idmp.md +26 -0
- package/assets/guardrails/domain/pharma/integration-terms.md +21 -0
- package/assets/guardrails/domain/pharma/ndc.md +28 -0
- package/assets/guardrails/domain/pharma/persistence.domain.md +23 -0
- package/assets/guardrails/domain/pharma/pharma-terms.md +27 -0
- package/assets/guardrails/domain/pharma/product-lifecycle.domain.md +30 -0
- package/assets/guardrails/domain/pharma/regulatory-compliance.md +19 -0
- package/assets/guardrails/domain/pharma/terminology.md +32 -0
- package/assets/guardrails/domain/pharma/testing.domain.md +19 -0
- package/assets/guardrails/domain/pharma/vertical-slices.domain.md +17 -0
- package/assets/guardrails/domain/pharma/zero-trust.domain.md +12 -0
- package/assets/guardrails/projects/dotnet-default-project/domain/domain-requirements.md +58 -0
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +113 -0
- package/assets/guardrails/projects/dotnet-default-project/frontend/ui-requirements.md +43 -0
- package/assets/guardrails/projects/dotnet-default-project/service/technical-requirements.md +40 -0
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +263 -0
- package/assets/guardrails/requirement-spec.txt +27 -0
- package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +184 -0
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +44 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +96 -0
- package/assets/guardrails/technology/dotnet-webapi/authorization-matrix.md +14 -0
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +39 -0
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +54 -0
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +87 -0
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +63 -0
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +38 -0
- package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +40 -0
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +91 -0
- package/assets/guardrails/technology/dotnet-webapi/eventing.md +57 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/api-versioning.md +75 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/auditing.md +84 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/authorization.md +68 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/clean-architecture.md +37 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +97 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +141 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/eventing.md +46 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/persistence.md +207 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/vertical-slices.md +151 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/zero-trust.md +48 -0
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +61 -0
- package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +47 -0
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +82 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +162 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +55 -0
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +70 -0
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +48 -0
- package/assets/guardrails/technology/java-spring/README.md +62 -0
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +56 -0
- package/assets/guardrails/technology/nextjs/api-client.md +56 -0
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +174 -0
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +203 -0
- package/assets/guardrails/technology/nextjs/auth-session.md +68 -0
- package/assets/guardrails/technology/nextjs/client-security.md +69 -0
- package/assets/guardrails/technology/nextjs/component-architecture.md +60 -0
- package/assets/guardrails/technology/nextjs/component-conventions.md +58 -0
- package/assets/guardrails/technology/nextjs/design-system.md +60 -0
- package/assets/guardrails/technology/nextjs/error-handling.md +72 -0
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +75 -0
- package/assets/guardrails/technology/nextjs/frontend-testing.md +94 -0
- package/assets/guardrails/technology/nextjs/i18n.md +57 -0
- package/assets/guardrails/technology/nextjs/observability.md +67 -0
- package/assets/guardrails/technology/nextjs/performance-budgets.md +66 -0
- package/assets/guardrails/technology/nextjs/routing-navigation.md +52 -0
- package/assets/guardrails/technology/nextjs/state-management.md +70 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +166 -0
- package/package.json +1 -1
- package/assets/docs/pharma-constitution.my-ref.md +0 -86
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
aw-stack — the lane model's deterministic core.
|
|
4
|
+
|
|
5
|
+
Backs seven aw-spec-actions subcommands, called identically by the bash and the
|
|
6
|
+
powershell twin so the two stay in parity by construction rather than by review:
|
|
7
|
+
|
|
8
|
+
list enumerate .appweaver/stack-profiles/ — the candidate list
|
|
9
|
+
detect match the repo's own files against those profiles — the BROWNFIELD
|
|
10
|
+
candidate list, since an existing repo already answered the question
|
|
11
|
+
declare upsert a lane into .appweaver/stacks.json — THE SOLE WRITER
|
|
12
|
+
resolve read .appweaver/stacks.json — for every downstream reader
|
|
13
|
+
verify is the declaration USABLE for the phase about to run — the gate
|
|
14
|
+
run-phase run a phase's command for EVERY declared lane, not just the first
|
|
15
|
+
probe is there evidence of a lane of this kind — read-only, never fatal
|
|
16
|
+
|
|
17
|
+
`declare` is the sole writer and has exactly one caller — `select-stack`, where a
|
|
18
|
+
person picks — so the rule is a grep rather than a convention. The `origin` key
|
|
19
|
+
stays optional in the schema for a future caller that proposes a lane without a
|
|
20
|
+
person in the loop; a reader that branches on it keeps working either way.
|
|
21
|
+
|
|
22
|
+
A person editing stacks.json in a text editor is the other way a lane arrives,
|
|
23
|
+
and it is supported: the kit ships the file holding `{"lanes": []}` for exactly
|
|
24
|
+
that. It is the one path that does not pass the writer's checks, which is why
|
|
25
|
+
`verify` re-checks the roots-disjoint invariant rather than trusting that
|
|
26
|
+
`declare` already did. `resolve` can never write, which is why these are two
|
|
27
|
+
subcommands and not one with a mode flag.
|
|
28
|
+
|
|
29
|
+
A lane's `root` IS its source path — the subtree the lane owns and the only place
|
|
30
|
+
its implement phase may write. The two words name one field, and `--source-path`
|
|
31
|
+
is accepted wherever `--root` is.
|
|
32
|
+
|
|
33
|
+
WHERE THE COMMANDS RUN is a separate question, because for most stacks the answer
|
|
34
|
+
is a different directory: `dotnet build` wants the solution file at the top while
|
|
35
|
+
the lane owns `src/`, and `npm run build` wants the package.json at the top while
|
|
36
|
+
the lane owns `web/`. `run_from` carries that answer — "repo" (the default, and
|
|
37
|
+
the shape of every lane written before the key existed) or "root". A stack whose
|
|
38
|
+
manifest lives INSIDE its subtree needs "root" and cannot work without it: a Maven
|
|
39
|
+
wrapper at backend/<app>/mvnw is unreachable from the repository root.
|
|
40
|
+
|
|
41
|
+
WHERE THE PATH IS CHECKED AGAINST THE DISK. `declare` records a path; it does not
|
|
42
|
+
require the path to exist, because in a greenfield project it does not exist yet —
|
|
43
|
+
the whole point of declaring `src/` on day one is that `implement` will create it.
|
|
44
|
+
`verify` is the check, and it runs where the path has to be real: before implement,
|
|
45
|
+
build, run, and before onboard reverse-engineers a codebase out of it. That split
|
|
46
|
+
is what lets one file serve a project that has no code yet and a project that is
|
|
47
|
+
nothing but code.
|
|
48
|
+
|
|
49
|
+
Stdlib only. Emits one line of compact JSON on stdout.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import argparse
|
|
53
|
+
import fnmatch
|
|
54
|
+
import glob
|
|
55
|
+
import json
|
|
56
|
+
import os
|
|
57
|
+
import re
|
|
58
|
+
import sys
|
|
59
|
+
|
|
60
|
+
STACKS_REL = ".appweaver/stacks.json"
|
|
61
|
+
PROFILES_REL = ".appweaver/stack-profiles"
|
|
62
|
+
|
|
63
|
+
# A lane id names the ROLE in the project; the profile names the technology.
|
|
64
|
+
# Keeping them separate is what lets a project re-stack a lane without renaming
|
|
65
|
+
# the paths, rules and constitution half that hang off the lane id.
|
|
66
|
+
DEFAULT_LANE_ID = {"backend": "service", "frontend": "web"}
|
|
67
|
+
|
|
68
|
+
# "(none)" is a real answer for EITHER kind: a backend-only project declines the UI
|
|
69
|
+
# lane, and a UI-only project declines the service lane. Both are projects the kit
|
|
70
|
+
# has to be able to govern, and the sentinel reads as the answer to the question
|
|
71
|
+
# that was actually asked. The leading "(none)" token is what `declare` matches on,
|
|
72
|
+
# so the trailing prose is free to differ by kind.
|
|
73
|
+
NONE_SENTINEL = "(none) — no lane of this kind"
|
|
74
|
+
NONE_SENTINEL_BY_KIND = {
|
|
75
|
+
"backend": "(none) — no service lane, UI-only project",
|
|
76
|
+
"frontend": "(none) — no UI lane, backend-only project",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def none_sentinel(kind):
|
|
81
|
+
return NONE_SENTINEL_BY_KIND.get(kind or "", NONE_SENTINEL)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def emit(obj):
|
|
85
|
+
sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fail(msg):
|
|
89
|
+
sys.stderr.write(msg + "\n")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ─────────────────────────────── profiles ──────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def load_profiles(repo, kind=None):
|
|
96
|
+
base = os.path.join(repo, PROFILES_REL)
|
|
97
|
+
out = []
|
|
98
|
+
for path in sorted(glob.glob(os.path.join(base, "*.json"))):
|
|
99
|
+
try:
|
|
100
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
101
|
+
prof = json.load(fh)
|
|
102
|
+
except (ValueError, OSError):
|
|
103
|
+
continue # a malformed profile is skipped, never fatal
|
|
104
|
+
if not prof.get("id") or not prof.get("kind"):
|
|
105
|
+
continue
|
|
106
|
+
if kind and kind != "all" and prof.get("kind") != kind:
|
|
107
|
+
continue
|
|
108
|
+
out.append(prof)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def option_label(prof):
|
|
113
|
+
lang = prof.get("language", "")
|
|
114
|
+
suffix = " (%s)" % lang if lang else ""
|
|
115
|
+
return "%s — %s%s" % (prof["id"], prof.get("title", prof["id"]), suffix)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def profile_id_from_label(label):
|
|
119
|
+
"""Options are '<id> — <title> (<lang>)'; the id is the leading token.
|
|
120
|
+
|
|
121
|
+
Accepts a bare id too, so --profile works with a direct argument and with a
|
|
122
|
+
selection relayed back from the launcher.
|
|
123
|
+
"""
|
|
124
|
+
return (label or "").strip().split()[0] if (label or "").strip() else ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_preselect(text, profiles, include_none, kind=None):
|
|
128
|
+
"""Match a direct argument against THIS kind's profiles only.
|
|
129
|
+
|
|
130
|
+
select-stack asks twice, so one argument would be ambiguous — except that a
|
|
131
|
+
profile id belongs to exactly one kind. Each list matches the token it owns
|
|
132
|
+
and ignores the rest, which makes `select-stack dotnet-webapi react-vite`
|
|
133
|
+
answer both selects and `select-stack react-vite` answer only the UI one.
|
|
134
|
+
"""
|
|
135
|
+
tokens = [t.strip().lower() for t in (text or "").replace(",", " ").split()]
|
|
136
|
+
if not tokens:
|
|
137
|
+
return ""
|
|
138
|
+
for prof in profiles:
|
|
139
|
+
if prof["id"].lower() in tokens:
|
|
140
|
+
return option_label(prof)
|
|
141
|
+
if include_none and ("none" in tokens or "(none)" in tokens):
|
|
142
|
+
return none_sentinel(kind)
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ─────────────────────────────── detection ─────────────────────────────────
|
|
147
|
+
# Match a repository against the installed profiles' own `detect` patterns. This
|
|
148
|
+
# is the brownfield source of lane candidates: greenfield asks an operator to pick
|
|
149
|
+
# from `stack-list`, and an existing repo already answered the question in its
|
|
150
|
+
# files. DETECTION PROPOSES — it writes nothing and decides nothing; `declare` is
|
|
151
|
+
# still the only writer, and a person still confirms through select-stack.
|
|
152
|
+
#
|
|
153
|
+
# Patterns are matched on BASENAME, anywhere in the tree, not as root-anchored
|
|
154
|
+
# globs: a profile that ships `detect: ["web/package.json", "web/vite.config.*"]`
|
|
155
|
+
# must still recognise the same stack in `client/` or `apps/portal/`, because a
|
|
156
|
+
# brownfield repo did not choose its layout from our profile. The directory where
|
|
157
|
+
# the patterns actually met IS the detected root, which is why the root comes out
|
|
158
|
+
# of detection rather than out of the profile.
|
|
159
|
+
DETECT_PRUNE = {"node_modules", ".git", "dist", "build", "out", "bin", "obj",
|
|
160
|
+
"vendor", ".venv", "venv", "__pycache__", ".appweaver", "specs", "knowledge"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_REPO_FILES_CACHE = {}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _repo_files(repo, cap=20000):
|
|
167
|
+
"""Every file in the repo, pruned of the directories detection never looks in.
|
|
168
|
+
|
|
169
|
+
Memoised per (repo, cap) for the life of the process. Detection is asked the same
|
|
170
|
+
question several times in one run — once per kind, plus once more to work out which
|
|
171
|
+
subtrees another kind already owns — and the answer cannot change underneath a single
|
|
172
|
+
CLI invocation, which is the only scope this cache spans. On a large brownfield tree
|
|
173
|
+
the walk is the expensive part of `stack-list --suggest`.
|
|
174
|
+
|
|
175
|
+
Callers only read the result, so they share one list.
|
|
176
|
+
"""
|
|
177
|
+
key = (repo, cap)
|
|
178
|
+
if key in _REPO_FILES_CACHE:
|
|
179
|
+
return _REPO_FILES_CACHE[key]
|
|
180
|
+
out = []
|
|
181
|
+
for dirpath, dirnames, filenames in os.walk(repo):
|
|
182
|
+
dirnames[:] = [d for d in dirnames if d not in DETECT_PRUNE and not d.startswith(".")]
|
|
183
|
+
rel = os.path.relpath(dirpath, repo).replace("\\", "/")
|
|
184
|
+
rel = "" if rel == "." else rel
|
|
185
|
+
for f in filenames:
|
|
186
|
+
out.append((rel + "/" + f) if rel else f)
|
|
187
|
+
if len(out) >= cap:
|
|
188
|
+
_REPO_FILES_CACHE[key] = out
|
|
189
|
+
return out
|
|
190
|
+
_REPO_FILES_CACHE[key] = out
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _glob_rx(pattern):
|
|
195
|
+
"""Translate a detect glob to a regex over a '/'-relative path.
|
|
196
|
+
|
|
197
|
+
`**` spans directory separators and `*` does not, which is the distinction
|
|
198
|
+
`src/**/*.csproj` depends on and fnmatch does not make.
|
|
199
|
+
"""
|
|
200
|
+
out, i = [], 0
|
|
201
|
+
while i < len(pattern):
|
|
202
|
+
c = pattern[i]
|
|
203
|
+
if pattern.startswith("**/", i):
|
|
204
|
+
out.append("(?:[^/]+/)*"); i += 3
|
|
205
|
+
elif pattern.startswith("**", i):
|
|
206
|
+
out.append(".*"); i += 2
|
|
207
|
+
elif c == "*":
|
|
208
|
+
out.append("[^/]*"); i += 1
|
|
209
|
+
elif c == "?":
|
|
210
|
+
out.append("[^/]"); i += 1
|
|
211
|
+
else:
|
|
212
|
+
out.append(re.escape(c)); i += 1
|
|
213
|
+
return re.compile(r"\A" + "".join(out) + r"\Z")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _ambiguous_basenames(repo):
|
|
217
|
+
"""Detect-pattern filenames that profiles of MORE THAN ONE kind claim.
|
|
218
|
+
|
|
219
|
+
`package.json` marks a Node service as readily as a Vite UI, and `*.csproj` a Web API
|
|
220
|
+
as readily as a Blazor client. Such a name is real evidence where the profile's own
|
|
221
|
+
layout put it, but it cannot by itself carry a profile into a directory the profile
|
|
222
|
+
never named — that is how a .NET backend acquires a phantom Blazor UI lane. Derived
|
|
223
|
+
from the installed profiles rather than listed here, so a new profile that shares a
|
|
224
|
+
manifest is accounted for by being installed.
|
|
225
|
+
"""
|
|
226
|
+
owners = {}
|
|
227
|
+
for prof in load_profiles(repo):
|
|
228
|
+
for pat in (prof.get("detect") or []):
|
|
229
|
+
owners.setdefault(os.path.basename(pat), set()).add(prof.get("kind", ""))
|
|
230
|
+
return {b for b, kinds in owners.items() if len(kinds) > 1}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def detect_profiles(repo, kind=None, exclude_roots=()):
|
|
234
|
+
"""Rank the installed profiles against what this repo actually contains.
|
|
235
|
+
|
|
236
|
+
`detect` is an ANY-of list: `java-spring` names both `pom.xml` and
|
|
237
|
+
`build.gradle` because a project has one or the other, and requiring both
|
|
238
|
+
would make it undetectable. So one pattern hitting is a match, and the COUNT
|
|
239
|
+
of patterns that hit is the score.
|
|
240
|
+
|
|
241
|
+
Matching is tried twice. ANCHORED — the pattern against the repo-relative
|
|
242
|
+
path, which is how the patterns are written. Then RELOCATED — the same
|
|
243
|
+
pattern allowed any directory prefix, because a profile that ships
|
|
244
|
+
`web/package.json` must still recognise the same stack in `client/` or
|
|
245
|
+
`apps/portal/`: a brownfield repo did not pick its layout from our profiles.
|
|
246
|
+
An anchored hit outranks a relocated one.
|
|
247
|
+
|
|
248
|
+
`root` is reported separately from the match, because in these profiles the
|
|
249
|
+
two are independent — `go-chi` detects on `go.mod` at the repo root and
|
|
250
|
+
declares a root of `internal/`. The reported root is the shallowest directory
|
|
251
|
+
the evidence actually sits in, falling back to the profile's own. Callers with
|
|
252
|
+
better information override it; `onboard` does exactly that for the frontend
|
|
253
|
+
lane, from the scan's own candidate roots."""
|
|
254
|
+
files = _repo_files(repo)
|
|
255
|
+
ambiguous = _ambiguous_basenames(repo)
|
|
256
|
+
out = []
|
|
257
|
+
for prof in load_profiles(repo, kind):
|
|
258
|
+
patterns = prof.get("detect") or []
|
|
259
|
+
if not patterns:
|
|
260
|
+
continue
|
|
261
|
+
# A subtree another lane already owns cannot be evidence for this one.
|
|
262
|
+
# Without this a backend profile relocates onto the frontend's own
|
|
263
|
+
# `package.json` and claims the UI's root — the ownership rule violated by
|
|
264
|
+
# the very step that is supposed to establish it.
|
|
265
|
+
scoped = [f for f in files
|
|
266
|
+
if not any(norm_root(os.path.dirname(f) or ".").startswith(x)
|
|
267
|
+
or (f + "/").startswith(x)
|
|
268
|
+
for x in exclude_roots if x)]
|
|
269
|
+
files_for_prof = scoped if prof.get("kind") != "frontend" else files
|
|
270
|
+
evidence, matched, anchored_hits = [], 0, 0
|
|
271
|
+
prof_root = norm_root(prof.get("root") or "")
|
|
272
|
+
for pat in patterns:
|
|
273
|
+
rx = _glob_rx(pat)
|
|
274
|
+
rx_any = _glob_rx("**/" + pat)
|
|
275
|
+
hits = [f for f in files_for_prof if rx.match(f)]
|
|
276
|
+
if hits:
|
|
277
|
+
anchored_hits += 1
|
|
278
|
+
else:
|
|
279
|
+
hits = [f for f in files_for_prof if rx_any.match(f)]
|
|
280
|
+
if not hits and prof_root and pat.startswith(prof_root) \
|
|
281
|
+
and os.path.basename(pat) not in ambiguous:
|
|
282
|
+
# RELOCATED, with the profile's own root segment substituted rather than
|
|
283
|
+
# prefixed. Prefixing alone only ever finds the profile's own folder name
|
|
284
|
+
# deeper in the tree — `apps/portal/web/angular.json` — so a UI in
|
|
285
|
+
# `client/` or at the repo root, which is what most repositories that did
|
|
286
|
+
# not start from these profiles actually look like, went undetected.
|
|
287
|
+
# Restricted to names only one kind claims, so substitution widens where a
|
|
288
|
+
# stack may live without widening what counts as evidence for it.
|
|
289
|
+
hits = [f for f in files_for_prof
|
|
290
|
+
if _glob_rx("**/" + pat[len(prof_root):]).match(f)]
|
|
291
|
+
if not hits:
|
|
292
|
+
continue
|
|
293
|
+
matched += 1
|
|
294
|
+
evidence.extend(hits[:10])
|
|
295
|
+
if not matched:
|
|
296
|
+
continue # no evidence at all is not a stack
|
|
297
|
+
dirs = sorted({os.path.dirname(f) for f in evidence}, key=lambda d: (d.count("/"), len(d)))
|
|
298
|
+
# WHERE THE SOURCE LIVES, which is not always where the evidence sits. A manifest
|
|
299
|
+
# at the repo root — `App.sln`, a workspace `package.json` — marks the repository,
|
|
300
|
+
# not the subtree: the code it describes is under the profile's own root. So repo-
|
|
301
|
+
# root evidence defers to that root when the directory is really there, and only
|
|
302
|
+
# falls back to the repo root itself when it is not, which is the case for the
|
|
303
|
+
# single-package layouts (`create-next-app`, a UI checked out at the top level)
|
|
304
|
+
# whose files genuinely are at the top.
|
|
305
|
+
prof_default = norm_root(prof.get("root") or "")
|
|
306
|
+
if not dirs:
|
|
307
|
+
root = prof_default
|
|
308
|
+
elif dirs[0]:
|
|
309
|
+
root = norm_root(dirs[0])
|
|
310
|
+
elif prof_default and os.path.isdir(os.path.join(repo, prof_default)):
|
|
311
|
+
root = prof_default
|
|
312
|
+
else:
|
|
313
|
+
root = REPO_ROOT
|
|
314
|
+
out.append({
|
|
315
|
+
"id": prof["id"],
|
|
316
|
+
"kind": prof.get("kind", ""),
|
|
317
|
+
"title": prof.get("title", prof["id"]),
|
|
318
|
+
"language": prof.get("language", ""),
|
|
319
|
+
"root": root,
|
|
320
|
+
"profile_root": norm_root(prof.get("root") or ""),
|
|
321
|
+
"build": prof.get("build", ""),
|
|
322
|
+
"test": prof.get("test", ""),
|
|
323
|
+
"run": prof.get("run", ""),
|
|
324
|
+
"patterns": len(patterns),
|
|
325
|
+
"patterns_matched": matched,
|
|
326
|
+
"anchored": anchored_hits > 0,
|
|
327
|
+
"evidence": sorted(set(evidence))[:20],
|
|
328
|
+
})
|
|
329
|
+
# Strongest first: an anchored match, then more patterns hit, then a shallower root.
|
|
330
|
+
out.sort(key=lambda m: (m["anchored"], m["patterns_matched"], -m["root"].count("/")),
|
|
331
|
+
reverse=True)
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def cmd_detect(args):
|
|
336
|
+
excl = [norm_root(r) for r in (args.exclude_root or []) if r]
|
|
337
|
+
matches = detect_profiles(args.repo, args.kind, excl)
|
|
338
|
+
best = {}
|
|
339
|
+
for m in matches:
|
|
340
|
+
best.setdefault(m["kind"], m)
|
|
341
|
+
emit({
|
|
342
|
+
"matches": matches,
|
|
343
|
+
"count": len(matches),
|
|
344
|
+
"kinds": sorted(best),
|
|
345
|
+
"backend": best.get("backend", {}),
|
|
346
|
+
"backend_profile": best.get("backend", {}).get("id", ""),
|
|
347
|
+
"backend_root": best.get("backend", {}).get("root", ""),
|
|
348
|
+
"frontend": best.get("frontend", {}),
|
|
349
|
+
"frontend_profile": best.get("frontend", {}).get("id", ""),
|
|
350
|
+
"frontend_root": best.get("frontend", {}).get("root", ""),
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def suggestion_exclusions(repo, kind):
|
|
355
|
+
"""Subtrees that belong to a DIFFERENT lane, and so cannot be evidence for this one.
|
|
356
|
+
|
|
357
|
+
`detect_profiles` already refuses to score a profile on files another lane owns;
|
|
358
|
+
without a caller supplying that set the rule never engaged on the path select-stack
|
|
359
|
+
actually uses, and the ownership rule was violated by the very step that establishes
|
|
360
|
+
it. A repo with an Angular UI in `web/` and a Spring service in `backend/svc/` offered
|
|
361
|
+
`node-express` rooted at `web/` as its BACKEND: `web/package.json` is anchored evidence
|
|
362
|
+
for the UI and merely relocated evidence for a Node service, and a shallower root broke
|
|
363
|
+
the tie the wrong way.
|
|
364
|
+
|
|
365
|
+
Two sources, both about a subtree that is already spoken for:
|
|
366
|
+
- a lane of another kind already declared in stacks.json, and
|
|
367
|
+
- a profile of another kind whose evidence is ANCHORED — the strength the ranking
|
|
368
|
+
itself treats as decisive.
|
|
369
|
+
|
|
370
|
+
The repo root is never excluded. It is the one directory a fullstack repo legitimately
|
|
371
|
+
shares — a root `package.json` next to a root `go.mod` — and excluding it would remove
|
|
372
|
+
every file from consideration rather than narrow the field.
|
|
373
|
+
"""
|
|
374
|
+
out = set()
|
|
375
|
+
data = read_stacks(repo) or {}
|
|
376
|
+
for lane in (data.get("lanes") or []):
|
|
377
|
+
if lane.get("kind") and lane.get("kind") != kind:
|
|
378
|
+
r = norm_root(lane.get("root"))
|
|
379
|
+
if r and r != REPO_ROOT:
|
|
380
|
+
out.add(r)
|
|
381
|
+
for other in ("frontend", "backend"):
|
|
382
|
+
if other == kind:
|
|
383
|
+
continue
|
|
384
|
+
others = detect_profiles(repo, other)
|
|
385
|
+
for i, m in enumerate(others):
|
|
386
|
+
# The strongest match owns its root even when it had to relocate to find it:
|
|
387
|
+
# a Vite UI in `client/` is no less the owner of `client/` for the profile
|
|
388
|
+
# having shipped `web/`. Beyond the strongest, only anchored evidence claims a
|
|
389
|
+
# subtree, so a weak relocation cannot fence off a directory it merely touched.
|
|
390
|
+
if i and not m.get("anchored"):
|
|
391
|
+
continue
|
|
392
|
+
r = norm_root(m.get("root"))
|
|
393
|
+
if r and r != REPO_ROOT:
|
|
394
|
+
out.add(r)
|
|
395
|
+
return sorted(out)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def cmd_list(args):
|
|
399
|
+
"""The menu for one kind, and — with --suggest — the repo's own answer to it.
|
|
400
|
+
|
|
401
|
+
Folding detection into the listing is deliberate. select-stack asks two
|
|
402
|
+
questions and each needs the same two things: the candidates, and what this
|
|
403
|
+
repository looks like. Two subcommands would walk the file tree twice and add
|
|
404
|
+
two steps to a workflow whose whole value is being short. One call returns the
|
|
405
|
+
options, the profile the files point at, and the directory the evidence sits
|
|
406
|
+
in — which is the source path the operator is about to be asked to confirm.
|
|
407
|
+
|
|
408
|
+
HOW A DETECTED STACK IS PROPOSED, and why it is not preselected. Detection
|
|
409
|
+
moves its match to the FRONT of the option list; it never fills `preselect`.
|
|
410
|
+
The two are answered at different strengths: `preselect` is the direct-argument
|
|
411
|
+
fast path and the engine treats it as the answer, ahead of a headless
|
|
412
|
+
`--select`, so filling it from a scan would let this repo's files silently
|
|
413
|
+
overrule an operator who named a stack on the command line. Leading the list
|
|
414
|
+
is the right strength for a proposal — it is what an interactive menu opens on
|
|
415
|
+
and what `--non-interactive` takes, and both are still choices anyone can
|
|
416
|
+
override. `(none)` stays last wherever it is offered.
|
|
417
|
+
"""
|
|
418
|
+
profiles = load_profiles(args.repo, args.kind)
|
|
419
|
+
sentinel = none_sentinel(args.kind)
|
|
420
|
+
|
|
421
|
+
matches = detect_profiles(args.repo, args.kind,
|
|
422
|
+
suggestion_exclusions(args.repo, args.kind)) \
|
|
423
|
+
if args.suggest else []
|
|
424
|
+
detected = matches[0] if matches else {}
|
|
425
|
+
suggested_profile = detected.get("id", "")
|
|
426
|
+
suggested_root = detected.get("root") or detected.get("profile_root") or ""
|
|
427
|
+
evidence = ", ".join(detected.get("evidence", [])[:4])
|
|
428
|
+
|
|
429
|
+
ordered = sorted(profiles, key=lambda p: p["id"] != suggested_profile) \
|
|
430
|
+
if suggested_profile else profiles
|
|
431
|
+
options = [option_label(p) for p in ordered]
|
|
432
|
+
if args.include_none:
|
|
433
|
+
options.append(sentinel)
|
|
434
|
+
|
|
435
|
+
preselect = resolve_preselect(args.preselect, profiles, args.include_none, args.kind)
|
|
436
|
+
preselect_source = "argument" if preselect else ""
|
|
437
|
+
|
|
438
|
+
# Candidate SOURCE PATHS for the follow-up question, best first: where the
|
|
439
|
+
# evidence actually sits, then each profile's own default. Labelled with why
|
|
440
|
+
# each is offered, because the operator picking from this list is choosing the
|
|
441
|
+
# subtree a generator will be allowed to write into. Free text is always
|
|
442
|
+
# accepted — a repo that matches no profile still has a source path.
|
|
443
|
+
root_options, seen = [], set()
|
|
444
|
+
|
|
445
|
+
def offer(root, why):
|
|
446
|
+
r = norm_root(root)
|
|
447
|
+
if not r or r in seen:
|
|
448
|
+
return
|
|
449
|
+
seen.add(r)
|
|
450
|
+
root_options.append(ROOT_LABEL_SEP.join([r, why]))
|
|
451
|
+
|
|
452
|
+
if suggested_root:
|
|
453
|
+
offer(suggested_root, "detected here (%s)" % (suggested_profile or "evidence"))
|
|
454
|
+
for m in matches[1:]:
|
|
455
|
+
offer(m.get("root") or m.get("profile_root"), "detected here (%s)" % m["id"])
|
|
456
|
+
for p in profiles:
|
|
457
|
+
offer(p.get("root"), "%s default" % p["id"])
|
|
458
|
+
|
|
459
|
+
emit({
|
|
460
|
+
"kind": args.kind or "all",
|
|
461
|
+
"profiles": profiles,
|
|
462
|
+
"ids": [p["id"] for p in profiles],
|
|
463
|
+
"select_options": options,
|
|
464
|
+
"count": len(profiles),
|
|
465
|
+
"preselect": preselect,
|
|
466
|
+
"preselect_source": preselect_source,
|
|
467
|
+
"detected_profile": suggested_profile,
|
|
468
|
+
"detected_root": suggested_root,
|
|
469
|
+
"detected_evidence": evidence,
|
|
470
|
+
"detected_count": len(matches),
|
|
471
|
+
"root_options": root_options,
|
|
472
|
+
"none_sentinel": sentinel,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ──────────────────────────────── stacks.json ──────────────────────────────
|
|
477
|
+
|
|
478
|
+
def read_stacks(repo):
|
|
479
|
+
path = os.path.join(repo, STACKS_REL)
|
|
480
|
+
if not os.path.isfile(path):
|
|
481
|
+
return None
|
|
482
|
+
try:
|
|
483
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
484
|
+
return json.load(fh)
|
|
485
|
+
except (ValueError, OSError):
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# A source-path option reads "<path> — <why it is offered>". The separator is two
|
|
490
|
+
# spaces and an em dash, which cannot occur in a path, so one field carries both the
|
|
491
|
+
# value and its justification and `declare` still receives a path.
|
|
492
|
+
ROOT_LABEL_SEP = " — "
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def strip_root_label(value):
|
|
496
|
+
return (value or "").split(ROOT_LABEL_SEP, 1)[0].strip()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# The repository root as a source path. A single-module project — a Maven, Cargo, Go
|
|
500
|
+
# or Node repo whose manifest sits at the top — has no subdirectory to name, and the
|
|
501
|
+
# empty string cannot carry it: `verify` reads an empty root as "no source path
|
|
502
|
+
# declared". "./" is the one spelling that survives being normalised twice, which is
|
|
503
|
+
# what makes it usable as a STORED value rather than only as typed input.
|
|
504
|
+
REPO_ROOT = "./"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def norm_root(root):
|
|
508
|
+
"""Canonicalise a source path, IDEMPOTENTLY.
|
|
509
|
+
|
|
510
|
+
Idempotence is a correctness requirement, not tidiness: `declare` normalises what it
|
|
511
|
+
writes and `verify` normalises what it reads, so a value that changes on the second
|
|
512
|
+
pass fails the lane the writer had just accepted. `.` normalising to `./` and `./`
|
|
513
|
+
then normalising to `""` did exactly that — every repo-root lane declared clean and
|
|
514
|
+
was rejected by the next gate that looked at it.
|
|
515
|
+
"""
|
|
516
|
+
raw = strip_root_label(root).replace("\\", "/").strip()
|
|
517
|
+
r = raw
|
|
518
|
+
while r.startswith("./"):
|
|
519
|
+
r = r[2:]
|
|
520
|
+
if r == ".":
|
|
521
|
+
r = ""
|
|
522
|
+
r = r.lstrip("/") # leading separators only — a '.output/' root keeps its dot
|
|
523
|
+
if not r:
|
|
524
|
+
# An empty result means one of two different things: nothing was supplied (no
|
|
525
|
+
# root), or the repository root itself was. Only the second gets a value.
|
|
526
|
+
return REPO_ROOT if raw else ""
|
|
527
|
+
if not r.endswith("/"):
|
|
528
|
+
r += "/"
|
|
529
|
+
return r
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def root_on_disk(repo, root):
|
|
533
|
+
"""Does the lane's source path exist as a directory?
|
|
534
|
+
|
|
535
|
+
Reported everywhere a root is reported and enforced only by `verify`. A false
|
|
536
|
+
here is an ordinary state before any code is written and a hard error once a
|
|
537
|
+
phase needs to read or write the subtree.
|
|
538
|
+
"""
|
|
539
|
+
r = norm_root(root)
|
|
540
|
+
return bool(r) and os.path.isdir(os.path.join(repo, r.rstrip("/")))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def lane_workdir(repo, lane):
|
|
544
|
+
"""The directory a lane's commands run in.
|
|
545
|
+
|
|
546
|
+
`run_from: "root"` runs them in the lane's own source path; anything else — including
|
|
547
|
+
the key being ABSENT, which is the shape of every lane written before this key
|
|
548
|
+
existed — runs them at the repository root. That default is not laziness: a .NET
|
|
549
|
+
solution file, a go.mod or a package.json at the top is what `dotnet build`,
|
|
550
|
+
`go build ./...` and `npm run build` need to find, and those profiles declare a `root`
|
|
551
|
+
that is their SOURCE directory rather than their build directory. The two coincide for
|
|
552
|
+
a stack whose manifest lives inside the subtree — a Maven wrapper at
|
|
553
|
+
backend/orderservice/mvnw cannot be reached from the repo root at all.
|
|
554
|
+
|
|
555
|
+
Falls back to the repo root when the declared root is not on disk, so the phase
|
|
556
|
+
reports the command's own failure instead of a chdir error.
|
|
557
|
+
"""
|
|
558
|
+
if (lane.get("run_from") or "repo") == "root":
|
|
559
|
+
r = norm_root(lane.get("root"))
|
|
560
|
+
cand = os.path.join(repo, r.rstrip("/")) if r else repo
|
|
561
|
+
if os.path.isdir(cand):
|
|
562
|
+
return cand
|
|
563
|
+
return repo
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def lane_command(lane, phase):
|
|
567
|
+
"""The command for one phase, with `{{root}}` standing for the lane's root.
|
|
568
|
+
|
|
569
|
+
A profile command sometimes has to NAME the source path — `dotnet run --project
|
|
570
|
+
src/`, `python -m compileall app`. Written literally, that is the profile's
|
|
571
|
+
default root frozen into the command, and it stays frozen when the lane declares
|
|
572
|
+
a different one: a service at `Services/Api/` runs the project at `src/`, or
|
|
573
|
+
fails looking for it. The placeholder lets the profile say "the root this lane
|
|
574
|
+
declares" and be right on every layout.
|
|
575
|
+
|
|
576
|
+
Expanded at RUN time, not at declare time, so editing `root` in stacks.json by
|
|
577
|
+
hand keeps the command consistent with it. A command with no placeholder is
|
|
578
|
+
returned untouched, so every literal command already on disk is unaffected.
|
|
579
|
+
"""
|
|
580
|
+
cmd = (lane.get(phase) or "").strip()
|
|
581
|
+
if "{{root}}" not in cmd:
|
|
582
|
+
return cmd
|
|
583
|
+
r = norm_root(lane.get("root"))
|
|
584
|
+
# `./` is the repo root: a bare `./` reads as noise in a command line, and the
|
|
585
|
+
# only sensible substitution for "the root, which is here" is the cwd itself.
|
|
586
|
+
sub = "." if (r == REPO_ROOT or not r) else r.rstrip("/")
|
|
587
|
+
return cmd.replace("{{root}}", sub)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def roots_overlap(a, b):
|
|
591
|
+
"""Roots own their subtree, so containment either way is an overlap.
|
|
592
|
+
|
|
593
|
+
Compared as '/'-terminated strings, which keeps 'src/' clear of 'src-gen/'
|
|
594
|
+
while still catching 'src/' against 'src/api/'.
|
|
595
|
+
"""
|
|
596
|
+
a, b = norm_root(a), norm_root(b)
|
|
597
|
+
if not a or not b:
|
|
598
|
+
return False
|
|
599
|
+
# The repository root contains every other subtree, and prefix comparison cannot see
|
|
600
|
+
# that — "./" is not a prefix of "web/". Without this a lane rooted at the top and a
|
|
601
|
+
# lane rooted inside it both pass the writer's check and then own the same files.
|
|
602
|
+
if a == REPO_ROOT or b == REPO_ROOT:
|
|
603
|
+
return True
|
|
604
|
+
return a == b or a.startswith(b) or b.startswith(a)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def cmd_declare(args):
|
|
608
|
+
repo = args.repo
|
|
609
|
+
data = read_stacks(repo) or {"lanes": []}
|
|
610
|
+
lanes = data.get("lanes") or []
|
|
611
|
+
|
|
612
|
+
# "none" is a declaration too: it says this project has no lane of this kind.
|
|
613
|
+
# Routing it through the writer keeps re-runs correct — a UI lane declared in
|
|
614
|
+
# an earlier run is removed here rather than stranded.
|
|
615
|
+
profile_id = profile_id_from_label(args.profile)
|
|
616
|
+
if profile_id.lower() in ("none", "(none)"):
|
|
617
|
+
if not args.kind:
|
|
618
|
+
fail("lane-declare: --kind is required with --profile none")
|
|
619
|
+
removed = [l for l in lanes if l.get("kind") == args.kind]
|
|
620
|
+
lanes = [l for l in lanes if l.get("kind") != args.kind]
|
|
621
|
+
data["lanes"] = lanes
|
|
622
|
+
if args.kind == "frontend":
|
|
623
|
+
data.pop("prototype", None)
|
|
624
|
+
write_stacks(repo, data)
|
|
625
|
+
# Same key set as the declare branch below: callers bind these outputs
|
|
626
|
+
# without knowing which branch ran.
|
|
627
|
+
emit({
|
|
628
|
+
"declared": False,
|
|
629
|
+
"lane": {},
|
|
630
|
+
"lane_id": "",
|
|
631
|
+
"kind": args.kind,
|
|
632
|
+
"profile": "",
|
|
633
|
+
"root": "",
|
|
634
|
+
"source_path": "",
|
|
635
|
+
"root_exists": False,
|
|
636
|
+
"replaced": False,
|
|
637
|
+
"removed": [l.get("id") for l in removed],
|
|
638
|
+
"lanes": lanes,
|
|
639
|
+
"lane_count": len(lanes),
|
|
640
|
+
"prototype": data.get("prototype", {}),
|
|
641
|
+
"path": STACKS_REL,
|
|
642
|
+
"written": True,
|
|
643
|
+
})
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
# `detected` is a profile-shaped hole for a stack the kit ships no profile for.
|
|
647
|
+
# Brownfield needs it and greenfield never does: the eleven installed profiles
|
|
648
|
+
# describe how a NEW project would be started, while a legacy WebForms, JSP,
|
|
649
|
+
# ERB or Razor-MVC lane matches none of them and is no less real for it.
|
|
650
|
+
# Refusing to declare those would leave exactly the repos onboard exists for
|
|
651
|
+
# unable to record a lane. It is deliberately a distinct sentinel rather than a
|
|
652
|
+
# relaxation of the unknown-profile check, so a mistyped profile id still fails
|
|
653
|
+
# loudly instead of silently becoming a lane.
|
|
654
|
+
prof = None
|
|
655
|
+
if profile_id.lower() == "detected":
|
|
656
|
+
if not args.kind:
|
|
657
|
+
fail("lane-declare: --kind is required with --profile detected")
|
|
658
|
+
if not args.stack:
|
|
659
|
+
fail("lane-declare: --stack <label> is required with --profile detected "
|
|
660
|
+
"(what the code was found to be, e.g. 'asp.net-webforms')")
|
|
661
|
+
if not args.root:
|
|
662
|
+
fail("lane-declare: --root is required with --profile detected "
|
|
663
|
+
"(no profile to take a default root from)")
|
|
664
|
+
else:
|
|
665
|
+
profiles = {p["id"]: p for p in load_profiles(repo)}
|
|
666
|
+
prof = profiles.get(profile_id)
|
|
667
|
+
if prof is None:
|
|
668
|
+
fail("lane-declare: unknown stack profile '%s'. Known: %s"
|
|
669
|
+
% (profile_id, ", ".join(sorted(profiles)) or "(none installed)"))
|
|
670
|
+
|
|
671
|
+
kind = args.kind or (prof or {}).get("kind")
|
|
672
|
+
lane_id = args.id or DEFAULT_LANE_ID.get(kind, kind)
|
|
673
|
+
root = norm_root(args.root or (prof or {}).get("root") or "")
|
|
674
|
+
if not root:
|
|
675
|
+
fail("lane-declare: lane '%s' has no source path. A lane without a root owns "
|
|
676
|
+
"nothing, so nothing can be built, run or written into it. Pass "
|
|
677
|
+
"--source-path <dir> (the profile declares no default)." % lane_id)
|
|
678
|
+
|
|
679
|
+
# Greenfield declares a path that does not exist yet, so existence is checked
|
|
680
|
+
# only where a caller says the subtree must already be there — onboard, which
|
|
681
|
+
# is about to read the code out of it.
|
|
682
|
+
exists = root_on_disk(repo, root)
|
|
683
|
+
if args.require_existing_root and not exists:
|
|
684
|
+
fail("lane-declare: source path '%s' for lane '%s' does not exist under %s. "
|
|
685
|
+
"Point the lane at the directory that actually holds this lane's source, "
|
|
686
|
+
"or re-run /appweaver select-stack to correct it."
|
|
687
|
+
% (root, lane_id, repo))
|
|
688
|
+
|
|
689
|
+
lane = {
|
|
690
|
+
"id": lane_id,
|
|
691
|
+
"kind": kind,
|
|
692
|
+
"profile": prof["id"] if prof else "detected",
|
|
693
|
+
"root": root,
|
|
694
|
+
"build": args.build or (prof or {}).get("build", ""),
|
|
695
|
+
"test": args.test or (prof or {}).get("test", ""),
|
|
696
|
+
"run": args.run or (prof or {}).get("run", ""),
|
|
697
|
+
}
|
|
698
|
+
# SCHEMA MIGRATION, the one phase most stacks answer differently. A profile that
|
|
699
|
+
# declares `migrate` gets it recorded like any other phase command; the shipped
|
|
700
|
+
# profiles declare none, because there is no single right answer (Flyway, Liquibase,
|
|
701
|
+
# Alembic, Prisma, golang-migrate all coexist) and inventing one would be a guess
|
|
702
|
+
# dressed as a default. Absent, `run-migrate` keeps its .NET `dotnet ef` path and
|
|
703
|
+
# otherwise reports honestly that nothing ran — an empty key here changes nothing.
|
|
704
|
+
migrate = args.migrate or (prof or {}).get("migrate", "")
|
|
705
|
+
if migrate:
|
|
706
|
+
lane["migrate"] = migrate
|
|
707
|
+
# WHERE this lane's commands run — see lane_workdir. Recorded only when it is not
|
|
708
|
+
# the default, so a lane reads the same as it always did unless its stack needs the
|
|
709
|
+
# other answer.
|
|
710
|
+
run_from = getattr(args, "run_from", "") or (prof or {}).get("run_from", "")
|
|
711
|
+
if run_from == "root":
|
|
712
|
+
lane["run_from"] = "root"
|
|
713
|
+
# What the code was found to be, when no shipped profile names it.
|
|
714
|
+
if args.stack:
|
|
715
|
+
lane["stack"] = args.stack
|
|
716
|
+
# How this lane came to be declared. Absent means a person picked it — the
|
|
717
|
+
# greenfield path, and the historical shape of every lane written before now.
|
|
718
|
+
if args.origin and args.origin != "declared":
|
|
719
|
+
lane["origin"] = args.origin
|
|
720
|
+
|
|
721
|
+
# root IS ownership: two generators must never be able to write one file.
|
|
722
|
+
for other in lanes:
|
|
723
|
+
if other.get("id") == lane_id:
|
|
724
|
+
continue
|
|
725
|
+
if roots_overlap(root, other.get("root")):
|
|
726
|
+
fail("lane-declare: root '%s' for lane '%s' overlaps lane '%s' at '%s'. "
|
|
727
|
+
"Lane roots own their subtree and must be disjoint."
|
|
728
|
+
% (root, lane_id, other.get("id"), other.get("root")))
|
|
729
|
+
|
|
730
|
+
replaced = False
|
|
731
|
+
for i, other in enumerate(lanes):
|
|
732
|
+
if other.get("id") == lane_id:
|
|
733
|
+
lanes[i] = lane
|
|
734
|
+
replaced = True
|
|
735
|
+
break
|
|
736
|
+
if not replaced:
|
|
737
|
+
lanes.append(lane)
|
|
738
|
+
|
|
739
|
+
data["lanes"] = lanes
|
|
740
|
+
|
|
741
|
+
# The prototype block is NOT a lane: a root and a run command so an operator
|
|
742
|
+
# can open it, and nothing else. It is never built, tested, gated or deployed.
|
|
743
|
+
if kind == "frontend" and not args.no_prototype:
|
|
744
|
+
proto_run = args.prototype_run or (prof or {}).get("prototype_run", "")
|
|
745
|
+
if proto_run:
|
|
746
|
+
data["prototype"] = {
|
|
747
|
+
"root": norm_root(args.prototype_root or "prototype/"),
|
|
748
|
+
"run": proto_run,
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
write_stacks(repo, data)
|
|
752
|
+
emit({
|
|
753
|
+
"declared": True,
|
|
754
|
+
"lane": lane,
|
|
755
|
+
"lane_id": lane_id,
|
|
756
|
+
"kind": kind,
|
|
757
|
+
"profile": lane["profile"],
|
|
758
|
+
"stack": lane.get("stack", ""),
|
|
759
|
+
"origin": lane.get("origin", "declared"),
|
|
760
|
+
"root": root,
|
|
761
|
+
"source_path": root,
|
|
762
|
+
"root_exists": exists,
|
|
763
|
+
"replaced": replaced,
|
|
764
|
+
"removed": [],
|
|
765
|
+
"lanes": lanes,
|
|
766
|
+
"lane_count": len(lanes),
|
|
767
|
+
"prototype": data.get("prototype", {}),
|
|
768
|
+
"path": STACKS_REL,
|
|
769
|
+
"written": True,
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def write_stacks(repo, data):
|
|
774
|
+
os.makedirs(os.path.join(repo, ".appweaver"), exist_ok=True)
|
|
775
|
+
path = os.path.join(repo, STACKS_REL)
|
|
776
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
777
|
+
json.dump(data, fh, indent=2)
|
|
778
|
+
fh.write("\n")
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def profile_of(repo, lane):
|
|
782
|
+
"""The stack profile a lane names, or {} when it names none the kit ships."""
|
|
783
|
+
if not lane:
|
|
784
|
+
return {}
|
|
785
|
+
pid = lane.get("profile", "")
|
|
786
|
+
if not pid or pid in ("detected", "none"):
|
|
787
|
+
return {}
|
|
788
|
+
return next((p for p in load_profiles(repo) if p.get("id") == pid), {})
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def kind_block(repo, lanes, kind):
|
|
792
|
+
"""Every field a caller needs about ONE kind, flattened and prefixed.
|
|
793
|
+
|
|
794
|
+
A workflow that wants the service source path binds `$.backend_source_path`
|
|
795
|
+
and is done — no `--kind` argument, no second call, and no branch on which
|
|
796
|
+
lane came back. Both kinds are always present in the payload, so the two-lane
|
|
797
|
+
steps (a gate that needs backend, a report that names both) resolve one script
|
|
798
|
+
call rather than two. Empty strings where the lane is not declared: the engine
|
|
799
|
+
compares variables as text, and an absent lane must read as absent.
|
|
800
|
+
"""
|
|
801
|
+
lane = next((l for l in lanes if l.get("kind") == kind), {})
|
|
802
|
+
root = lane.get("root", "")
|
|
803
|
+
prof = profile_of(repo, lane)
|
|
804
|
+
return {
|
|
805
|
+
kind + "_declared": bool(lane),
|
|
806
|
+
kind + "_lane_id": lane.get("id", ""),
|
|
807
|
+
kind + "_profile": lane.get("profile", ""),
|
|
808
|
+
kind + "_stack": lane.get("stack", ""),
|
|
809
|
+
kind + "_origin": lane.get("origin", "declared") if lane else "",
|
|
810
|
+
# WHAT the stack IS, as opposed to where it lives. The three identity fields
|
|
811
|
+
# come from the profile at read time rather than from the lane record, so a
|
|
812
|
+
# lane declared before they existed answers them too — no re-declaration.
|
|
813
|
+
# A `detected` lane has no profile to read: its own `stack` label is the
|
|
814
|
+
# only name it has, and language/ecosystem stay empty rather than guessed.
|
|
815
|
+
kind + "_language": prof.get("language", ""),
|
|
816
|
+
kind + "_ecosystem": prof.get("ecosystem", ""),
|
|
817
|
+
kind + "_title": prof.get("title", "") or lane.get("stack", ""),
|
|
818
|
+
kind + "_root": root,
|
|
819
|
+
kind + "_source_path": root,
|
|
820
|
+
kind + "_root_exists": root_on_disk(repo, root),
|
|
821
|
+
kind + "_build": lane_command(lane, "build"),
|
|
822
|
+
kind + "_test": lane_command(lane, "test"),
|
|
823
|
+
kind + "_run": lane_command(lane, "run"),
|
|
824
|
+
kind + "_migrate": lane_command(lane, "migrate") or prof.get("migrate", ""),
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def cmd_resolve(args):
|
|
829
|
+
data = read_stacks(args.repo)
|
|
830
|
+
|
|
831
|
+
# Absence is a valid state for READING, never a failure: `resolve` reports what
|
|
832
|
+
# is there and lets the caller decide. It is `verify` that turns an absent or
|
|
833
|
+
# unusable declaration into a stop, and only in the phases that need one.
|
|
834
|
+
if data is None:
|
|
835
|
+
payload = {
|
|
836
|
+
"declared": False, "exists": False, "lanes": [], "lane_count": 0,
|
|
837
|
+
"found": False, "lane": {}, "kinds": [], "prototype": {},
|
|
838
|
+
"lane_id": "", "root": "", "source_path": "", "root_exists": False,
|
|
839
|
+
"build": "", "test": "", "run": "", "migrate": "",
|
|
840
|
+
"language": "", "ecosystem": "",
|
|
841
|
+
"path": STACKS_REL,
|
|
842
|
+
}
|
|
843
|
+
payload.update(kind_block(args.repo, [], "backend"))
|
|
844
|
+
payload.update(kind_block(args.repo, [], "frontend"))
|
|
845
|
+
emit(payload)
|
|
846
|
+
return
|
|
847
|
+
|
|
848
|
+
lanes = data.get("lanes") or []
|
|
849
|
+
match = {}
|
|
850
|
+
if args.id:
|
|
851
|
+
match = next((l for l in lanes if l.get("id") == args.id), {})
|
|
852
|
+
elif args.kind:
|
|
853
|
+
match = next((l for l in lanes if l.get("kind") == args.kind), {})
|
|
854
|
+
|
|
855
|
+
# `declared` is about LANES and `exists` about the file — they part company on
|
|
856
|
+
# the shipped-but-unfilled seed, which is on disk and declares nothing.
|
|
857
|
+
payload = {
|
|
858
|
+
"declared": bool(lanes),
|
|
859
|
+
"exists": True,
|
|
860
|
+
"lanes": lanes,
|
|
861
|
+
"lane_count": len(lanes),
|
|
862
|
+
"kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
|
|
863
|
+
"found": bool(match),
|
|
864
|
+
"lane": match,
|
|
865
|
+
"lane_id": match.get("id", ""),
|
|
866
|
+
"root": match.get("root", ""),
|
|
867
|
+
"source_path": match.get("root", ""),
|
|
868
|
+
"root_exists": root_on_disk(args.repo, match.get("root", "")),
|
|
869
|
+
"build": lane_command(match, "build"),
|
|
870
|
+
"test": lane_command(match, "test"),
|
|
871
|
+
"run": lane_command(match, "run"),
|
|
872
|
+
"migrate": lane_command(match, "migrate") or profile_of(args.repo, match).get("migrate", ""),
|
|
873
|
+
"language": profile_of(args.repo, match).get("language", ""),
|
|
874
|
+
"ecosystem": profile_of(args.repo, match).get("ecosystem", ""),
|
|
875
|
+
"prototype": data.get("prototype", {}),
|
|
876
|
+
"path": STACKS_REL,
|
|
877
|
+
}
|
|
878
|
+
payload.update(kind_block(args.repo, lanes, "backend"))
|
|
879
|
+
payload.update(kind_block(args.repo, lanes, "frontend"))
|
|
880
|
+
emit(payload)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# ──────────────────────────────── verify ───────────────────────────────────
|
|
884
|
+
# The gate. `resolve` reports; this decides whether the declaration is USABLE for
|
|
885
|
+
# the phase about to run, and it is the only place a lane model becomes fatal.
|
|
886
|
+
#
|
|
887
|
+
# TWO CLASSES OF FAILURE, and the distinction is the whole design:
|
|
888
|
+
#
|
|
889
|
+
# MISSING — the phase needs a lane of this kind and none is declared. Reported
|
|
890
|
+
# only for the kinds a caller asks for with --require, because "no frontend
|
|
891
|
+
# lane" is the correct and common state and must never fail a backend build.
|
|
892
|
+
#
|
|
893
|
+
# BROKEN — a lane IS declared and its source path is empty or is not a directory.
|
|
894
|
+
# Checked for EVERY declared lane whether or not the caller asked about it. A
|
|
895
|
+
# stack named against a path that is not there is a mistake in every phase, and
|
|
896
|
+
# letting it through means discovering it as an empty build or a generator
|
|
897
|
+
# writing into a directory it just created by accident.
|
|
898
|
+
#
|
|
899
|
+
# --allow-missing-root turns the on-disk half off. That is the greenfield state:
|
|
900
|
+
# select-stack declares `src/` before any code exists, and the path becomes real
|
|
901
|
+
# when implement writes into it. Every phase that READS the subtree — implement,
|
|
902
|
+
# build, run, onboard — leaves the check on.
|
|
903
|
+
|
|
904
|
+
KIND_PHASES = {
|
|
905
|
+
"backend": "implement / implement-delta / build / run",
|
|
906
|
+
"frontend": "ui-implement / ui-implement-delta",
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def cmd_verify(args):
|
|
911
|
+
repo = args.repo
|
|
912
|
+
data = read_stacks(repo)
|
|
913
|
+
lanes = (data or {}).get("lanes") or []
|
|
914
|
+
required = [k for k in (args.require or []) if k in ("backend", "frontend")]
|
|
915
|
+
issues = []
|
|
916
|
+
|
|
917
|
+
# An empty file and an absent one are the SAME state. The kit ships
|
|
918
|
+
# stacks.json holding `{"lanes": []}` so a project can fill it in by hand, so
|
|
919
|
+
# the file being there says nothing about whether a lane was declared — only
|
|
920
|
+
# its contents do. Both cases report one issue naming which of the two it is.
|
|
921
|
+
if not lanes:
|
|
922
|
+
issues.append(
|
|
923
|
+
"no lane is declared: %s %s. A project with neither a service nor a UI "
|
|
924
|
+
"lane has nothing to build, govern or generate. Run "
|
|
925
|
+
"`/appweaver select-stack` to declare each lane's stack and source "
|
|
926
|
+
"path, or fill the file in by hand (the schema is in "
|
|
927
|
+
".appweaver/STACK-PROFILES.md)."
|
|
928
|
+
% (STACKS_REL, "does not exist" if data is None else "declares no lanes"))
|
|
929
|
+
else:
|
|
930
|
+
for kind in required:
|
|
931
|
+
if not any(l.get("kind") == kind for l in lanes):
|
|
932
|
+
issues.append(
|
|
933
|
+
"no %s lane is declared in %s, and %s needs one. Run "
|
|
934
|
+
"`/appweaver select-stack` and choose a %s stack."
|
|
935
|
+
% (kind, STACKS_REL, args.command or KIND_PHASES.get(kind, "this phase"), kind))
|
|
936
|
+
# BROKEN is checked across every declared lane, not just the required ones.
|
|
937
|
+
for lane in lanes:
|
|
938
|
+
kind, lane_id = lane.get("kind", "?"), lane.get("id", "?")
|
|
939
|
+
root = lane.get("root", "")
|
|
940
|
+
if not root:
|
|
941
|
+
issues.append(
|
|
942
|
+
"lane '%s' (%s, stack %s) declares no source path. A lane owns a "
|
|
943
|
+
"subtree; without one nothing can be built, run or written into it. "
|
|
944
|
+
"Re-run `/appweaver select-stack` and give it a source path."
|
|
945
|
+
% (lane_id, kind, lane.get("profile", "?")))
|
|
946
|
+
elif not args.allow_missing_root and not root_on_disk(repo, root):
|
|
947
|
+
issues.append(
|
|
948
|
+
"lane '%s' (%s, stack %s) declares source path '%s', which is not a "
|
|
949
|
+
"directory in this repository. Re-run `/appweaver select-stack` and "
|
|
950
|
+
"point the lane at the directory that holds its source."
|
|
951
|
+
% (lane_id, kind, lane.get("profile", "?"), root))
|
|
952
|
+
# `declare` refuses an overlap, so one can only arrive here through a
|
|
953
|
+
# hand-edited file — a supported way to fill this in, and the one path that
|
|
954
|
+
# does not pass the writer. The invariant is worth a second check anywhere:
|
|
955
|
+
# two lanes sharing a subtree means two generators can write one file.
|
|
956
|
+
for i, first in enumerate(lanes):
|
|
957
|
+
for second in lanes[i + 1:]:
|
|
958
|
+
if roots_overlap(first.get("root"), second.get("root")):
|
|
959
|
+
issues.append(
|
|
960
|
+
"lanes '%s' and '%s' claim overlapping source paths '%s' and '%s'. "
|
|
961
|
+
"Lane roots own their subtree and must be disjoint, or two "
|
|
962
|
+
"generators can write the same file. Give each lane a source path "
|
|
963
|
+
"outside the other's."
|
|
964
|
+
% (first.get("id", "?"), second.get("id", "?"),
|
|
965
|
+
first.get("root", ""), second.get("root", "")))
|
|
966
|
+
|
|
967
|
+
payload = {
|
|
968
|
+
"ok": not issues,
|
|
969
|
+
"declared": bool(lanes),
|
|
970
|
+
"required": required,
|
|
971
|
+
"lanes": lanes,
|
|
972
|
+
"lane_count": len(lanes),
|
|
973
|
+
"kinds": sorted({l.get("kind", "") for l in lanes if l.get("kind")}),
|
|
974
|
+
"root_check": not args.allow_missing_root,
|
|
975
|
+
"issues": issues,
|
|
976
|
+
"issue_count": len(issues),
|
|
977
|
+
# No lanes always carries an issue now, so a clean run always has lanes to name.
|
|
978
|
+
"message": " ".join(issues) if issues else
|
|
979
|
+
("lane model ok: " + ", ".join(
|
|
980
|
+
"%s (%s) at %s" % (l.get("id", "?"), l.get("profile", "?"), l.get("root", "?"))
|
|
981
|
+
for l in lanes)),
|
|
982
|
+
"path": STACKS_REL,
|
|
983
|
+
}
|
|
984
|
+
payload.update(kind_block(repo, lanes, "backend"))
|
|
985
|
+
payload.update(kind_block(repo, lanes, "frontend"))
|
|
986
|
+
emit(payload)
|
|
987
|
+
# Exit 0 even when the check fails: the workflow gate owns the stop, so the
|
|
988
|
+
# message and every flattened field still reach it. A non-zero exit here would
|
|
989
|
+
# make `on_error: continue` the only way to read the reason a run was refused.
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
# ────────────────────────────── run-phase ──────────────────────────────────
|
|
993
|
+
# Execute a phase's project-supplied command for EVERY declared lane.
|
|
994
|
+
#
|
|
995
|
+
# This is the payoff of declaring a source path. Without a declaration, `build`
|
|
996
|
+
# sniffs: it looks for a .sln, then for a package.json, and takes the first hit.
|
|
997
|
+
# On a single-lane repo that is right. On a two-lane repo it is wrong in the worst
|
|
998
|
+
# way available — it builds the backend, reports green, and never mentions that the
|
|
999
|
+
# frontend was not compiled at all. Iterating the declared lanes is what turns "one
|
|
1000
|
+
# build" from a guess into a fact, and a lane whose command fails fails the phase.
|
|
1001
|
+
#
|
|
1002
|
+
# The engine stays stack-ignorant: these are strings out of stacks.json, run in the
|
|
1003
|
+
# repo root, and nothing here knows what a compiler is. A lane with no command for
|
|
1004
|
+
# the phase is skipped rather than failed — a profile is allowed to have no test
|
|
1005
|
+
# command, and a lane the project never wired up should not fail a build.
|
|
1006
|
+
|
|
1007
|
+
LOG_TAIL = 1400
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def cmd_run_phase(args):
|
|
1011
|
+
import subprocess
|
|
1012
|
+
|
|
1013
|
+
repo = args.repo
|
|
1014
|
+
data = read_stacks(repo)
|
|
1015
|
+
declared = (data or {}).get("lanes") or []
|
|
1016
|
+
lanes = declared
|
|
1017
|
+
if args.kind:
|
|
1018
|
+
lanes = [l for l in lanes if l.get("kind") == args.kind]
|
|
1019
|
+
|
|
1020
|
+
results, tails, cmds = [], [], []
|
|
1021
|
+
ok, ran = True, False
|
|
1022
|
+
for lane in lanes:
|
|
1023
|
+
cmd = lane_command(lane, args.phase)
|
|
1024
|
+
lane_id, root = lane.get("id", "?"), lane.get("root", "")
|
|
1025
|
+
if not cmd:
|
|
1026
|
+
results.append({"lane": lane_id, "root": root, "cmd": "",
|
|
1027
|
+
"ran": False, "ok": True, "skipped": "no %s command" % args.phase})
|
|
1028
|
+
continue
|
|
1029
|
+
# The source path has to be there before a command is run against it.
|
|
1030
|
+
# Reported per lane rather than raised, so a two-lane run still reports
|
|
1031
|
+
# what the other lane did.
|
|
1032
|
+
if not root_on_disk(repo, root):
|
|
1033
|
+
ok = False
|
|
1034
|
+
results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": False, "ok": False,
|
|
1035
|
+
"error": "source path '%s' is not a directory" % root})
|
|
1036
|
+
tails.append("[%s] source path '%s' is not a directory" % (lane_id, root))
|
|
1037
|
+
continue
|
|
1038
|
+
ran = True
|
|
1039
|
+
cmds.append(cmd)
|
|
1040
|
+
workdir = lane_workdir(repo, lane)
|
|
1041
|
+
proc = subprocess.run(cmd, shell=True, cwd=workdir, stdout=subprocess.PIPE,
|
|
1042
|
+
stderr=subprocess.STDOUT)
|
|
1043
|
+
out = proc.stdout.decode("utf-8", "replace")
|
|
1044
|
+
lane_ok = proc.returncode == 0
|
|
1045
|
+
ok = ok and lane_ok
|
|
1046
|
+
results.append({"lane": lane_id, "root": root, "cmd": cmd, "ran": True,
|
|
1047
|
+
"ok": lane_ok, "exit_code": proc.returncode,
|
|
1048
|
+
"cwd": os.path.relpath(workdir, repo).replace("\\", "/")})
|
|
1049
|
+
if not lane_ok or args.phase == "run":
|
|
1050
|
+
tails.append("[%s] %s" % (lane_id, out[-LOG_TAIL:]))
|
|
1051
|
+
|
|
1052
|
+
emit({
|
|
1053
|
+
"ok": ok,
|
|
1054
|
+
# Every lane the file declares, not the kind-filtered slice this phase ran.
|
|
1055
|
+
"declared": bool(declared),
|
|
1056
|
+
"phase": args.phase,
|
|
1057
|
+
"tool": "lanes",
|
|
1058
|
+
"ran": ran,
|
|
1059
|
+
"lane_count": len(lanes),
|
|
1060
|
+
"lanes_run": [r["lane"] for r in results if r["ran"]],
|
|
1061
|
+
"lanes_skipped": [r["lane"] for r in results if not r["ran"]],
|
|
1062
|
+
"results": results,
|
|
1063
|
+
"cmd": " && ".join(cmds),
|
|
1064
|
+
"log_tail": "\n".join(tails)[-LOG_TAIL:],
|
|
1065
|
+
})
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
# ──────────────────────────────── probe ────────────────────────────────────
|
|
1069
|
+
|
|
1070
|
+
def profile_terms(prof):
|
|
1071
|
+
"""The words that unambiguously NAME this stack: its id and the id's parts.
|
|
1072
|
+
|
|
1073
|
+
Title and language are deliberately excluded. "TypeScript", "Next" and "Web"
|
|
1074
|
+
all occur in ordinary prose about a backend, and the two errors are not
|
|
1075
|
+
symmetric: a false positive authors a governed file the project never asked
|
|
1076
|
+
for, while a false negative only produces a warning that names the fix.
|
|
1077
|
+
"""
|
|
1078
|
+
pid = (prof.get("id") or "").lower()
|
|
1079
|
+
terms = {pid} if pid else set()
|
|
1080
|
+
terms.update(p for p in re.split(r"[-_]+", pid) if len(p) >= 3)
|
|
1081
|
+
return terms
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def hint_tokens(text):
|
|
1085
|
+
"""Tokenise free prose into stack-name candidates.
|
|
1086
|
+
|
|
1087
|
+
Keeps '-' and '.' inside a token so "react-vite" and "next.js" survive
|
|
1088
|
+
whole, and adds a punctuation-stripped form of each so "next.js" also
|
|
1089
|
+
reaches the profile id "nextjs".
|
|
1090
|
+
"""
|
|
1091
|
+
out = set()
|
|
1092
|
+
for raw in re.split(r"[^A-Za-z0-9+#._-]+", (text or "").lower()):
|
|
1093
|
+
tok = raw.strip("._-+")
|
|
1094
|
+
if not tok:
|
|
1095
|
+
continue
|
|
1096
|
+
out.add(tok)
|
|
1097
|
+
out.add(re.sub(r"[._]", "", tok))
|
|
1098
|
+
return out
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def cmd_probe(args):
|
|
1102
|
+
"""Evidence that a lane of this kind exists, from the two FILE signals.
|
|
1103
|
+
|
|
1104
|
+
This answers only what stacks.json and the shipped profiles can answer. The
|
|
1105
|
+
authoritative signal — whether the active pack maps `## /aw-spec-<command>`
|
|
1106
|
+
for the lane's phases — belongs to `resolve-tech`, and callers OR it in.
|
|
1107
|
+
Re-parsing the rule map here would be a third implementation of a contract
|
|
1108
|
+
that already has two, so this deliberately stops short of it.
|
|
1109
|
+
|
|
1110
|
+
Read-only and never fatal: an absent stacks.json, an absent profile
|
|
1111
|
+
directory and an empty hint are all ordinary answers, not errors.
|
|
1112
|
+
"""
|
|
1113
|
+
lanes = (read_stacks(args.repo) or {}).get("lanes") or []
|
|
1114
|
+
lane = next((l for l in lanes if l.get("kind") == args.kind), {})
|
|
1115
|
+
|
|
1116
|
+
tokens = hint_tokens(args.hint)
|
|
1117
|
+
hint_profile, hint_terms = "", []
|
|
1118
|
+
for prof in load_profiles(args.repo, args.kind):
|
|
1119
|
+
hit = sorted(profile_terms(prof) & tokens)
|
|
1120
|
+
if hit:
|
|
1121
|
+
hint_profile, hint_terms = prof["id"], hit
|
|
1122
|
+
break
|
|
1123
|
+
|
|
1124
|
+
signals = []
|
|
1125
|
+
if lane:
|
|
1126
|
+
signals.append("declaration")
|
|
1127
|
+
if hint_profile:
|
|
1128
|
+
signals.append("hint")
|
|
1129
|
+
|
|
1130
|
+
parts = []
|
|
1131
|
+
if lane:
|
|
1132
|
+
parts.append("lane '%s' (%s) declared in %s"
|
|
1133
|
+
% (lane.get("id", ""), lane.get("profile", ""), STACKS_REL))
|
|
1134
|
+
if hint_profile:
|
|
1135
|
+
parts.append("the request names %s (matched on %s)"
|
|
1136
|
+
% (hint_profile, ", ".join(hint_terms)))
|
|
1137
|
+
|
|
1138
|
+
emit({
|
|
1139
|
+
"kind": args.kind,
|
|
1140
|
+
"declared": bool(lane),
|
|
1141
|
+
"lane": lane,
|
|
1142
|
+
"declared_lane_id": lane.get("id", ""),
|
|
1143
|
+
"declared_profile": lane.get("profile", ""),
|
|
1144
|
+
"declared_root": lane.get("root", ""),
|
|
1145
|
+
"hint_profile": hint_profile,
|
|
1146
|
+
"hint_terms": hint_terms,
|
|
1147
|
+
"signals": signals,
|
|
1148
|
+
"any": bool(signals),
|
|
1149
|
+
"evidence": "; ".join(parts),
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def main():
|
|
1154
|
+
ap = argparse.ArgumentParser(prog="aw-stack", description=__doc__)
|
|
1155
|
+
ap.add_argument("--repo", default=".", help="repository root (callers pass their own resolver's answer)")
|
|
1156
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
1157
|
+
|
|
1158
|
+
p = sub.add_parser("list", help="enumerate the installed stack profiles")
|
|
1159
|
+
p.add_argument("--kind", choices=["backend", "frontend", "all"], default="all")
|
|
1160
|
+
p.add_argument("--include-none", action="store_true",
|
|
1161
|
+
help="append the '(none)' sentinel — for an optional lane")
|
|
1162
|
+
p.add_argument("--preselect", default="",
|
|
1163
|
+
help="direct-argument text; matched against this kind's profile ids")
|
|
1164
|
+
p.add_argument("--suggest", action="store_true",
|
|
1165
|
+
help="also run detection over this repo, so the menu opens on the "
|
|
1166
|
+
"stack the files point at and root_options leads with the "
|
|
1167
|
+
"directory the evidence sits in")
|
|
1168
|
+
p.set_defaults(func=cmd_list)
|
|
1169
|
+
|
|
1170
|
+
p = sub.add_parser("detect", help="match the repo's files against the installed profiles")
|
|
1171
|
+
p.add_argument("--kind", choices=["backend", "frontend", "all"], default="all")
|
|
1172
|
+
p.add_argument("--exclude-root", dest="exclude_root", action="append", default=[],
|
|
1173
|
+
help="a subtree another lane owns; repeatable. Backend candidates find no "
|
|
1174
|
+
"evidence there, so a service profile cannot relocate onto the UI's files")
|
|
1175
|
+
p.set_defaults(func=cmd_detect)
|
|
1176
|
+
|
|
1177
|
+
p = sub.add_parser("declare", help="upsert a lane into stacks.json (sole writer)")
|
|
1178
|
+
p.add_argument("--profile", required=True,
|
|
1179
|
+
help="profile id, a select_options label, 'none', or 'detected' "
|
|
1180
|
+
"(a stack the kit ships no profile for — needs --stack and --root)")
|
|
1181
|
+
p.add_argument("--kind", choices=["backend", "frontend"])
|
|
1182
|
+
p.add_argument("--id", help="lane id (default: service for backend, web for frontend)")
|
|
1183
|
+
p.add_argument("--root", "--source-path", dest="root",
|
|
1184
|
+
help="the lane's SOURCE PATH — the subtree it owns, overriding the "
|
|
1185
|
+
"profile's default root. The two spellings are one field")
|
|
1186
|
+
p.add_argument("--run-from", dest="run_from", choices=["repo", "root"],
|
|
1187
|
+
help="where this lane's build/test/run commands execute: 'repo' (default) "
|
|
1188
|
+
"at the repository root, which is what a top-level solution file, "
|
|
1189
|
+
"go.mod or package.json needs; 'root' inside the lane's own source "
|
|
1190
|
+
"path, which is the only way to reach a manifest nested in the "
|
|
1191
|
+
"subtree (a Maven wrapper at backend/<app>/mvnw). Defaults to the "
|
|
1192
|
+
"profile's own setting")
|
|
1193
|
+
p.add_argument("--require-existing-root", dest="require_existing_root", action="store_true",
|
|
1194
|
+
help="refuse a source path that is not a directory. For a caller about "
|
|
1195
|
+
"to READ the subtree (onboard); greenfield declares a path that "
|
|
1196
|
+
"implement has not created yet, and leaves this off")
|
|
1197
|
+
p.add_argument("--stack", default="",
|
|
1198
|
+
help="what the code was found to be, when no shipped profile names it")
|
|
1199
|
+
p.add_argument("--origin", default="declared", choices=["declared", "reverse-engineered"],
|
|
1200
|
+
help="how the lane came to be declared; recorded only when not 'declared'")
|
|
1201
|
+
p.add_argument("--build")
|
|
1202
|
+
p.add_argument("--test")
|
|
1203
|
+
p.add_argument("--run")
|
|
1204
|
+
p.add_argument("--migrate", default="",
|
|
1205
|
+
help="schema-migration command for this lane (overrides the profile's)")
|
|
1206
|
+
p.add_argument("--prototype-root", dest="prototype_root")
|
|
1207
|
+
p.add_argument("--prototype-run", dest="prototype_run")
|
|
1208
|
+
p.add_argument("--no-prototype", dest="no_prototype", action="store_true",
|
|
1209
|
+
help="do not write the prototype block. Brownfield: the prototype phase "
|
|
1210
|
+
"does not run on adopted features, and the profile's default would "
|
|
1211
|
+
"point at a directory the repo does not have")
|
|
1212
|
+
p.set_defaults(func=cmd_declare)
|
|
1213
|
+
|
|
1214
|
+
p = sub.add_parser("resolve", help="read stacks.json")
|
|
1215
|
+
p.add_argument("--kind", choices=["backend", "frontend"])
|
|
1216
|
+
p.add_argument("--id")
|
|
1217
|
+
p.set_defaults(func=cmd_resolve)
|
|
1218
|
+
|
|
1219
|
+
p = sub.add_parser("verify", help="is the declaration usable for the phase about to run")
|
|
1220
|
+
p.add_argument("--require", action="append", default=[], choices=["backend", "frontend"],
|
|
1221
|
+
help="a lane kind this phase cannot run without; repeatable. Omit to "
|
|
1222
|
+
"check only that whatever IS declared is sound")
|
|
1223
|
+
p.add_argument("--allow-missing-root", dest="allow_missing_root", action="store_true",
|
|
1224
|
+
help="do not require the source paths to exist on disk yet — the "
|
|
1225
|
+
"greenfield state, where select-stack declares a path implement "
|
|
1226
|
+
"will create")
|
|
1227
|
+
p.add_argument("--command", default="",
|
|
1228
|
+
help="the phase being gated, quoted back in the failure message")
|
|
1229
|
+
p.set_defaults(func=cmd_verify)
|
|
1230
|
+
|
|
1231
|
+
p = sub.add_parser("run-phase", help="run a phase's command for every declared lane")
|
|
1232
|
+
p.add_argument("--phase", required=True, choices=["build", "test", "run", "migrate"],
|
|
1233
|
+
help="which project-supplied command to run")
|
|
1234
|
+
p.add_argument("--kind", choices=["backend", "frontend"],
|
|
1235
|
+
help="restrict to one lane kind (default: every declared lane)")
|
|
1236
|
+
p.set_defaults(func=cmd_run_phase)
|
|
1237
|
+
|
|
1238
|
+
p = sub.add_parser("probe", help="evidence of a lane of this kind (file signals only)")
|
|
1239
|
+
p.add_argument("--kind", choices=["backend", "frontend"], default="frontend")
|
|
1240
|
+
p.add_argument("--hint", default="", help="free prose to scan for a stack name")
|
|
1241
|
+
p.set_defaults(func=cmd_probe)
|
|
1242
|
+
|
|
1243
|
+
args = ap.parse_args()
|
|
1244
|
+
if not getattr(args, "func", None):
|
|
1245
|
+
ap.print_help()
|
|
1246
|
+
sys.exit(0)
|
|
1247
|
+
args.func(args)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
if __name__ == "__main__":
|
|
1251
|
+
main()
|