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.
Files changed (257) hide show
  1. package/assets/.appweaver/DESIGN-FORMAT.md +200 -0
  2. package/assets/.appweaver/ORCH-DIALECT.md +156 -0
  3. package/assets/.appweaver/RULE-PARTITION.md +175 -0
  4. package/assets/.appweaver/STACK-PROFILES.md +461 -0
  5. package/assets/.appweaver/ai-rules.md +180 -0
  6. package/assets/.appweaver/domain.json +1 -0
  7. package/assets/.appweaver/extensions/.registry +19 -0
  8. package/assets/.appweaver/extensions/agent-context/README.md +57 -0
  9. package/assets/.appweaver/extensions/agent-context/agent-context-config.yml +4 -0
  10. package/assets/.appweaver/extensions/agent-context/commands/aw-spec.agent-context.update.md +26 -0
  11. package/assets/.appweaver/extensions/agent-context/extension.yml +37 -0
  12. package/assets/.appweaver/extensions/agent-context/scripts/bash/update-agent-context.sh +201 -0
  13. package/assets/.appweaver/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +237 -0
  14. package/assets/.appweaver/extensions.yml +16 -0
  15. package/assets/.appweaver/feature.json +3 -0
  16. package/assets/.appweaver/init-options.json +9 -0
  17. package/assets/.appweaver/integration.json +15 -0
  18. package/assets/.appweaver/integrations/aw-spec.manifest.json +9 -0
  19. package/assets/.appweaver/interpreter/run-skill.py +1176 -0
  20. package/assets/.appweaver/memory/constitution.md +50 -0
  21. package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +6 -0
  22. package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +94 -0
  23. package/assets/.appweaver/orchestrator/README.md +300 -0
  24. package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +74 -0
  25. package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +74 -0
  26. package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +105 -0
  27. package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +101 -0
  28. package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +90 -0
  29. package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +126 -0
  30. package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +123 -0
  31. package/assets/.appweaver/orchestrator/orchestrator.py +1721 -0
  32. package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +40 -0
  33. package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +101 -0
  34. package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +245 -0
  35. package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +63 -0
  36. package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
  37. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +59 -0
  38. package/assets/.appweaver/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
  39. package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +63 -0
  40. package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +116 -0
  41. package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
  42. package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
  43. package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +125 -0
  44. package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +123 -0
  45. package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +294 -0
  46. package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +160 -0
  47. package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +270 -0
  48. package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +934 -0
  49. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +251 -0
  50. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +265 -0
  51. package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +437 -0
  52. package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +197 -0
  53. package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +475 -0
  54. package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
  55. package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +727 -0
  56. package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +100 -0
  57. package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
  58. package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
  59. package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +386 -0
  60. package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
  61. package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +462 -0
  62. package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +265 -0
  63. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +253 -0
  64. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +267 -0
  65. package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +287 -0
  66. package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +238 -0
  67. package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1822 -0
  68. package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
  69. package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +194 -0
  70. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +83 -0
  71. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
  72. package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +296 -0
  73. package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +439 -0
  74. package/assets/.appweaver/scripts/bash/aw-inventory.sh +17 -0
  75. package/assets/.appweaver/scripts/bash/aw-parity.sh +13 -0
  76. package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +13 -0
  77. package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2626 -0
  78. package/assets/.appweaver/scripts/bash/aw-xref.sh +15 -0
  79. package/assets/.appweaver/scripts/bash/common.sh +533 -0
  80. package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +17 -0
  81. package/assets/.appweaver/scripts/powershell/aw-parity.ps1 +13 -0
  82. package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +13 -0
  83. package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2232 -0
  84. package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +15 -0
  85. package/assets/.appweaver/scripts/python/aw-component-catalog.py +121 -0
  86. package/assets/.appweaver/scripts/python/aw-inventory.py +1180 -0
  87. package/assets/.appweaver/scripts/python/aw-parity.py +380 -0
  88. package/assets/.appweaver/scripts/python/aw-rev-constitution.py +329 -0
  89. package/assets/.appweaver/scripts/python/aw-stack.py +1251 -0
  90. package/assets/.appweaver/scripts/python/aw-xref.py +1330 -0
  91. package/assets/.appweaver/scripts/python/validate_okf.py +352 -0
  92. package/assets/.appweaver/stack-profiles/angular.json +16 -0
  93. package/assets/.appweaver/stack-profiles/blazor-wasm.json +17 -0
  94. package/assets/.appweaver/stack-profiles/dotnet-webapi.json +15 -0
  95. package/assets/.appweaver/stack-profiles/go-chi.json +14 -0
  96. package/assets/.appweaver/stack-profiles/java-spring.json +16 -0
  97. package/assets/.appweaver/stack-profiles/nextjs.json +16 -0
  98. package/assets/.appweaver/stack-profiles/node-express.json +15 -0
  99. package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
  100. package/assets/.appweaver/stack-profiles/react-vite.json +17 -0
  101. package/assets/.appweaver/stack-profiles/sveltekit.json +16 -0
  102. package/assets/.appweaver/stack-profiles/vue-vite.json +17 -0
  103. package/assets/.appweaver/stacks.json +3 -0
  104. package/assets/.appweaver/templates/constitution-domain-template.md +116 -0
  105. package/assets/.appweaver/templates/constitution-technical-template.md +108 -0
  106. package/assets/.appweaver/templates/constitution-ui-template.md +116 -0
  107. package/assets/.appweaver/templates/delta-design-template.md +69 -0
  108. package/assets/.appweaver/templates/delta-spec-template.md +63 -0
  109. package/assets/.appweaver/templates/design-template.md +383 -0
  110. package/assets/.appweaver/templates/okf-concept-template.md +114 -0
  111. package/assets/.appweaver/templates/parity-manifest-template.json +49 -0
  112. package/assets/.appweaver/templates/source-map-template.md +85 -0
  113. package/assets/.appweaver/templates/spec-template.md +309 -0
  114. package/assets/.appweaver/templates/ui-design-template.md +273 -0
  115. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.pack.txt +25248 -0
  116. package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +5726 -0
  117. package/assets/foundations/dotnet/ModularMonolith/pack-foundation.ps1 +145 -0
  118. package/assets/foundations/dotnet/ModularMonolith/pack-foundation.sh +196 -0
  119. package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.ps1 +157 -0
  120. package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.sh +209 -0
  121. package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.ps1 +126 -0
  122. package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.sh +217 -0
  123. package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.ps1 +129 -0
  124. package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.sh +218 -0
  125. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.pack.txt +19318 -0
  126. package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2833 -0
  127. package/assets/foundations/dotnet/VerticalSlice/pack-foundation.ps1 +145 -0
  128. package/assets/foundations/dotnet/VerticalSlice/pack-foundation.sh +196 -0
  129. package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.ps1 +157 -0
  130. package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.sh +209 -0
  131. package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.ps1 +126 -0
  132. package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.sh +217 -0
  133. package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.ps1 +129 -0
  134. package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.sh +218 -0
  135. package/assets/foundations/dotnet/components/Email/README.md +119 -0
  136. package/assets/foundations/dotnet/components/Email/ReferenceEndpoints/SendEmailEndpoint.cs +84 -0
  137. package/assets/foundations/dotnet/components/Email/integration.md +300 -0
  138. package/assets/foundations/dotnet/components/Jobs/README.md +113 -0
  139. package/assets/foundations/dotnet/components/Jobs/integration.md +259 -0
  140. package/assets/foundations/dotnet/components/QrGenerator/README.md +85 -0
  141. package/assets/foundations/dotnet/components/QrGenerator/ReferenceEndpoints/GenerateQrEndpoint.cs +57 -0
  142. package/assets/foundations/dotnet/components/QrGenerator/integration.md +230 -0
  143. package/assets/foundations/dotnet/components/README.md +74 -0
  144. package/assets/guardrails/README.md +222 -0
  145. package/assets/guardrails/domain/healthcare/ai-rules.domain.md +34 -0
  146. package/assets/guardrails/domain/healthcare/api-versioning.domain.md +17 -0
  147. package/assets/guardrails/domain/healthcare/auditing.domain.md +97 -0
  148. package/assets/guardrails/domain/healthcare/authorization.domain.md +26 -0
  149. package/assets/guardrails/domain/healthcare/clinical-terms.md +20 -0
  150. package/assets/guardrails/domain/healthcare/coding-standards.domain.md +26 -0
  151. package/assets/guardrails/domain/healthcare/consent.md +19 -0
  152. package/assets/guardrails/domain/healthcare/cqrs.domain.md +12 -0
  153. package/assets/guardrails/domain/healthcare/data-classification.domain.md +28 -0
  154. package/assets/guardrails/domain/healthcare/dependency-policy.domain.md +11 -0
  155. package/assets/guardrails/domain/healthcare/dicom.md +15 -0
  156. package/assets/guardrails/domain/healthcare/domain-constitution.md +119 -0
  157. package/assets/guardrails/domain/healthcare/domain-driven-design.domain.md +28 -0
  158. package/assets/guardrails/domain/healthcare/eventing.domain.md +18 -0
  159. package/assets/guardrails/domain/healthcare/fhir.md +36 -0
  160. package/assets/guardrails/domain/healthcare/folder-structure.domain.md +25 -0
  161. package/assets/guardrails/domain/healthcare/hipaa.md +34 -0
  162. package/assets/guardrails/domain/healthcare/hl7.md +15 -0
  163. package/assets/guardrails/domain/healthcare/ihe-conformance.md +31 -0
  164. package/assets/guardrails/domain/healthcare/integration-terms.md +21 -0
  165. package/assets/guardrails/domain/healthcare/persistence.domain.md +20 -0
  166. package/assets/guardrails/domain/healthcare/terminology.md +30 -0
  167. package/assets/guardrails/domain/healthcare/testing.domain.md +15 -0
  168. package/assets/guardrails/domain/healthcare/ux/accessibility-standards.md +36 -0
  169. package/assets/guardrails/domain/healthcare/ux/content-design.md +32 -0
  170. package/assets/guardrails/domain/healthcare/ux/interaction-states.md +44 -0
  171. package/assets/guardrails/domain/healthcare/ux/journey-mapping.md +40 -0
  172. package/assets/guardrails/domain/healthcare/vertical-slices.domain.md +18 -0
  173. package/assets/guardrails/domain/healthcare/zero-trust.domain.md +12 -0
  174. package/assets/guardrails/domain/pharma/ai-rules.domain.md +32 -0
  175. package/assets/guardrails/domain/pharma/api-versioning.domain.md +20 -0
  176. package/assets/guardrails/domain/pharma/atc.md +22 -0
  177. package/assets/guardrails/domain/pharma/auditing.domain.md +90 -0
  178. package/assets/guardrails/domain/pharma/authorization.domain.md +25 -0
  179. package/assets/guardrails/domain/pharma/coding-standards.domain.md +28 -0
  180. package/assets/guardrails/domain/pharma/cqrs.domain.md +16 -0
  181. package/assets/guardrails/domain/pharma/data-classification.domain.md +24 -0
  182. package/assets/guardrails/domain/pharma/data-provenance.md +21 -0
  183. package/assets/guardrails/domain/pharma/dependency-policy.domain.md +11 -0
  184. package/assets/guardrails/domain/pharma/domain-constitution.md +113 -0
  185. package/assets/guardrails/domain/pharma/domain-driven-design.domain.md +36 -0
  186. package/assets/guardrails/domain/pharma/domain-requirements.sample.md +103 -0
  187. package/assets/guardrails/domain/pharma/eventing.domain.md +18 -0
  188. package/assets/guardrails/domain/pharma/fhir.md +31 -0
  189. package/assets/guardrails/domain/pharma/folder-structure.domain.md +26 -0
  190. package/assets/guardrails/domain/pharma/hl7.md +17 -0
  191. package/assets/guardrails/domain/pharma/idmp.md +26 -0
  192. package/assets/guardrails/domain/pharma/integration-terms.md +21 -0
  193. package/assets/guardrails/domain/pharma/ndc.md +28 -0
  194. package/assets/guardrails/domain/pharma/persistence.domain.md +23 -0
  195. package/assets/guardrails/domain/pharma/pharma-terms.md +27 -0
  196. package/assets/guardrails/domain/pharma/product-lifecycle.domain.md +30 -0
  197. package/assets/guardrails/domain/pharma/regulatory-compliance.md +19 -0
  198. package/assets/guardrails/domain/pharma/terminology.md +32 -0
  199. package/assets/guardrails/domain/pharma/testing.domain.md +19 -0
  200. package/assets/guardrails/domain/pharma/vertical-slices.domain.md +17 -0
  201. package/assets/guardrails/domain/pharma/zero-trust.domain.md +12 -0
  202. package/assets/guardrails/projects/dotnet-default-project/domain/domain-requirements.md +58 -0
  203. package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +113 -0
  204. package/assets/guardrails/projects/dotnet-default-project/frontend/ui-requirements.md +43 -0
  205. package/assets/guardrails/projects/dotnet-default-project/service/technical-requirements.md +40 -0
  206. package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +263 -0
  207. package/assets/guardrails/requirement-spec.txt +27 -0
  208. package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +184 -0
  209. package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +44 -0
  210. package/assets/guardrails/technology/dotnet-webapi/auditing.md +96 -0
  211. package/assets/guardrails/technology/dotnet-webapi/authorization-matrix.md +14 -0
  212. package/assets/guardrails/technology/dotnet-webapi/authorization.md +39 -0
  213. package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +54 -0
  214. package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +87 -0
  215. package/assets/guardrails/technology/dotnet-webapi/cqrs.md +63 -0
  216. package/assets/guardrails/technology/dotnet-webapi/data-classification.md +38 -0
  217. package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +40 -0
  218. package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +91 -0
  219. package/assets/guardrails/technology/dotnet-webapi/eventing.md +57 -0
  220. package/assets/guardrails/technology/dotnet-webapi/examples/api-versioning.md +75 -0
  221. package/assets/guardrails/technology/dotnet-webapi/examples/auditing.md +84 -0
  222. package/assets/guardrails/technology/dotnet-webapi/examples/authorization.md +68 -0
  223. package/assets/guardrails/technology/dotnet-webapi/examples/clean-architecture.md +37 -0
  224. package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +97 -0
  225. package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +141 -0
  226. package/assets/guardrails/technology/dotnet-webapi/examples/eventing.md +46 -0
  227. package/assets/guardrails/technology/dotnet-webapi/examples/persistence.md +207 -0
  228. package/assets/guardrails/technology/dotnet-webapi/examples/vertical-slices.md +151 -0
  229. package/assets/guardrails/technology/dotnet-webapi/examples/zero-trust.md +48 -0
  230. package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +61 -0
  231. package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +47 -0
  232. package/assets/guardrails/technology/dotnet-webapi/persistence.md +82 -0
  233. package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +162 -0
  234. package/assets/guardrails/technology/dotnet-webapi/testing.md +55 -0
  235. package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +70 -0
  236. package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +48 -0
  237. package/assets/guardrails/technology/java-spring/README.md +62 -0
  238. package/assets/guardrails/technology/nextjs/accessibility-implementation.md +56 -0
  239. package/assets/guardrails/technology/nextjs/api-client.md +56 -0
  240. package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +174 -0
  241. package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +203 -0
  242. package/assets/guardrails/technology/nextjs/auth-session.md +68 -0
  243. package/assets/guardrails/technology/nextjs/client-security.md +69 -0
  244. package/assets/guardrails/technology/nextjs/component-architecture.md +60 -0
  245. package/assets/guardrails/technology/nextjs/component-conventions.md +58 -0
  246. package/assets/guardrails/technology/nextjs/design-system.md +60 -0
  247. package/assets/guardrails/technology/nextjs/error-handling.md +72 -0
  248. package/assets/guardrails/technology/nextjs/forms-and-validation.md +75 -0
  249. package/assets/guardrails/technology/nextjs/frontend-testing.md +94 -0
  250. package/assets/guardrails/technology/nextjs/i18n.md +57 -0
  251. package/assets/guardrails/technology/nextjs/observability.md +67 -0
  252. package/assets/guardrails/technology/nextjs/performance-budgets.md +66 -0
  253. package/assets/guardrails/technology/nextjs/routing-navigation.md +52 -0
  254. package/assets/guardrails/technology/nextjs/state-management.md +70 -0
  255. package/assets/guardrails/technology/nextjs/ui-constitution.md +166 -0
  256. package/package.json +1 -1
  257. 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())