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,1180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""aw-inventory.py — brownfield repository inventory for the aw-spec pipeline.
|
|
3
|
+
|
|
4
|
+
The deterministic, model-free substrate for the Brownfield-Pipeline B0
|
|
5
|
+
(Inventory) phase (see Brownfield-Pipeline.md).
|
|
6
|
+
It scans an existing ("brownfield") codebase and emits a factual JSON substrate
|
|
7
|
+
that the /appweaver inventory workflow's enrichment agent turns into an
|
|
8
|
+
aggregate-organized OKF v0.1 knowledge bundle. The agent uses this so it never
|
|
9
|
+
has to guess the repo layout.
|
|
10
|
+
|
|
11
|
+
Ported from Reference/okf-inventory.sh (which was ~90% embedded python already)
|
|
12
|
+
into a single cross-platform python core, with two brownfield additions:
|
|
13
|
+
|
|
14
|
+
* aggregate_signals — candidate DDD aggregate roots derived from domain /
|
|
15
|
+
entity / model / route file clustering, ranked by churn + evidence. These
|
|
16
|
+
are SIGNALS, not decisions: the workflow's agent draws the real aggregate
|
|
17
|
+
boundaries. This is the "inventory following aggregate root" the pipeline
|
|
18
|
+
needs — one aggregate ≈ one candidate specs/<Feature>/ vertical slice.
|
|
19
|
+
* frontend_signals — candidate FRONTEND LANE roots derived from component /
|
|
20
|
+
view / stylesheet clustering around frontend build configs. Like the
|
|
21
|
+
aggregate signals these are SIGNALS, not decisions, and they are what lets
|
|
22
|
+
/appweaver onboard answer "does this repo have a UI, and where" from the
|
|
23
|
+
code alone rather than from a stack profile or a project name.
|
|
24
|
+
* ui_attribution — which frontend files serve which aggregate, so a slice's OKF
|
|
25
|
+
concept carries its UI surface and not just its backend files. Needed because
|
|
26
|
+
the lane scoping below builds aggregate_signals from backend files ALONE.
|
|
27
|
+
Searches the FRONTEND SOURCE PATH only (declared root, else the roots the scan
|
|
28
|
+
found) and does not run at all where the repo has no frontend — a backend repo's
|
|
29
|
+
email templates and docs stylesheets are not its UI. Signals again: files
|
|
30
|
+
matching no aggregate are reported unattributed rather than forced into one.
|
|
31
|
+
* git history scoped to the scan subtree (not the whole monorepo), so the
|
|
32
|
+
inventory reflects only the code being adopted.
|
|
33
|
+
|
|
34
|
+
Subcommands, all printing a single JSON object as the LAST stdout line (so the
|
|
35
|
+
orchestrator's execute_script can bind it); human-readable summaries go to STDERR
|
|
36
|
+
so they never corrupt the JSON contract:
|
|
37
|
+
|
|
38
|
+
scan [--root <dir>] [--out <path>] [--bundle <dir>] [--config <yml>]
|
|
39
|
+
-> writes the full substrate JSON to --out; prints a compact summary
|
|
40
|
+
object {inventory_json, file_count, head, branch, aggregate_count,
|
|
41
|
+
frontend_present, frontend_separable, frontend_root_count,
|
|
42
|
+
frontend_roots, bundle_dir, bundle_present, bundle_md_count}
|
|
43
|
+
concept --bundle <dir> --aggregate <name>
|
|
44
|
+
-> resolves ONE aggregate's OKF concept + source_files (the code that
|
|
45
|
+
IS the slice) — the index adopt reverse-engineers a feature from
|
|
46
|
+
list-aggregates --bundle <dir>
|
|
47
|
+
-> enumerates the bundle's Aggregate concepts (the brownfield feature
|
|
48
|
+
map) → {aggregates[{name,title,slug,concept_path,...}], count} — the
|
|
49
|
+
selectable feature list select-feature offers in a brownfield repo
|
|
50
|
+
validate --bundle <dir> [--repo-root <dir>]
|
|
51
|
+
-> runs the OKF v0.1 conformance checker (validate_okf.py, beside
|
|
52
|
+
this file) and re-emits {concepts, errors[], warnings[], result,
|
|
53
|
+
conformant}
|
|
54
|
+
|
|
55
|
+
Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import argparse
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
import subprocess
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
66
|
+
|
|
67
|
+
# Default excludes, aligned with okf-inventory.sh / okf-config.template.yml.
|
|
68
|
+
DEFAULT_EXCLUDE = re.compile(
|
|
69
|
+
r"^(node_modules|\.git|dist|build|vendor|\.specify|\.appweaver|specs|knowledge)/"
|
|
70
|
+
r"|(^|/)[^/]*\.min\.js$"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
CAP = int(os.environ.get("OKF_INVENTORY_CAP", "150"))
|
|
74
|
+
HIST_COMMITS = int(os.environ.get("OKF_HISTORY_COMMITS", "2000"))
|
|
75
|
+
HIST_RECENT = int(os.environ.get("OKF_HISTORY_RECENT", "20"))
|
|
76
|
+
CHURN_TOP = int(os.environ.get("OKF_CHURN_TOP", "30"))
|
|
77
|
+
|
|
78
|
+
# --- category regexes (mirror okf-inventory.sh) ----------------------------
|
|
79
|
+
MANIFESTS_RE = re.compile(
|
|
80
|
+
r"(^|/)(package\.json|pyproject\.toml|setup\.py|requirements[^/]*\.txt|"
|
|
81
|
+
r"go\.mod|Cargo\.toml|pom\.xml|build\.gradle(\.kts)?|Gemfile|composer\.json|"
|
|
82
|
+
r"[^/]*\.csproj)$"
|
|
83
|
+
)
|
|
84
|
+
ENTRYPOINTS_RE = re.compile(
|
|
85
|
+
r"(^|/)(main|app|index|server|cli|manage|__main__|Program)\.(py|js|ts|go|rs|rb|java|cs)$"
|
|
86
|
+
)
|
|
87
|
+
API_DEFS_RE = re.compile(
|
|
88
|
+
r"\.(proto|avsc|graphql|gql)$|(^|/)(openapi|swagger)[^/]*\.(ya?ml|json)$"
|
|
89
|
+
)
|
|
90
|
+
ROUTES_RE = re.compile(
|
|
91
|
+
r"(rout|controller|endpoint|handler|view|resource)s?[^/]*\.(py|js|ts|go|rb|java|cs|php)$",
|
|
92
|
+
re.IGNORECASE,
|
|
93
|
+
)
|
|
94
|
+
DB_FILES_RE = re.compile(
|
|
95
|
+
r"(^|/)(migrations?|alembic|db/migrate)/|"
|
|
96
|
+
r"(model|schema|entit|aggregat)[^/]*\.(py|js|ts|go|rb|java|cs|sql|prisma)$",
|
|
97
|
+
re.IGNORECASE,
|
|
98
|
+
)
|
|
99
|
+
OPS_FILES_RE = re.compile(
|
|
100
|
+
r"(^|/)(Dockerfile[^/]*|docker-compose[^/]*\.ya?ml|Makefile|Procfile|[^/]*\.tf|"
|
|
101
|
+
r"helm/.*|k8s/.*\.ya?ml|\.github/workflows/.*\.ya?ml|\.gitlab-ci\.yml|"
|
|
102
|
+
r"Jenkinsfile|cloudbuild\.ya?ml)$"
|
|
103
|
+
)
|
|
104
|
+
DOCS_RE = re.compile(
|
|
105
|
+
r"(^|/)(README[^/]*|CONTRIBUTING[^/]*|CHANGELOG[^/]*|ARCHITECTURE[^/]*|docs?/.*)"
|
|
106
|
+
r"\.(md|rst|txt)$"
|
|
107
|
+
)
|
|
108
|
+
CONFIGS_RE = re.compile(
|
|
109
|
+
r"(^|/)(config|settings|conf|appsettings)[^/]*\.(py|js|ts|ya?ml|json|toml|ini|env\.example)$",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
ADR_RE = re.compile(r"(^|/)(adr|adrs|decisions?|rfcs?)/|(^|/)(ADR|RFC)[-_0-9]", re.IGNORECASE)
|
|
113
|
+
|
|
114
|
+
# --- frontend / UI detection (the lane signal) ------------------------------
|
|
115
|
+
# Deterministic evidence that this repo has a USER INTERFACE, and where it lives.
|
|
116
|
+
# The CODE is the whole oracle here: a lane is recognised from files that exist,
|
|
117
|
+
# never from a stack profile, a manifest's dependency list, or a project's name.
|
|
118
|
+
# Covers BOTH shapes a UI takes in a legacy repo — a separable client app with its
|
|
119
|
+
# own build, and server-rendered views living inside the service tree with no root
|
|
120
|
+
# of their own. SIGNALS, not decisions: the workflow's agent reads the real source
|
|
121
|
+
# and draws the lane.
|
|
122
|
+
UI_COMPONENT_RE = re.compile(
|
|
123
|
+
r"\.(jsx|tsx|vue|svelte|astro|razor|cshtml|vbhtml|aspx|ascx|master|jsp|jspx|"
|
|
124
|
+
r"erb|haml|slim|hbs|handlebars|mustache|twig|ejs|pug|jade|njk|xaml|axml)$"
|
|
125
|
+
r"|\.blade\.php$",
|
|
126
|
+
re.IGNORECASE,
|
|
127
|
+
)
|
|
128
|
+
UI_MARKUP_RE = re.compile(r"\.(html?|xhtml)$", re.IGNORECASE)
|
|
129
|
+
UI_STYLE_RE = re.compile(r"\.(css|scss|sass|less|styl|pcss)$", re.IGNORECASE)
|
|
130
|
+
# A build/tooling config only a frontend carries. The strongest separable-lane
|
|
131
|
+
# signal there is: the directory holding one IS the lane root.
|
|
132
|
+
UI_BUILD_CFG_RE = re.compile(
|
|
133
|
+
r"(^|/)(angular\.json|vite\.config\.[cm]?[jt]s|next\.config\.[cm]?[jt]s|"
|
|
134
|
+
r"nuxt\.config\.[cm]?[jt]s|svelte\.config\.[cm]?[jt]s|vue\.config\.[cm]?[jt]s|"
|
|
135
|
+
r"remix\.config\.[cm]?[jt]s|astro\.config\.[cm]?[jt]s|gatsby-config\.[cm]?[jt]s|"
|
|
136
|
+
r"tailwind\.config\.[cm]?[jt]s|postcss\.config\.[cm]?[jt]s|"
|
|
137
|
+
r"webpack\.config\.[cm]?[jt]s|rollup\.config\.[cm]?[jt]s|"
|
|
138
|
+
r"ember-cli-build\.js|craco\.config\.js|metro\.config\.js)$",
|
|
139
|
+
re.IGNORECASE,
|
|
140
|
+
)
|
|
141
|
+
PKG_JSON_RE = re.compile(r"(^|/)package\.json$")
|
|
142
|
+
# Folders that mean "user interface" — reported only INSIDE a root that already
|
|
143
|
+
# qualified on file evidence, so a backend `routes/` or `public/` cannot create one.
|
|
144
|
+
UI_DIR_RE = re.compile(
|
|
145
|
+
r"(^|/)(components?|pages|views|screens|layouts|widgets|containers|templates|"
|
|
146
|
+
r"partials|wwwroot|styles?|themes?|stores?|hooks|composables)(/|$)",
|
|
147
|
+
re.IGNORECASE,
|
|
148
|
+
)
|
|
149
|
+
UI_ROUTE_RE = re.compile(
|
|
150
|
+
r"(^|/)(routes?|router|routing)[^/]*\.[cm]?[jt]sx?$|(^|/)app-routing\.module\.ts$",
|
|
151
|
+
re.IGNORECASE,
|
|
152
|
+
)
|
|
153
|
+
UI_STORE_RE = re.compile(
|
|
154
|
+
r"(^|/)(stores?|redux|vuex|pinia|ngrx)(/|[^/]*\.[cm]?[jt]sx?$)", re.IGNORECASE
|
|
155
|
+
)
|
|
156
|
+
# Evidence path -> the stack it indicates. A MARKER, never a decision: the agent
|
|
157
|
+
# names the framework from the code. Ordered — the first match wins, so an explicit
|
|
158
|
+
# build config outranks a bare file extension.
|
|
159
|
+
FRAMEWORK_MARKERS = [
|
|
160
|
+
(re.compile(r"(^|/)angular\.json$", re.I), "angular"),
|
|
161
|
+
(re.compile(r"(^|/)next\.config\.", re.I), "next.js"),
|
|
162
|
+
(re.compile(r"(^|/)nuxt\.config\.", re.I), "nuxt"),
|
|
163
|
+
(re.compile(r"(^|/)remix\.config\.", re.I), "remix"),
|
|
164
|
+
(re.compile(r"(^|/)gatsby-config\.", re.I), "gatsby"),
|
|
165
|
+
(re.compile(r"(^|/)astro\.config\.", re.I), "astro"),
|
|
166
|
+
(re.compile(r"(^|/)metro\.config\.js$", re.I), "react-native"),
|
|
167
|
+
(re.compile(r"(^|/)svelte\.config\.", re.I), "svelte"),
|
|
168
|
+
(re.compile(r"(^|/)vue\.config\.", re.I), "vue"),
|
|
169
|
+
(re.compile(r"(^|/)vite\.config\.", re.I), "vite"),
|
|
170
|
+
(re.compile(r"(^|/)tailwind\.config\.", re.I), "tailwind"),
|
|
171
|
+
(re.compile(r"\.vue$", re.I), "vue"),
|
|
172
|
+
(re.compile(r"\.svelte$", re.I), "svelte"),
|
|
173
|
+
(re.compile(r"\.astro$", re.I), "astro"),
|
|
174
|
+
(re.compile(r"\.[jt]sx$", re.I), "jsx components"),
|
|
175
|
+
(re.compile(r"\.razor$", re.I), "blazor"),
|
|
176
|
+
(re.compile(r"\.(cshtml|vbhtml)$", re.I), "asp.net razor views"),
|
|
177
|
+
(re.compile(r"\.(aspx|ascx|master)$", re.I), "asp.net web forms"),
|
|
178
|
+
(re.compile(r"\.jspx?$", re.I), "jsp"),
|
|
179
|
+
(re.compile(r"\.erb$", re.I), "erb views"),
|
|
180
|
+
(re.compile(r"\.blade\.php$", re.I), "laravel blade"),
|
|
181
|
+
(re.compile(r"\.twig$", re.I), "twig"),
|
|
182
|
+
(re.compile(r"\.(hbs|handlebars|mustache)$", re.I), "handlebars/mustache"),
|
|
183
|
+
(re.compile(r"\.(pug|jade)$", re.I), "pug"),
|
|
184
|
+
(re.compile(r"\.ejs$", re.I), "ejs"),
|
|
185
|
+
(re.compile(r"\.(haml|slim)$", re.I), "haml/slim"),
|
|
186
|
+
(re.compile(r"\.njk$", re.I), "nunjucks"),
|
|
187
|
+
(re.compile(r"\.(xaml|axml)$", re.I), "xaml"),
|
|
188
|
+
]
|
|
189
|
+
# Component extensions that ship as a CLIENT app vs. ones RENDERED BY THE SERVER.
|
|
190
|
+
# What separates them is whether the UI can own a root of its own — the ownership
|
|
191
|
+
# rule binds generation, and a server-rendered view tree has no root to own.
|
|
192
|
+
SPA_EXT_RE = re.compile(r"\.(jsx|tsx|vue|svelte|astro|razor)$", re.IGNORECASE)
|
|
193
|
+
DESKTOP_EXT_RE = re.compile(r"\.(xaml|axml)$", re.IGNORECASE)
|
|
194
|
+
# Component files below which a candidate is reported but not called a lane. A
|
|
195
|
+
# missed lane costs a re-run; an invented one costs a ratified governance file for
|
|
196
|
+
# a lane that does not exist, so a root with no build config must show a real view set.
|
|
197
|
+
UI_STRONG_MIN = 5
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def frontend_signals(files, churn_map):
|
|
201
|
+
"""Derive CANDIDATE frontend lane roots — the deterministic answer to "does
|
|
202
|
+
this repo have a UI, and where". Each root reports its evidence, what KIND of
|
|
203
|
+
UI it is, and whether it is SEPARABLE (owns a subtree) or server-rendered
|
|
204
|
+
inside the service tree.
|
|
205
|
+
|
|
206
|
+
`present` is true only where a root reaches lane strength: it holds a frontend
|
|
207
|
+
build config, or at least UI_STRONG_MIN component/view files. Weaker candidates
|
|
208
|
+
(a `wwwroot/` of loose scripts and stylesheets) are still LISTED, marked weak —
|
|
209
|
+
visible to the reader, but never enough on their own to author governance."""
|
|
210
|
+
comp = matching(files, UI_COMPONENT_RE)
|
|
211
|
+
styles = matching(files, UI_STYLE_RE)
|
|
212
|
+
markup = matching(files, UI_MARKUP_RE)
|
|
213
|
+
cfgs = matching(files, UI_BUILD_CFG_RE)
|
|
214
|
+
if not (comp or cfgs or styles or markup):
|
|
215
|
+
return {"present": False, "separable": False, "ui_file_count": 0, "roots": []}
|
|
216
|
+
|
|
217
|
+
# Anchors: the directory of every frontend build config or package.json, longest
|
|
218
|
+
# first, so a nested web/ app wins over a monorepo root that also has one.
|
|
219
|
+
anchor_set = {os.path.dirname(f) for f in cfgs + matching(files, PKG_JSON_RE)}
|
|
220
|
+
anchors = sorted(anchor_set, key=len, reverse=True)
|
|
221
|
+
|
|
222
|
+
def anchor_of(f):
|
|
223
|
+
for a in anchors:
|
|
224
|
+
if a == "" or f.startswith(a + "/"):
|
|
225
|
+
return a
|
|
226
|
+
return f.split("/", 1)[0] if "/" in f else ""
|
|
227
|
+
|
|
228
|
+
roots = {}
|
|
229
|
+
|
|
230
|
+
def root_rec(key):
|
|
231
|
+
return roots.setdefault(key, {
|
|
232
|
+
"root": (key + "/") if key else "./",
|
|
233
|
+
"component_files": [], "style_files": [], "markup_files": [],
|
|
234
|
+
"build_configs": [], "route_files": [], "store_files": [],
|
|
235
|
+
"ui_dirs": set(), "framework_markers": set(), "churn": 0,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
for bucket, key in (
|
|
239
|
+
(comp, "component_files"), (styles, "style_files"),
|
|
240
|
+
(markup, "markup_files"), (cfgs, "build_configs"),
|
|
241
|
+
):
|
|
242
|
+
for f in bucket:
|
|
243
|
+
r = root_rec(anchor_of(f))
|
|
244
|
+
r[key].append(f)
|
|
245
|
+
r["churn"] += churn_map.get(f, 0)
|
|
246
|
+
for rx, name in FRAMEWORK_MARKERS:
|
|
247
|
+
if rx.search(f):
|
|
248
|
+
r["framework_markers"].add(name)
|
|
249
|
+
break
|
|
250
|
+
d = os.path.dirname(f)
|
|
251
|
+
if d and UI_DIR_RE.search(d):
|
|
252
|
+
r["ui_dirs"].add(d)
|
|
253
|
+
|
|
254
|
+
# Routing + client state are reported only where a UI already lives, so a
|
|
255
|
+
# server-side `routes/` folder can never invent one.
|
|
256
|
+
for f in matching(files, UI_ROUTE_RE):
|
|
257
|
+
key = anchor_of(f)
|
|
258
|
+
if key in roots:
|
|
259
|
+
roots[key]["route_files"].append(f)
|
|
260
|
+
for f in matching(files, UI_STORE_RE):
|
|
261
|
+
key = anchor_of(f)
|
|
262
|
+
if key in roots:
|
|
263
|
+
roots[key]["store_files"].append(f)
|
|
264
|
+
|
|
265
|
+
# A root anchored on a build config keeps that config's directory — the project
|
|
266
|
+
# itself declares where it starts. An UNANCHORED root (a server-rendered view
|
|
267
|
+
# tree has no build config to anchor on) was bucketed by top-level directory,
|
|
268
|
+
# which is coarser than the truth, so narrow it to the directory its own UI
|
|
269
|
+
# files actually share: `src/` becomes `src/App/`, the web project.
|
|
270
|
+
for key, r in roots.items():
|
|
271
|
+
if key in anchor_set:
|
|
272
|
+
continue
|
|
273
|
+
dirs = [os.path.dirname(f) or "." for f in
|
|
274
|
+
r["component_files"] + r["style_files"] + r["markup_files"]]
|
|
275
|
+
if not dirs:
|
|
276
|
+
continue
|
|
277
|
+
common = os.path.commonpath(dirs) if len(dirs) > 1 else dirs[0]
|
|
278
|
+
if common not in (".", "", os.sep):
|
|
279
|
+
r["root"] = common + "/"
|
|
280
|
+
|
|
281
|
+
out = []
|
|
282
|
+
for r in roots.values():
|
|
283
|
+
comp_n = len(r["component_files"])
|
|
284
|
+
spa_n = sum(1 for f in r["component_files"] if SPA_EXT_RE.search(f))
|
|
285
|
+
desk_n = sum(1 for f in r["component_files"] if DESKTOP_EXT_RE.search(f))
|
|
286
|
+
srv_n = comp_n - spa_n - desk_n
|
|
287
|
+
if comp_n == 0:
|
|
288
|
+
kind = "assets-only"
|
|
289
|
+
elif desk_n > spa_n and desk_n > srv_n:
|
|
290
|
+
kind = "desktop"
|
|
291
|
+
elif srv_n > spa_n:
|
|
292
|
+
kind = "server-rendered"
|
|
293
|
+
else:
|
|
294
|
+
kind = "spa"
|
|
295
|
+
strong = bool(r["build_configs"]) or comp_n >= UI_STRONG_MIN
|
|
296
|
+
# A server-rendered view tree has no subtree to own; the ownership rule binds
|
|
297
|
+
# generation, not adoption, so such a lane is reversed in place.
|
|
298
|
+
separable = kind != "server-rendered" and r["root"] != "./"
|
|
299
|
+
for k in ("component_files", "style_files", "markup_files",
|
|
300
|
+
"build_configs", "route_files", "store_files"):
|
|
301
|
+
r[k] = sorted(set(r[k]))[:CAP]
|
|
302
|
+
r["ui_dirs"] = sorted(r["ui_dirs"])[:CAP]
|
|
303
|
+
r["framework_markers"] = sorted(r["framework_markers"])
|
|
304
|
+
r["counts"] = {
|
|
305
|
+
"component": comp_n, "style": len(r["style_files"]),
|
|
306
|
+
"markup": len(r["markup_files"]),
|
|
307
|
+
}
|
|
308
|
+
r["kind"] = kind
|
|
309
|
+
r["confidence"] = "strong" if strong else "weak"
|
|
310
|
+
r["separable"] = separable
|
|
311
|
+
out.append(r)
|
|
312
|
+
|
|
313
|
+
out.sort(key=lambda r: (r["confidence"] == "strong", r["counts"]["component"],
|
|
314
|
+
r["churn"]), reverse=True)
|
|
315
|
+
strong_roots = [r for r in out if r["confidence"] == "strong"]
|
|
316
|
+
return {
|
|
317
|
+
"present": bool(strong_roots),
|
|
318
|
+
"separable": any(r["separable"] for r in strong_roots),
|
|
319
|
+
"ui_file_count": len(comp) + len(styles) + len(markup),
|
|
320
|
+
"roots": out,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Directory / filename hints that a file participates in a domain aggregate.
|
|
324
|
+
DOMAIN_DIR_RE = re.compile(r"(^|/)(domain|aggregates?|entit(y|ies)|models?)(/|$)", re.IGNORECASE)
|
|
325
|
+
# Layer folders inside a vertical slice. The PARENT of any of these is an
|
|
326
|
+
# aggregate-module candidate (e.g. .../MedicinalProducts/Domain/... -> the
|
|
327
|
+
# aggregate module is MedicinalProducts). This mirrors the aw-spec
|
|
328
|
+
# MinimalMonolith vertical-slice layout and is the strongest, lowest-noise
|
|
329
|
+
# aggregate signal — far better than clustering on file basenames.
|
|
330
|
+
LAYER_DIR_RE = re.compile(
|
|
331
|
+
r"^(domain|features?|application|use[-_]?cases?|handlers?|persistence|"
|
|
332
|
+
r"infrastructure|infra|data|repositor(y|ies)|api|endpoints?|controllers?|"
|
|
333
|
+
r"aggregates?|entit(y|ies)|models?|dtos?|contracts?)$",
|
|
334
|
+
re.IGNORECASE,
|
|
335
|
+
)
|
|
336
|
+
# Which bucket a layer folder maps to, for grouping a candidate's files.
|
|
337
|
+
LAYER_KIND = {
|
|
338
|
+
"domain": "domain", "aggregate": "domain", "aggregates": "domain",
|
|
339
|
+
"entity": "domain", "entities": "domain", "model": "domain", "models": "domain",
|
|
340
|
+
"feature": "feature", "features": "feature", "application": "feature",
|
|
341
|
+
"usecase": "feature", "usecases": "feature", "use-case": "feature",
|
|
342
|
+
"use-cases": "feature", "use_case": "feature", "use_cases": "feature",
|
|
343
|
+
"handler": "feature", "handlers": "feature",
|
|
344
|
+
"persistence": "infra", "infrastructure": "infra", "infra": "infra",
|
|
345
|
+
"data": "infra", "repository": "infra", "repositories": "infra",
|
|
346
|
+
"api": "api", "endpoint": "api", "endpoints": "api",
|
|
347
|
+
"controller": "api", "controllers": "api",
|
|
348
|
+
"dto": "api", "dtos": "api", "contract": "api", "contracts": "api",
|
|
349
|
+
}
|
|
350
|
+
# Type-role suffixes stripped to recover the underlying aggregate/entity noun
|
|
351
|
+
# (used only for the secondary within-aggregate "entities" hint, and for the
|
|
352
|
+
# flat-repo fallback when there are no layer folders).
|
|
353
|
+
ROLE_SUFFIX_RE = re.compile(
|
|
354
|
+
r"(Aggregate(Root)?|Root|Entity|Model|Repository|Repo|Controller|Service|"
|
|
355
|
+
r"Handler|Endpoint|Resource|Dto|DTO|Mapper|Schema|Table|Command|Query|"
|
|
356
|
+
r"Factory|Spec|Specification|View)$"
|
|
357
|
+
)
|
|
358
|
+
# Verb prefixes on CQRS command/use-case names, stripped so RegisterMedicinal…
|
|
359
|
+
# resolves toward its aggregate rather than becoming its own candidate.
|
|
360
|
+
VERB_PREFIX_RE = re.compile(
|
|
361
|
+
r"^(Register|Create|Add|Update|Modify|Change|Delete|Remove|Get|List|Find|"
|
|
362
|
+
r"Fetch|Set|Assign|Approve|Reject|Cancel|Submit|Publish|Archive|Restore|"
|
|
363
|
+
r"Enable|Disable|Activate|Deactivate|Send|Generate)"
|
|
364
|
+
)
|
|
365
|
+
CODE_EXT_RE = re.compile(r"\.(py|js|ts|go|rs|rb|java|cs|php|kt|scala|sql|prisma)$", re.IGNORECASE)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def singularize(name):
|
|
369
|
+
"""Light singularization for a module dir name (MedicinalProducts ->
|
|
370
|
+
MedicinalProduct). Signal-grade only — never grammar-perfect."""
|
|
371
|
+
if len(name) > 4 and name.endswith("s") and not name.lower().endswith(("ss", "us", "is", "as")):
|
|
372
|
+
return name[:-1]
|
|
373
|
+
return name
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --- glob-based config excludes (best-effort, mirrors okf-inventory.sh) -----
|
|
377
|
+
def load_config_excludes(config_path):
|
|
378
|
+
if not config_path or not os.path.isfile(config_path):
|
|
379
|
+
return []
|
|
380
|
+
try:
|
|
381
|
+
with open(config_path, encoding="utf-8") as f:
|
|
382
|
+
lines = f.readlines()
|
|
383
|
+
except OSError:
|
|
384
|
+
return []
|
|
385
|
+
in_exclude = False
|
|
386
|
+
globs = []
|
|
387
|
+
for line in lines:
|
|
388
|
+
stripped = line.strip()
|
|
389
|
+
if re.match(r"^exclude\s*:\s*$", stripped):
|
|
390
|
+
in_exclude = True
|
|
391
|
+
continue
|
|
392
|
+
if in_exclude:
|
|
393
|
+
m = re.match(r"^-\s*[\"']?([^\"'#]+)[\"']?\s*(#.*)?$", stripped)
|
|
394
|
+
if m:
|
|
395
|
+
globs.append(m.group(1).strip())
|
|
396
|
+
continue
|
|
397
|
+
if stripped and not stripped.startswith("#"):
|
|
398
|
+
in_exclude = False
|
|
399
|
+
out = []
|
|
400
|
+
for g in globs:
|
|
401
|
+
parts, i = [], 0
|
|
402
|
+
while i < len(g):
|
|
403
|
+
if g[i:i + 2] == "**":
|
|
404
|
+
parts.append(".*"); i += 2
|
|
405
|
+
elif g[i] == "*":
|
|
406
|
+
parts.append("[^/]*"); i += 1
|
|
407
|
+
else:
|
|
408
|
+
parts.append(re.escape(g[i])); i += 1
|
|
409
|
+
out.append(re.compile("^" + "".join(parts) + "$"))
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def git(root, *args):
|
|
414
|
+
"""Run a git command in `root`; return stdout (stripped) or '' on failure."""
|
|
415
|
+
try:
|
|
416
|
+
p = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True)
|
|
417
|
+
return p.stdout.strip() if p.returncode == 0 else ""
|
|
418
|
+
except Exception:
|
|
419
|
+
return ""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def is_excluded(path, config_excludes):
|
|
423
|
+
if DEFAULT_EXCLUDE.search(path):
|
|
424
|
+
return True
|
|
425
|
+
return any(rx.match(path) for rx in config_excludes)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def list_files(root, config_excludes):
|
|
429
|
+
"""Repo-relative-to-`root` file list, honoring .gitignore via git ls-files."""
|
|
430
|
+
if git(root, "rev-parse", "--git-dir"):
|
|
431
|
+
raw = git(root, "ls-files")
|
|
432
|
+
files = raw.splitlines() if raw else []
|
|
433
|
+
else:
|
|
434
|
+
files = []
|
|
435
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
436
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
437
|
+
for fn in filenames:
|
|
438
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
|
439
|
+
files.append(rel.replace(os.sep, "/"))
|
|
440
|
+
return [f for f in files if f.strip() and not is_excluded(f, config_excludes)]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def churn(root, config_excludes):
|
|
444
|
+
"""Commit-count per file over the last HIST_COMMITS commits, scoped to the
|
|
445
|
+
scan subtree (paths made relative to `root` via --show-prefix)."""
|
|
446
|
+
if not git(root, "rev-parse", "--git-dir"):
|
|
447
|
+
return [], 0
|
|
448
|
+
prefix = git(root, "rev-parse", "--show-prefix") # root's path under toplevel
|
|
449
|
+
raw = git(root, "log", "--no-merges", "-n", str(HIST_COMMITS),
|
|
450
|
+
"--pretty=format:", "--name-only", "--", ".")
|
|
451
|
+
counts = {}
|
|
452
|
+
for line in raw.splitlines():
|
|
453
|
+
line = line.strip()
|
|
454
|
+
if not line:
|
|
455
|
+
continue
|
|
456
|
+
rel = line[len(prefix):] if prefix and line.startswith(prefix) else line
|
|
457
|
+
if prefix and not line.startswith(prefix):
|
|
458
|
+
continue # outside the scan subtree
|
|
459
|
+
if is_excluded(rel, config_excludes):
|
|
460
|
+
continue
|
|
461
|
+
counts[rel] = counts.get(rel, 0) + 1
|
|
462
|
+
ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:CHURN_TOP]
|
|
463
|
+
scanned = git(root, "rev-list", "--no-merges", "--count", "-n", str(HIST_COMMITS), "HEAD")
|
|
464
|
+
return [{"file": f, "commits": c} for f, c in ranked], int(scanned or 0)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def recent_commits(root):
|
|
468
|
+
if not git(root, "rev-parse", "--git-dir"):
|
|
469
|
+
return []
|
|
470
|
+
raw = git(root, "log", "--no-merges", "-n", str(HIST_RECENT),
|
|
471
|
+
"--pretty=format:%h%x09%cI%x09%s", "--", ".")
|
|
472
|
+
out = []
|
|
473
|
+
for line in raw.splitlines():
|
|
474
|
+
parts = line.split("\t")
|
|
475
|
+
if len(parts) >= 3:
|
|
476
|
+
out.append({"sha": parts[0], "date": parts[1], "subject": "\t".join(parts[2:])})
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def matching(files, rx):
|
|
481
|
+
return [f for f in files if rx.search(f)]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _entity_noun(path):
|
|
485
|
+
"""Recover the domain noun from a file basename (role suffixes + a CQRS verb
|
|
486
|
+
prefix stripped): RegisterMedicinalProductCommandHandler -> MedicinalProduct.
|
|
487
|
+
Signal-grade; returns None for non-nouny names."""
|
|
488
|
+
base = os.path.splitext(os.path.basename(path))[0]
|
|
489
|
+
base = re.split(r"[._-]", base)[0]
|
|
490
|
+
prev = None
|
|
491
|
+
while prev != base:
|
|
492
|
+
prev = base
|
|
493
|
+
base = ROLE_SUFFIX_RE.sub("", base)
|
|
494
|
+
base = VERB_PREFIX_RE.sub("", base)
|
|
495
|
+
return base if base and base[:1].isalpha() and len(base) > 2 else None
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def aggregate_signals(files, churn_map):
|
|
499
|
+
"""Derive CANDIDATE aggregate roots — one per vertical slice, the base unit
|
|
500
|
+
the /appweaver inventory workflow turns into a specs/<Feature>/ slice.
|
|
501
|
+
|
|
502
|
+
PRIMARY signal (low-noise): a MODULE DIRECTORY that owns layer folders
|
|
503
|
+
(Domain / Features / Persistence / Api …). Its parent segment, singularized,
|
|
504
|
+
is the aggregate (…/MedicinalProducts/Domain/… -> MedicinalProduct); every
|
|
505
|
+
file under the module is grouped by layer. FALLBACK (flat repos with no
|
|
506
|
+
layer folders): cluster domain/route/api files on the basename noun.
|
|
507
|
+
|
|
508
|
+
Signals only — the workflow's agent draws the real boundaries. Empty for a
|
|
509
|
+
repo with no recognizable domain structure."""
|
|
510
|
+
modules = {} # module_dir -> candidate
|
|
511
|
+
|
|
512
|
+
def module_candidate(module_dir, name):
|
|
513
|
+
return modules.setdefault(module_dir, {
|
|
514
|
+
"name": name, "module_dir": module_dir,
|
|
515
|
+
"domain_files": [], "feature_files": [], "infra_files": [],
|
|
516
|
+
"api_files": [], "other_files": [], "entities": set(), "churn": 0,
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
# PRIMARY: walk each file's path for a layer folder; its parent is the module.
|
|
520
|
+
module_files = {} # module_dir -> [(file, kind)]
|
|
521
|
+
for f in files:
|
|
522
|
+
parts = f.split("/")
|
|
523
|
+
for i in range(1, len(parts) - 1): # a layer folder needs a parent + a child
|
|
524
|
+
if LAYER_DIR_RE.match(parts[i]):
|
|
525
|
+
module_dir = "/".join(parts[:i])
|
|
526
|
+
kind = LAYER_KIND.get(parts[i].lower(), "other")
|
|
527
|
+
module_files.setdefault(module_dir, []).append((f, kind))
|
|
528
|
+
break
|
|
529
|
+
|
|
530
|
+
for module_dir, entries in module_files.items():
|
|
531
|
+
name = singularize(os.path.basename(module_dir)) or os.path.basename(module_dir)
|
|
532
|
+
c = module_candidate(module_dir, name)
|
|
533
|
+
for f, kind in entries:
|
|
534
|
+
c[kind + "_files"].append(f)
|
|
535
|
+
c["churn"] += churn_map.get(f, 0)
|
|
536
|
+
if kind == "domain":
|
|
537
|
+
n = _entity_noun(f)
|
|
538
|
+
if n:
|
|
539
|
+
c["entities"].add(n)
|
|
540
|
+
|
|
541
|
+
sig = []
|
|
542
|
+
for c in modules.values():
|
|
543
|
+
layers = ("domain_files", "feature_files", "infra_files", "api_files", "other_files")
|
|
544
|
+
# Drop pure-doc/config false positives (e.g. docs/domain/): a real
|
|
545
|
+
# aggregate module owns at least one source file.
|
|
546
|
+
if not any(CODE_EXT_RE.search(f) for k in layers for f in c[k]):
|
|
547
|
+
continue
|
|
548
|
+
for k in layers:
|
|
549
|
+
c[k] = sorted(set(c[k]))[:CAP]
|
|
550
|
+
c["entities"] = sorted(c["entities"])[:CAP]
|
|
551
|
+
c["file_count"] = sum(len(c[k]) for k in layers)
|
|
552
|
+
c["evidence_count"] = c["file_count"]
|
|
553
|
+
sig.append(c)
|
|
554
|
+
|
|
555
|
+
# FALLBACK: only if the module walk found nothing (flat repo, no layer dirs).
|
|
556
|
+
if not sig:
|
|
557
|
+
domain = matching(files, DB_FILES_RE) + [f for f in files if DOMAIN_DIR_RE.search(f)]
|
|
558
|
+
flat = {}
|
|
559
|
+
for f in domain + matching(files, ROUTES_RE) + matching(files, API_DEFS_RE):
|
|
560
|
+
noun = _entity_noun(f)
|
|
561
|
+
if not noun:
|
|
562
|
+
continue
|
|
563
|
+
c = flat.setdefault(noun.lower(), {
|
|
564
|
+
"name": noun, "module_dir": "", "domain_files": [], "feature_files": [],
|
|
565
|
+
"infra_files": [], "api_files": [], "other_files": [], "entities": [], "churn": 0})
|
|
566
|
+
c["other_files"].append(f)
|
|
567
|
+
c["churn"] += churn_map.get(f, 0)
|
|
568
|
+
for c in flat.values():
|
|
569
|
+
c["other_files"] = sorted(set(c["other_files"]))[:CAP]
|
|
570
|
+
c["file_count"] = len(c["other_files"])
|
|
571
|
+
c["evidence_count"] = c["file_count"]
|
|
572
|
+
if c["file_count"] >= 2:
|
|
573
|
+
sig.append(c)
|
|
574
|
+
|
|
575
|
+
sig.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
|
|
576
|
+
return sig
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# A UI file wears its role in its NAME, and the vocabulary is not the backend's:
|
|
580
|
+
# ROLE_SUFFIX_RE knows Repository / Handler / Controller and none of List / Form /
|
|
581
|
+
# Page. Kept as a separate list rather than merged into it — applying the UI roles to
|
|
582
|
+
# backend names would eat `ProductService` down to `Product`, which is a different
|
|
583
|
+
# claim than the backend clustering makes.
|
|
584
|
+
UI_ROLE_TOKENS = (
|
|
585
|
+
"list", "table", "grid", "form", "page", "screen", "view", "details", "detail",
|
|
586
|
+
"card", "item", "row", "modal", "dialog", "drawer", "panel", "editor", "widget",
|
|
587
|
+
"container", "component", "layout", "section", "tab", "menu", "nav", "header",
|
|
588
|
+
"footer", "sidebar", "toolbar", "filter", "search", "picker", "selector",
|
|
589
|
+
"provider", "context", "store", "slice", "hook", "service", "client", "api",
|
|
590
|
+
"index", "styles", "style", "module", "spec", "test", "stories",
|
|
591
|
+
)
|
|
592
|
+
UI_ACTION_TOKENS = (
|
|
593
|
+
"register", "create", "add", "new", "edit", "update", "modify", "change",
|
|
594
|
+
"delete", "remove", "get", "list", "find", "fetch", "view", "show", "manage",
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _ui_norm(text):
|
|
599
|
+
"""Bare lowercase alphanumerics — the one form every casing convention shares."""
|
|
600
|
+
return re.sub(r"[^a-z0-9]", "", text.lower())
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _ui_stem(path):
|
|
604
|
+
"""The concept a UI filename points at, its role suffix and action prefix removed:
|
|
605
|
+
medicinal-product-list.tsx, MedicinalProductForm.vue and EditMedicinalProduct.tsx
|
|
606
|
+
all reduce to `medicinalproduct`."""
|
|
607
|
+
stem = _ui_norm(os.path.splitext(os.path.basename(path))[0])
|
|
608
|
+
changed = True
|
|
609
|
+
while changed:
|
|
610
|
+
changed = False
|
|
611
|
+
for t in UI_ROLE_TOKENS:
|
|
612
|
+
if stem.endswith(t) and len(stem) > len(t) + 2:
|
|
613
|
+
stem, changed = stem[: -len(t)], True
|
|
614
|
+
for t in UI_ACTION_TOKENS:
|
|
615
|
+
if stem.startswith(t) and len(stem) > len(t) + 2:
|
|
616
|
+
stem, changed = stem[len(t):], True
|
|
617
|
+
return stem
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _ui_name_tokens(name):
|
|
621
|
+
"""The normalised forms an aggregate name wears in a UI tree. The folder is
|
|
622
|
+
usually plural where the aggregate name is singular, so both travel together."""
|
|
623
|
+
base = _ui_norm(name)
|
|
624
|
+
if not base:
|
|
625
|
+
return set()
|
|
626
|
+
out = {base, base + "s"}
|
|
627
|
+
if base.endswith("y"):
|
|
628
|
+
out.add(base[:-1] + "ies")
|
|
629
|
+
if base.endswith("s"):
|
|
630
|
+
out.add(base[:-1])
|
|
631
|
+
return out
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def attribute_ui_files(signals, fe_files, churn_map, ui_roots):
|
|
635
|
+
"""Attribute each frontend file to the aggregate it serves.
|
|
636
|
+
|
|
637
|
+
WHY THIS EXISTS. scope_to_lanes splits the substrate so the backend and frontend
|
|
638
|
+
steps cannot see each other's files, which means aggregate_signals is built from
|
|
639
|
+
backend files ALONE — and a slice's OKF concept would list none of the UI that
|
|
640
|
+
serves it. adopt then reverses that slice with no UI source within reach, so the
|
|
641
|
+
frontend lane's design has nothing to be derived from.
|
|
642
|
+
|
|
643
|
+
The match is by NAME with both sides normalised: a DIRECTORY SEGMENT named for the
|
|
644
|
+
concept (the strong signal — a feature folder), or a filename whose concept stem
|
|
645
|
+
matches once its UI role suffix is stripped. Where several aggregates match, the
|
|
646
|
+
LONGEST name wins, so `MedicinalProduct` takes a file that `Product` would also
|
|
647
|
+
have claimed.
|
|
648
|
+
|
|
649
|
+
SIGNALS, not decisions — the same contract as every other function here. A file
|
|
650
|
+
matching no aggregate stays in `unattributed`, which is the honest answer for the
|
|
651
|
+
shell, layout and design-system files that serve no single feature and belong in
|
|
652
|
+
the architecture note instead of being forced into a slice.
|
|
653
|
+
|
|
654
|
+
SCOPED TO THE FRONTEND TREE, AND SKIPPED ENTIRELY WITHOUT ONE. `ui_roots` is the
|
|
655
|
+
lane's source path — the declared one, or the roots the scan actually found. Two
|
|
656
|
+
guards, because neither is sufficient alone: with no UI root declared scope_to_lanes
|
|
657
|
+
hands back the WHOLE REPO as the frontend list, and the file-shape regexes match
|
|
658
|
+
plenty of things a backend repo legitimately owns — a server-side email template, a
|
|
659
|
+
docs stylesheet, an `.html` test fixture. Matching on shape alone attributes those
|
|
660
|
+
to an aggregate as its "UI". So the search happens under the frontend roots only,
|
|
661
|
+
and where there is no frontend root there is no search.
|
|
662
|
+
"""
|
|
663
|
+
roots = [r for r in (ui_roots or []) if r]
|
|
664
|
+
if not roots:
|
|
665
|
+
return {"attributed_file_count": 0, "aggregates_with_ui": 0,
|
|
666
|
+
"unattributed_count": 0, "unattributed": [], "search_roots": []}
|
|
667
|
+
# "./" is a UI that owns no subtree of its own (a flat repo, a server-rendered view
|
|
668
|
+
# tree): it narrows nothing, and it is only ever reached because a frontend WAS
|
|
669
|
+
# found there — the file-shape filter below carries that case alone.
|
|
670
|
+
prefixes = [] if any(r in ("./", "/") for r in roots) else roots
|
|
671
|
+
in_lane = fe_files if not prefixes else [
|
|
672
|
+
f for f in fe_files if any(f.startswith(p) for p in prefixes)]
|
|
673
|
+
ui_files = sorted(set(
|
|
674
|
+
matching(in_lane, UI_COMPONENT_RE) + matching(in_lane, UI_MARKUP_RE)
|
|
675
|
+
+ matching(in_lane, UI_STYLE_RE) + matching(in_lane, UI_ROUTE_RE)
|
|
676
|
+
+ matching(in_lane, UI_STORE_RE)
|
|
677
|
+
))
|
|
678
|
+
if not ui_files:
|
|
679
|
+
return {"attributed_file_count": 0, "aggregates_with_ui": 0,
|
|
680
|
+
"unattributed_count": 0, "unattributed": [], "search_roots": roots}
|
|
681
|
+
|
|
682
|
+
# name tokens -> the candidate, longest name first so the most specific match wins
|
|
683
|
+
ranked = sorted(signals, key=lambda c: len(_ui_norm(c["name"])), reverse=True)
|
|
684
|
+
token_map = [(c, _ui_name_tokens(c["name"])) for c in ranked]
|
|
685
|
+
for c, _ in token_map:
|
|
686
|
+
c.setdefault("ui_files", [])
|
|
687
|
+
|
|
688
|
+
unattributed = []
|
|
689
|
+
for f in ui_files:
|
|
690
|
+
segments = {_ui_norm(p) for p in os.path.dirname(f).split("/") if p}
|
|
691
|
+
stem = _ui_stem(f)
|
|
692
|
+
for c, tokens in token_map:
|
|
693
|
+
if (segments & tokens) or (stem and stem in tokens):
|
|
694
|
+
c["ui_files"].append(f)
|
|
695
|
+
c["churn"] += churn_map.get(f, 0)
|
|
696
|
+
break
|
|
697
|
+
else:
|
|
698
|
+
unattributed.append(f)
|
|
699
|
+
|
|
700
|
+
attributed = 0
|
|
701
|
+
with_ui = 0
|
|
702
|
+
for c, _ in token_map:
|
|
703
|
+
c["ui_files"] = sorted(set(c["ui_files"]))[:CAP]
|
|
704
|
+
# ui_file_count is reported BESIDE file_count, never folded into it: the
|
|
705
|
+
# aggregate ranking is backend evidence and a UI-heavy slice must not
|
|
706
|
+
# outrank a domain-heavy one on component count.
|
|
707
|
+
c["ui_file_count"] = len(c["ui_files"])
|
|
708
|
+
attributed += c["ui_file_count"]
|
|
709
|
+
if c["ui_file_count"]:
|
|
710
|
+
with_ui += 1
|
|
711
|
+
|
|
712
|
+
return {"attributed_file_count": attributed, "aggregates_with_ui": with_ui,
|
|
713
|
+
"unattributed_count": len(unattributed),
|
|
714
|
+
"unattributed": sorted(unattributed)[:CAP], "search_roots": roots}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def bundle_state(bundle_dir):
|
|
718
|
+
"""How many concept .md files a candidate bundle already holds (the OKF
|
|
719
|
+
generate guard: a non-empty bundle means /appweaver inventory would
|
|
720
|
+
clobber curation — the workflow should stop unless --force)."""
|
|
721
|
+
if not os.path.isdir(bundle_dir):
|
|
722
|
+
return False, 0
|
|
723
|
+
count = 0
|
|
724
|
+
for dirpath, dirnames, filenames in os.walk(bundle_dir):
|
|
725
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
726
|
+
count += sum(1 for f in filenames if f.endswith(".md") and f not in ("index.md", "log.md"))
|
|
727
|
+
return count > 0, count
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _lane_prefix(p):
|
|
731
|
+
"""A declared source path as a '/'-terminated repo-relative prefix, or ''."""
|
|
732
|
+
s = (p or "").strip().replace("\\", "/").lstrip("./").lstrip("/")
|
|
733
|
+
if s and not s.endswith("/"):
|
|
734
|
+
s += "/"
|
|
735
|
+
return "" if s == "/" else s
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def scope_to_lanes(files, service_root, ui_root):
|
|
739
|
+
"""Split the file list the way the project declared its lanes.
|
|
740
|
+
|
|
741
|
+
WHY THIS EXISTS. The substrate is inlined verbatim into every agent request the
|
|
742
|
+
onboarding run makes — six of them — so its size is paid six times, and its
|
|
743
|
+
CONTENT is paid in a subtler currency. Unscoped, the step deriving the service
|
|
744
|
+
lane's rules receives the whole frontend file list while its prompt tells it to
|
|
745
|
+
exclude those roots, and the steps deriving the UI lane's rules receive every
|
|
746
|
+
backend aggregate. Telling a model not to use what you have just put in front of
|
|
747
|
+
it is the weakest control available; not putting it there is the strongest.
|
|
748
|
+
|
|
749
|
+
So: backend signals come from everything OUTSIDE the UI lane's subtree (and
|
|
750
|
+
inside the service lane's, where one is declared), and frontend signals come
|
|
751
|
+
from the UI lane's subtree alone. Repo-wide categories are deliberately NOT
|
|
752
|
+
scoped — the CI config, the editorconfig and the root manifests describe the
|
|
753
|
+
whole repository, and scoping them would hide the very files that say how it is
|
|
754
|
+
built.
|
|
755
|
+
|
|
756
|
+
With neither root declared this returns the full list twice and the scan behaves
|
|
757
|
+
exactly as it did before lanes were declarable.
|
|
758
|
+
"""
|
|
759
|
+
svc, ui = _lane_prefix(service_root), _lane_prefix(ui_root)
|
|
760
|
+
be = [f for f in files if not (ui and f.startswith(ui))]
|
|
761
|
+
if svc:
|
|
762
|
+
be = [f for f in be if f.startswith(svc)]
|
|
763
|
+
fe = [f for f in files if f.startswith(ui)] if ui else files
|
|
764
|
+
return be, fe, svc, ui
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def cmd_scan(args):
|
|
768
|
+
root = os.path.abspath(args.root or os.getcwd())
|
|
769
|
+
config_excludes = load_config_excludes(args.config)
|
|
770
|
+
out = os.path.abspath(args.out) if args.out else os.path.join(root, ".appweaver", "inventory", "inventory.json")
|
|
771
|
+
bundle_dir = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
|
|
772
|
+
|
|
773
|
+
files = list_files(root, config_excludes)
|
|
774
|
+
churn_list, commits_scanned = churn(root, config_excludes)
|
|
775
|
+
churn_map = {c["file"]: c["commits"] for c in churn_list}
|
|
776
|
+
|
|
777
|
+
def hist(s):
|
|
778
|
+
h = {}
|
|
779
|
+
for f in files:
|
|
780
|
+
_, dot, ext = f.rpartition(".")
|
|
781
|
+
if dot and ext:
|
|
782
|
+
h[ext] = h.get(ext, 0) + 1
|
|
783
|
+
return h
|
|
784
|
+
|
|
785
|
+
lang_hist = dict(sorted(hist(files).items(), key=lambda kv: kv[1], reverse=True)[:15])
|
|
786
|
+
topdirs = {}
|
|
787
|
+
for f in files:
|
|
788
|
+
if "/" in f:
|
|
789
|
+
top = f.split("/", 1)[0]
|
|
790
|
+
topdirs[top] = topdirs.get(top, 0) + 1
|
|
791
|
+
top_level_dirs = dict(sorted(topdirs.items(), key=lambda kv: kv[1], reverse=True)[:20])
|
|
792
|
+
|
|
793
|
+
def category(rx):
|
|
794
|
+
hits = matching(files, rx)
|
|
795
|
+
return {"items": hits[:CAP], "truncated": len(hits) > CAP}
|
|
796
|
+
|
|
797
|
+
cats = {
|
|
798
|
+
"dependency_manifests": category(MANIFESTS_RE),
|
|
799
|
+
"entrypoints": category(ENTRYPOINTS_RE),
|
|
800
|
+
"api_definitions": category(API_DEFS_RE),
|
|
801
|
+
"route_like_files": category(ROUTES_RE),
|
|
802
|
+
"data_layer_files": category(DB_FILES_RE),
|
|
803
|
+
"ops_files": category(OPS_FILES_RE),
|
|
804
|
+
"docs": category(DOCS_RE),
|
|
805
|
+
"config_files": category(CONFIGS_RE),
|
|
806
|
+
"adr_docs": category(ADR_RE),
|
|
807
|
+
}
|
|
808
|
+
be_files, fe_files, svc_prefix, ui_prefix = scope_to_lanes(
|
|
809
|
+
files, args.service_root, args.ui_root)
|
|
810
|
+
signals = aggregate_signals(be_files, churn_map)
|
|
811
|
+
fsig = frontend_signals(fe_files, churn_map)
|
|
812
|
+
|
|
813
|
+
# A DECLARED UI lane is a lane. The strength test below UI_STRONG_MIN exists to
|
|
814
|
+
# stop a scan INVENTING one from a wwwroot/ of loose scripts; it has no business
|
|
815
|
+
# overruling a person who declared the lane and named its source path. What the
|
|
816
|
+
# evidence still decides is what goes IN the governance — and a declared root
|
|
817
|
+
# holding no recognisable UI files is reported as exactly that, so onboard can
|
|
818
|
+
# say so rather than quietly authoring a UI half out of nothing.
|
|
819
|
+
if ui_prefix:
|
|
820
|
+
fsig["declared_root"] = ui_prefix
|
|
821
|
+
fsig["ui_evidence_found"] = bool(fsig.get("roots"))
|
|
822
|
+
fsig["present"] = True
|
|
823
|
+
fsig["separable"] = ui_prefix not in ("", "./") and not svc_prefix.startswith(ui_prefix)
|
|
824
|
+
if not fsig.get("roots"):
|
|
825
|
+
fsig["roots"] = [{"root": ui_prefix, "kind": "declared", "confidence": "declared",
|
|
826
|
+
"counts": {"component": 0}, "framework_markers": [],
|
|
827
|
+
"component_files": [], "style_files": [], "markup_files": [],
|
|
828
|
+
"build_configs": [], "route_files": [], "store_files": []}]
|
|
829
|
+
|
|
830
|
+
# Give every aggregate candidate the UI that serves it — searching the FRONTEND
|
|
831
|
+
# SOURCE PATH and nothing else. The declared root is the authority; absent a
|
|
832
|
+
# declaration the roots the scan actually found stand in, which keeps a repo whose
|
|
833
|
+
# UI is server-rendered inside the service tree covered. With neither, this repo
|
|
834
|
+
# has no frontend and the pass does not run at all.
|
|
835
|
+
if ui_prefix:
|
|
836
|
+
ui_search_roots = [ui_prefix]
|
|
837
|
+
elif fsig.get("present"):
|
|
838
|
+
ui_search_roots = [r.get("root", "") for r in (fsig.get("roots") or [])]
|
|
839
|
+
else:
|
|
840
|
+
ui_search_roots = []
|
|
841
|
+
ui_attr = attribute_ui_files(signals, fe_files, churn_map, ui_search_roots)
|
|
842
|
+
|
|
843
|
+
present, md_count = bundle_state(bundle_dir)
|
|
844
|
+
|
|
845
|
+
inv = {
|
|
846
|
+
"root": root,
|
|
847
|
+
"git": {
|
|
848
|
+
"remote": git(root, "remote", "get-url", "origin"),
|
|
849
|
+
"branch": git(root, "symbolic-ref", "--short", "HEAD") or "main",
|
|
850
|
+
"head": git(root, "rev-parse", "--short", "HEAD"),
|
|
851
|
+
"history": {
|
|
852
|
+
"commits_scanned": commits_scanned,
|
|
853
|
+
"churn": churn_list,
|
|
854
|
+
"recent_commits": recent_commits(root),
|
|
855
|
+
"churn_truncated": len(churn_list) >= CHURN_TOP > 0,
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
"file_count": len(files),
|
|
859
|
+
"cap": CAP,
|
|
860
|
+
# The lane scoping this substrate was built under, so a reader can tell a
|
|
861
|
+
# repo with no backend aggregates from one whose backend was scoped away.
|
|
862
|
+
"declared_service_root": svc_prefix,
|
|
863
|
+
"declared_ui_root": ui_prefix,
|
|
864
|
+
"scoped_file_counts": {"backend": len(be_files), "frontend": len(fe_files),
|
|
865
|
+
"repo": len(files)},
|
|
866
|
+
"language_histogram": lang_hist,
|
|
867
|
+
"top_level_dirs": top_level_dirs,
|
|
868
|
+
**{k: v["items"] for k, v in cats.items()},
|
|
869
|
+
"aggregate_signals": signals,
|
|
870
|
+
"frontend_signals": fsig,
|
|
871
|
+
# Which UI files serve which aggregate, and which serve none. The second half
|
|
872
|
+
# matters as much as the first: `unattributed` is the shared shell, layout and
|
|
873
|
+
# design-system surface, which belongs in the architecture note rather than in
|
|
874
|
+
# any one slice.
|
|
875
|
+
"ui_attribution": ui_attr,
|
|
876
|
+
"truncated": {k: True for k, v in cats.items() if v["truncated"]},
|
|
877
|
+
}
|
|
878
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
879
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
880
|
+
json.dump(inv, f, indent=2)
|
|
881
|
+
|
|
882
|
+
# Human summary -> STDERR (must not pollute the JSON contract on stdout).
|
|
883
|
+
print(f"Inventory written to {out}", file=sys.stderr)
|
|
884
|
+
print(f" files: {inv['file_count']} head: {inv['git']['head'] or '-'} "
|
|
885
|
+
f"branch: {inv['git']['branch']}", file=sys.stderr)
|
|
886
|
+
h = inv["git"]["history"]
|
|
887
|
+
print(f" git history: {h['commits_scanned']} commits scanned, "
|
|
888
|
+
f"{len(h['churn'])} hot files, {len(h['recent_commits'])} recent subjects",
|
|
889
|
+
file=sys.stderr)
|
|
890
|
+
print(f" candidate aggregates ({len(signals)}): "
|
|
891
|
+
f"{', '.join(s['name'] for s in signals[:12])}"
|
|
892
|
+
f"{' …' if len(signals) > 12 else ''}", file=sys.stderr)
|
|
893
|
+
if fsig["roots"]:
|
|
894
|
+
print(f" frontend: {'lane present' if fsig['present'] else 'no lane (weak signals only)'}"
|
|
895
|
+
f" — {len(fsig['roots'])} candidate root(s), {fsig['ui_file_count']} UI file(s)",
|
|
896
|
+
file=sys.stderr)
|
|
897
|
+
for r in fsig["roots"][:6]:
|
|
898
|
+
print(f" {r['root']} {r['kind']}/{r['confidence']}"
|
|
899
|
+
f" components={r['counts']['component']}"
|
|
900
|
+
f" {', '.join(r['framework_markers'][:4]) or '-'}", file=sys.stderr)
|
|
901
|
+
for k in cats:
|
|
902
|
+
flag = " (truncated)" if k in inv["truncated"] else ""
|
|
903
|
+
print(f" {k}: {len(inv[k])}{flag}", file=sys.stderr)
|
|
904
|
+
if present:
|
|
905
|
+
print(f" NOTE: bundle {bundle_dir} already holds {md_count} concept file(s)",
|
|
906
|
+
file=sys.stderr)
|
|
907
|
+
|
|
908
|
+
# Machine contract -> LAST stdout line (bound by the orchestrator).
|
|
909
|
+
print(json.dumps({
|
|
910
|
+
"inventory_json": out,
|
|
911
|
+
"root": root,
|
|
912
|
+
"file_count": inv["file_count"],
|
|
913
|
+
"head": inv["git"]["head"],
|
|
914
|
+
"branch": inv["git"]["branch"],
|
|
915
|
+
"aggregate_count": len(signals),
|
|
916
|
+
# The frontend lane, as the CODE evidences it. Strings, because the engine
|
|
917
|
+
# compares variables as text. `frontend_roots` is the human line a workflow
|
|
918
|
+
# message or prompt quotes; the full evidence stays in the substrate file.
|
|
919
|
+
"frontend_present": "true" if fsig["present"] else "false",
|
|
920
|
+
"frontend_separable": "true" if fsig["separable"] else "false",
|
|
921
|
+
"frontend_root_count": len(fsig["roots"]),
|
|
922
|
+
# The PRIMARY lane root — the strongest candidate, and the single value a
|
|
923
|
+
# lane declaration needs. Empty when no root reached lane strength.
|
|
924
|
+
"frontend_root": next((r["root"] for r in fsig["roots"]
|
|
925
|
+
if r["confidence"] == "strong"), ""),
|
|
926
|
+
"frontend_kind": next((r["kind"] for r in fsig["roots"]
|
|
927
|
+
if r["confidence"] == "strong"), ""),
|
|
928
|
+
"frontend_stack": next(("/".join(r["framework_markers"][:3]) for r in fsig["roots"]
|
|
929
|
+
if r["confidence"] == "strong" and r["framework_markers"]), ""),
|
|
930
|
+
"frontend_roots": ", ".join(
|
|
931
|
+
f"{r['root']} ({r['kind']}, {r['confidence']}"
|
|
932
|
+
+ (f", {'/'.join(r['framework_markers'][:3])}" if r["framework_markers"] else "")
|
|
933
|
+
+ ")"
|
|
934
|
+
for r in fsig["roots"][:6]
|
|
935
|
+
),
|
|
936
|
+
# What the declaration scoped this run to, and whether the UI subtree it named
|
|
937
|
+
# actually held any UI. A declared lane stays a lane either way; this is how
|
|
938
|
+
# onboard tells "reverse-engineered from real UI source" from "declared, but
|
|
939
|
+
# there is nothing here to reverse".
|
|
940
|
+
"declared_service_root": svc_prefix,
|
|
941
|
+
"declared_ui_root": ui_prefix,
|
|
942
|
+
"ui_evidence_found": "true" if fsig.get("ui_evidence_found", bool(fsig["roots"])) else "false",
|
|
943
|
+
"backend_file_count": len(be_files),
|
|
944
|
+
"frontend_file_count": len(fe_files),
|
|
945
|
+
"bundle_dir": bundle_dir,
|
|
946
|
+
"bundle_present": "true" if present else "false",
|
|
947
|
+
"bundle_md_count": md_count,
|
|
948
|
+
}))
|
|
949
|
+
return 0
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _slug(s):
|
|
953
|
+
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", (s or "").lower())).strip("-")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _pascal(s):
|
|
957
|
+
"""kebab/space/anything -> PascalCase, byte-compatible with the bash/ps
|
|
958
|
+
_pascal + set-feature normalizer (uppercase each word's first char, keep the
|
|
959
|
+
rest as-is so an existing PascalCase token like MedicinalProduct is preserved)."""
|
|
960
|
+
words = re.split(r"[^A-Za-z0-9]+", s or "")
|
|
961
|
+
return "".join(w[:1].upper() + w[1:] for w in words if w)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _read_frontmatter(path):
|
|
965
|
+
"""Return the concept's frontmatter dict (title/type/source_files), best-effort
|
|
966
|
+
without a YAML dependency. Only the fields adopt needs."""
|
|
967
|
+
try:
|
|
968
|
+
with open(path, encoding="utf-8") as f:
|
|
969
|
+
text = f.read()
|
|
970
|
+
except OSError:
|
|
971
|
+
return {}
|
|
972
|
+
if not text.startswith("---"):
|
|
973
|
+
return {}
|
|
974
|
+
lines = text.splitlines()
|
|
975
|
+
end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
976
|
+
if end is None:
|
|
977
|
+
return {}
|
|
978
|
+
data, key = {}, None
|
|
979
|
+
for ln in lines[1:end]:
|
|
980
|
+
m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
|
|
981
|
+
if m:
|
|
982
|
+
key = m.group(1)
|
|
983
|
+
val = m.group(2).strip()
|
|
984
|
+
data[key] = val.strip("'\"") if val else []
|
|
985
|
+
elif key and re.match(r"^\s*-\s+", ln): # a list item under the last key
|
|
986
|
+
if not isinstance(data.get(key), list):
|
|
987
|
+
data[key] = []
|
|
988
|
+
data[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
|
|
989
|
+
return data
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def cmd_concept(args):
|
|
993
|
+
"""Resolve the OKF bundle concept for an aggregate → its path + source_files
|
|
994
|
+
(the code that IS the slice). The index adopt reverse-engineers spec.md +
|
|
995
|
+
design.md FROM. Matches on title/filename slug; prefers an Aggregate type."""
|
|
996
|
+
bundle = os.path.abspath(args.bundle)
|
|
997
|
+
want = _slug(args.aggregate)
|
|
998
|
+
best = None
|
|
999
|
+
if os.path.isdir(bundle):
|
|
1000
|
+
for dirpath, dirnames, filenames in os.walk(bundle):
|
|
1001
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
1002
|
+
for fn in filenames:
|
|
1003
|
+
if not fn.endswith(".md") or fn in ("index.md", "log.md"):
|
|
1004
|
+
continue
|
|
1005
|
+
path = os.path.join(dirpath, fn)
|
|
1006
|
+
fm = _read_frontmatter(path)
|
|
1007
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
1008
|
+
base = os.path.splitext(fn)[0]
|
|
1009
|
+
is_match = want in (_slug(title), _slug(base)) or (
|
|
1010
|
+
want and title and want.replace("-", "") in _slug(title).replace("-", ""))
|
|
1011
|
+
if not is_match:
|
|
1012
|
+
continue
|
|
1013
|
+
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
1014
|
+
sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
|
|
1015
|
+
usf = [s for s in (fm.get("ui_source_files") or []) if isinstance(s, str) and s.strip()]
|
|
1016
|
+
score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
|
|
1017
|
+
cand = {"concept_path": os.path.relpath(path, os.getcwd()),
|
|
1018
|
+
"title": title or base, "type": typ, "source_files": sf,
|
|
1019
|
+
"ui_source_files": usf, "score": score}
|
|
1020
|
+
if best is None or cand["score"] > best["score"]:
|
|
1021
|
+
best = cand
|
|
1022
|
+
if not best:
|
|
1023
|
+
print(json.dumps({"exists": "false", "aggregate": args.aggregate, "concept_path": "",
|
|
1024
|
+
"source_files": [], "ui_source_files": [], "ui_count": 0,
|
|
1025
|
+
"count": 0, "module_dir": ""}))
|
|
1026
|
+
return 0
|
|
1027
|
+
sf = best["source_files"]
|
|
1028
|
+
usf = best.get("ui_source_files") or []
|
|
1029
|
+
# Common directory prefix of the SERVICE source files = the aggregate's module dir.
|
|
1030
|
+
# The UI half is deliberately excluded: a separable frontend shares no prefix with
|
|
1031
|
+
# the service tree, so folding it in would collapse module_dir to the repo root.
|
|
1032
|
+
module_dir = ""
|
|
1033
|
+
if sf:
|
|
1034
|
+
common = os.path.commonpath([s.replace("\\", "/") for s in sf]) if len(sf) > 1 else os.path.dirname(sf[0])
|
|
1035
|
+
module_dir = common
|
|
1036
|
+
print(f"Concept for {args.aggregate}: {best['concept_path']} "
|
|
1037
|
+
f"({len(sf)} source file(s), {len(usf)} ui file(s), "
|
|
1038
|
+
f"module {module_dir or '-'})", file=sys.stderr)
|
|
1039
|
+
print(json.dumps({
|
|
1040
|
+
"exists": "true", "aggregate": args.aggregate, "concept_path": best["concept_path"],
|
|
1041
|
+
"title": best["title"], "type": best["type"],
|
|
1042
|
+
"source_files": sf, "count": len(sf), "module_dir": module_dir,
|
|
1043
|
+
# The slice's UI surface, listed apart so each lane's reverse step reads its
|
|
1044
|
+
# own files. Empty on a backend-only slice.
|
|
1045
|
+
"ui_source_files": usf, "ui_count": len(usf),
|
|
1046
|
+
}))
|
|
1047
|
+
return 0
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def cmd_list_aggregates(args):
|
|
1051
|
+
"""Enumerate the OKF bundle's Aggregate concepts — the brownfield FEATURE MAP.
|
|
1052
|
+
This is the selectable feature list `select-feature` offers in a brownfield
|
|
1053
|
+
repo (the discovered aggregates), as opposed to only the specs/ sub-folders.
|
|
1054
|
+
|
|
1055
|
+
Each aggregate → its candidate specs/<Feature> slice name (PascalCase, the id
|
|
1056
|
+
set-feature would create), its title/concept_path, and source_file_count. The
|
|
1057
|
+
caller merges these with the specs/ list, deduping by name (case-insensitive).
|
|
1058
|
+
Empty {aggregates:[], count:0} on a greenfield repo (no bundle) — a no-op there.
|
|
1059
|
+
"""
|
|
1060
|
+
bundle = os.path.abspath(args.bundle)
|
|
1061
|
+
present = os.path.isdir(bundle)
|
|
1062
|
+
by_name = {}
|
|
1063
|
+
if present:
|
|
1064
|
+
for dirpath, dirnames, filenames in os.walk(bundle):
|
|
1065
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
1066
|
+
for fn in sorted(filenames):
|
|
1067
|
+
if not fn.endswith(".md") or fn in ("index.md", "log.md"):
|
|
1068
|
+
continue
|
|
1069
|
+
path = os.path.join(dirpath, fn)
|
|
1070
|
+
fm = _read_frontmatter(path)
|
|
1071
|
+
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
1072
|
+
if "aggregate" not in typ.lower():
|
|
1073
|
+
continue
|
|
1074
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
1075
|
+
base = os.path.splitext(fn)[0]
|
|
1076
|
+
name = _pascal(title or base)
|
|
1077
|
+
if not name:
|
|
1078
|
+
continue
|
|
1079
|
+
sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
|
|
1080
|
+
cand = {
|
|
1081
|
+
"name": name,
|
|
1082
|
+
"title": title or base,
|
|
1083
|
+
"slug": _slug(name),
|
|
1084
|
+
"concept_path": os.path.relpath(path, os.getcwd()),
|
|
1085
|
+
"type": typ,
|
|
1086
|
+
"source_file_count": len(sf),
|
|
1087
|
+
}
|
|
1088
|
+
# Dedup by the case-insensitive feature name; a filename-slug match
|
|
1089
|
+
# to the wanted id, or a richer source set, wins the tie.
|
|
1090
|
+
key = name.lower()
|
|
1091
|
+
prev = by_name.get(key)
|
|
1092
|
+
if prev is None or (
|
|
1093
|
+
(_slug(base) == cand["slug"]) or cand["source_file_count"] > prev["source_file_count"]):
|
|
1094
|
+
by_name[key] = cand
|
|
1095
|
+
aggs = sorted(by_name.values(), key=lambda a: a["name"].lower())
|
|
1096
|
+
print(f"OKF aggregates in {args.bundle}: {len(aggs)} "
|
|
1097
|
+
f"({', '.join(a['name'] for a in aggs) or '-'})", file=sys.stderr)
|
|
1098
|
+
print(json.dumps({
|
|
1099
|
+
"aggregates": aggs, "count": len(aggs),
|
|
1100
|
+
"bundle": args.bundle, "bundle_present": "true" if present else "false",
|
|
1101
|
+
}))
|
|
1102
|
+
return 0
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def cmd_validate(args):
|
|
1106
|
+
validator = os.path.join(HERE, "validate_okf.py")
|
|
1107
|
+
if not os.path.isfile(validator):
|
|
1108
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
1109
|
+
"errors": [f"validator not found: {validator}"], "warnings": [],
|
|
1110
|
+
"concepts": 0}))
|
|
1111
|
+
return 1
|
|
1112
|
+
if not os.path.isdir(args.bundle):
|
|
1113
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
1114
|
+
"errors": [f"bundle dir not found: {args.bundle}"],
|
|
1115
|
+
"warnings": [], "concepts": 0}))
|
|
1116
|
+
return 1
|
|
1117
|
+
cmd = [sys.executable, validator, args.bundle, "--json"]
|
|
1118
|
+
if args.repo_root:
|
|
1119
|
+
cmd += ["--repo-root", args.repo_root]
|
|
1120
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
1121
|
+
try:
|
|
1122
|
+
# validate_okf.py --json emits one pretty-printed (multi-line) object.
|
|
1123
|
+
raw = proc.stdout.strip()
|
|
1124
|
+
data = json.loads(raw[raw.index("{"):])
|
|
1125
|
+
except Exception:
|
|
1126
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
1127
|
+
"errors": [f"validator emitted no JSON: {proc.stdout[:300]} "
|
|
1128
|
+
f"{proc.stderr[:300]}"], "warnings": [], "concepts": 0}))
|
|
1129
|
+
return 1
|
|
1130
|
+
# Mirror validate_okf's human report to stderr for the operator.
|
|
1131
|
+
print(f"OKF validation of {args.bundle}: {data.get('result')} "
|
|
1132
|
+
f"({data.get('concepts')} concepts, {len(data.get('errors', []))} errors, "
|
|
1133
|
+
f"{len(data.get('warnings', []))} warnings)", file=sys.stderr)
|
|
1134
|
+
for e in data.get("errors", []):
|
|
1135
|
+
print(f" ERROR {e}", file=sys.stderr)
|
|
1136
|
+
for w in data.get("warnings", []):
|
|
1137
|
+
print(f" WARNING {w}", file=sys.stderr)
|
|
1138
|
+
data["conformant"] = "false" if data.get("errors") else "true"
|
|
1139
|
+
print(json.dumps(data))
|
|
1140
|
+
return 1 if data.get("errors") else 0
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def main():
|
|
1144
|
+
ap = argparse.ArgumentParser(description="Brownfield inventory scanner + OKF validator bridge.")
|
|
1145
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1146
|
+
|
|
1147
|
+
ps = sub.add_parser("scan", help="Scan a brownfield repo; emit the JSON inventory substrate.")
|
|
1148
|
+
ps.add_argument("--root", help="Codebase to inventory (default: CWD; git history scoped to it).")
|
|
1149
|
+
ps.add_argument("--out", help="Substrate JSON path (default: <root>/.appweaver/inventory/inventory.json).")
|
|
1150
|
+
ps.add_argument("--bundle", help="OKF bundle dir to check for existing curation (default: <root>/knowledge).")
|
|
1151
|
+
ps.add_argument("--config", help="okf-config.yml whose exclude: list is honored.")
|
|
1152
|
+
ps.add_argument("--service-root", dest="service_root", default="",
|
|
1153
|
+
help="The service lane's declared source path. Backend signals are "
|
|
1154
|
+
"derived from this subtree only.")
|
|
1155
|
+
ps.add_argument("--ui-root", dest="ui_root", default="",
|
|
1156
|
+
help="The UI lane's declared source path. Frontend signals are derived "
|
|
1157
|
+
"from this subtree only, and it is excluded from the backend's — "
|
|
1158
|
+
"so neither lane's substrate carries the other's files.")
|
|
1159
|
+
ps.set_defaults(func=cmd_scan)
|
|
1160
|
+
|
|
1161
|
+
pc = sub.add_parser("concept", help="Resolve the OKF bundle concept + source_files for an aggregate.")
|
|
1162
|
+
pc.add_argument("--bundle", required=True, help="OKF bundle dir to search.")
|
|
1163
|
+
pc.add_argument("--aggregate", required=True, help="Aggregate name (e.g. MedicinalProduct).")
|
|
1164
|
+
pc.set_defaults(func=cmd_concept)
|
|
1165
|
+
|
|
1166
|
+
pl = sub.add_parser("list-aggregates", help="List the OKF bundle's Aggregate concepts (the brownfield feature map).")
|
|
1167
|
+
pl.add_argument("--bundle", required=True, help="OKF bundle dir to enumerate.")
|
|
1168
|
+
pl.set_defaults(func=cmd_list_aggregates)
|
|
1169
|
+
|
|
1170
|
+
pv = sub.add_parser("validate", help="Run the OKF v0.1 conformance checker on a bundle.")
|
|
1171
|
+
pv.add_argument("--bundle", required=True, help="OKF bundle dir to validate.")
|
|
1172
|
+
pv.add_argument("--repo-root", help="Repo root for resolving source_files (default: nearest .git).")
|
|
1173
|
+
pv.set_defaults(func=cmd_validate)
|
|
1174
|
+
|
|
1175
|
+
args = ap.parse_args()
|
|
1176
|
+
return args.func(args)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
if __name__ == "__main__":
|
|
1180
|
+
sys.exit(main())
|