@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,720 @@
1
+ #!/usr/bin/env node
2
+ // Composition operation registry and desired-state reducer (E1 task 4.1).
3
+ //
4
+ // This module is the single authority for the six public composition operations
5
+ // and for the pure manifest reduction they describe. `plan`/`apply` and the
6
+ // legacy `view add|remove` / `feature add|remove` aliases all reduce through
7
+ // `reduceManifest`, so an alias and its plan/apply equivalent mutate the same
8
+ // manifest and emit the same diagnostics.
9
+ //
10
+ // It is pure over its arguments: it reads the registry through
11
+ // `recipes/validate.mjs` and never touches the filesystem. Writing derived files
12
+ // and the manifest is owned by `plan-apply.mjs`.
13
+ //
14
+ // Envelope: `{ "formatVersion": 1, "operations": [...] }`. A missing or
15
+ // non-1 version is `E_INVALID_ENVELOPE`; an operation name outside the six is
16
+ // `E_UNKNOWN_OPERATION`. The registry is namespaced and extensible — later
17
+ // changes add namespaced operations without redefining these names (Decision 16).
18
+ //
19
+ // The six closed operation field schemas are declared by `OPERATION_SCHEMAS`
20
+ // (required + optional fields per operation); this module is their runtime
21
+ // authority. The same contract is published as `recipes/operations.schema.json`
22
+ // (E1 task 4.10). The canonical field order is read from the active registry
23
+ // (`scripts/operation-registry.mjs`), so there is one source of the operation
24
+ // vocabulary.
25
+ //
26
+ // Whole-plan diagnostics (E1 task 4.3): `parseOperationsEnvelope` collects every
27
+ // malformed/unknown operation instead of stopping at the first, and
28
+ // `reduceManifest` validates every operation against the resulting complete
29
+ // state and reports all diagnostics. A caller that sees any diagnostic MUST NOT
30
+ // emit a plan or write anything, so a single invalid operation never leaves a
31
+ // partially valid plan or a mutated project.
32
+
33
+ import {
34
+ allowedFeatureNames,
35
+ allowedViewNames,
36
+ featureConfigRejectionFor,
37
+ getCatalogVariant,
38
+ getFeature,
39
+ isAllowedVariant,
40
+ isSupportedVariant,
41
+ normalizeFeatureConfig,
42
+ resolveIntent,
43
+ resolveMountClaims,
44
+ } from '../recipes/validate.mjs'
45
+ import { MANIFEST_FILE, normalizeFeature, normalizeView } from './manifest.mjs'
46
+ import {
47
+ createOperationRegistry,
48
+ extendOperationRegistry,
49
+ operationFieldOrder,
50
+ operationNames,
51
+ operationReducer,
52
+ operationSchema,
53
+ } from './operation-registry.mjs'
54
+ import { CATALOG_LOGICAL_CONTROLS } from './logical-controls.mjs'
55
+ import { preflightManifest } from './preflight.mjs'
56
+
57
+ export const OPERATIONS_FORMAT_VERSION = 1
58
+
59
+ // E1 registers exactly these six namespaced operations (E1 design Decision 16).
60
+ // Each `reducer` is the behavior; the reducer functions are hoisted declarations
61
+ // below, so the registry is built once at module load. `OPERATION_NAMES` and
62
+ // `OPERATION_SCHEMAS` are derived from this registry, so the operation
63
+ // vocabulary has exactly one source.
64
+ //
65
+ // EXTENSIBILITY (E1 task 4.15): the registry is a seam, not a freeze. A later
66
+ // change (E2's logical controls, E3's `section.*`) builds a new registry with
67
+ // `extendOperationRegistry` and threads it through `options.registry`; the E1
68
+ // registry and its six names are never mutated, and production rejects any
69
+ // operation outside the six with `E_UNKNOWN_OPERATION`. See
70
+ // `scripts/operation-registry.mjs`.
71
+ export const E1_OPERATION_REGISTRY = createOperationRegistry({
72
+ operations: [
73
+ {
74
+ name: 'view.add',
75
+ required: ['view'],
76
+ optional: ['intents', 'variants'],
77
+ reducer: reduceViewAdd,
78
+ },
79
+ { name: 'view.remove', required: ['view'], optional: [], reducer: reduceViewRemove },
80
+ {
81
+ name: 'view.configure',
82
+ required: ['view', 'slot'],
83
+ optional: ['intent', 'variant', 'reset'],
84
+ reducer: reduceViewConfigure,
85
+ },
86
+ { name: 'feature.add', required: ['feature'], optional: ['config'], reducer: reduceFeatureAdd },
87
+ { name: 'feature.remove', required: ['feature'], optional: [], reducer: reduceFeatureRemove },
88
+ {
89
+ name: 'feature.configure',
90
+ required: ['feature', 'config'],
91
+ optional: [],
92
+ reducer: reduceFeatureConfigure,
93
+ },
94
+ ],
95
+ })
96
+
97
+ // The closed E1 operation names, derived from the registry. Any other `op` is
98
+ // rejected with `E_UNKNOWN_OPERATION`; later changes extend the registry without
99
+ // changing these names.
100
+ export const OPERATION_NAMES = Object.freeze(operationNames(E1_OPERATION_REGISTRY))
101
+
102
+ // E2 task 6.1: the production registry E2's generation consumes — E1's six
103
+ // operations plus the two catalog logical controls. It is a NEW frozen registry;
104
+ // `E1_OPERATION_REGISTRY` is never widened, so E1's public operation vocabulary
105
+ // and its empty logical-controls set are unchanged.
106
+ export const CATALOG_OPERATION_REGISTRY = extendOperationRegistry(E1_OPERATION_REGISTRY, {
107
+ logicalControls: CATALOG_LOGICAL_CONTROLS,
108
+ })
109
+
110
+ export const DIAGNOSTIC_CODES = Object.freeze({
111
+ E_INVALID_ENVELOPE: 'E_INVALID_ENVELOPE',
112
+ E_INVALID_OPERATION: 'E_INVALID_OPERATION',
113
+ E_UNKNOWN_OPERATION: 'E_UNKNOWN_OPERATION',
114
+ E_UNKNOWN_VIEW: 'E_UNKNOWN_VIEW',
115
+ E_UNKNOWN_FEATURE: 'E_UNKNOWN_FEATURE',
116
+ E_VIEW_NOT_INSTALLED: 'E_VIEW_NOT_INSTALLED',
117
+ E_FEATURE_NOT_INSTALLED: 'E_FEATURE_NOT_INSTALLED',
118
+ E_VIEW_ALREADY_INSTALLED: 'E_VIEW_ALREADY_INSTALLED',
119
+ E_FEATURE_ALREADY_INSTALLED: 'E_FEATURE_ALREADY_INSTALLED',
120
+ E_HELD_VARIANT: 'E_HELD_VARIANT',
121
+ E_INVALID_COMPOSITION: 'E_INVALID_COMPOSITION',
122
+ E_CONFIG_INVALID: 'E_CONFIG_INVALID',
123
+ E_UNPROVEN_CHECKOUT_OPTION: 'E_UNPROVEN_CHECKOUT_OPTION',
124
+ E_SLOT_OWNED_BY_INSTANCE: 'E_SLOT_OWNED_BY_INSTANCE',
125
+ E_OWNERSHIP_CONFLICT: 'E_OWNERSHIP_CONFLICT',
126
+ })
127
+
128
+ // The six closed operation field schemas: the required and optional fields of
129
+ // each operation (E1 task 4.10). Derived from the E1 registry so the vocabulary
130
+ // has exactly one source. `required` is enforced by the reducers (which reject a
131
+ // missing field with `E_INVALID_OPERATION`); `optional` is the closed allow-list.
132
+ // This is the single authority the published `recipes/operations.schema.json`
133
+ // mirrors.
134
+ export const OPERATION_SCHEMAS = Object.freeze(
135
+ Object.fromEntries(
136
+ OPERATION_NAMES.map((op) => {
137
+ const schema = operationSchema(E1_OPERATION_REGISTRY, op)
138
+ return [
139
+ op,
140
+ Object.freeze({
141
+ required: Object.freeze([...schema.required]),
142
+ optional: Object.freeze([...schema.optional]),
143
+ }),
144
+ ]
145
+ }),
146
+ ),
147
+ )
148
+
149
+ // The closed set of envelope-level fields. `$schema` is the optional JSON-schema
150
+ // self-description (matching `recipes/operations.schema.json`); anything else is
151
+ // an unknown top-level field and is rejected with `E_INVALID_ENVELOPE`.
152
+ const ENVELOPE_FIELDS = Object.freeze(['$schema', 'formatVersion', 'operations'])
153
+
154
+ // The allowed fields of one operation: `op` plus its declared required and
155
+ // optional fields, read from the active registry. Used at the untrusted boundary
156
+ // to reject an unknown field or a field that belongs to a different operation
157
+ // (E1 task 4.10).
158
+ function operationFieldSet(registry, op) {
159
+ return new Set(['op', ...operationFieldOrder(registry, op)])
160
+ }
161
+
162
+ function isPlainObject(value) {
163
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
164
+ }
165
+
166
+ function hasOwn(object, key) {
167
+ return Object.prototype.hasOwnProperty.call(object, key)
168
+ }
169
+
170
+ export function diagnostic(code, message, extra = {}) {
171
+ return { code, severity: 'error', message, ...extra }
172
+ }
173
+
174
+ function preflightDiagnostics(errors) {
175
+ return errors.map((message) => diagnostic(DIAGNOSTIC_CODES.E_INVALID_COMPOSITION, message))
176
+ }
177
+
178
+ // Recursively sort object keys so a serialized operation is byte-stable for the
179
+ // same logical input, regardless of the caller's key insertion order.
180
+ function sortValue(value) {
181
+ if (Array.isArray(value)) return value.map(sortValue)
182
+ if (isPlainObject(value)) {
183
+ const out = {}
184
+ for (const key of Object.keys(value).sort()) out[key] = sortValue(value[key])
185
+ return out
186
+ }
187
+ return value
188
+ }
189
+
190
+ /**
191
+ * Canonicalize one operation: fixed field order, sorted nested maps. This is a
192
+ * TRUSTED helper — it assumes the operation already passed the boundary check in
193
+ * `parseOperationsEnvelope`, so an unknown field is stripped rather than
194
+ * reported. Never call it on untrusted input directly (E1 task 4.10).
195
+ *
196
+ * `options.registry` selects the active registry (defaults to E1's); a later
197
+ * change canonicalizes its namespaced operations against its own registry
198
+ * without touching E1's names.
199
+ */
200
+ export function canonicalOperation(op, options = {}) {
201
+ const registry = options.registry ?? E1_OPERATION_REGISTRY
202
+ const out = { op: op.op }
203
+ for (const field of operationFieldOrder(registry, op.op)) {
204
+ if (hasOwn(op, field)) out[field] = sortValue(op[field])
205
+ }
206
+ return out
207
+ }
208
+
209
+ export function canonicalOperations(operations, options = {}) {
210
+ return operations.map((op) => canonicalOperation(op, options))
211
+ }
212
+
213
+ /**
214
+ * Validate and canonicalize an operations envelope. Returns
215
+ * `{ ok, operations, diagnostics }`. On an envelope-level failure (not an
216
+ * object, wrong `formatVersion`, an unknown top-level field, or a non-array
217
+ * `operations`) `operations` is empty because the envelope cannot be interpreted
218
+ * at all. When individual operations are malformed, unknown, or carry a field
219
+ * outside their closed per-operation schema, they are reported and skipped while
220
+ * the remaining valid operations are still returned, so a caller can combine
221
+ * parse-level and semantic diagnostics in one pass (E1 tasks 4.3, 4.10). `ok` is
222
+ * true only when there are no diagnostics.
223
+ *
224
+ * This is the untrusted boundary: the active registry's per-operation field
225
+ * schemas are closed here, so an unknown field, a field belonging to a different
226
+ * operation, or a public `force: true` is rejected with `E_INVALID_OPERATION`
227
+ * before any canonicalization or write.
228
+ *
229
+ * `options.registry` selects the active registry (defaults to E1's). E1's six
230
+ * names and diagnostics are unchanged; a later change passes its own registry to
231
+ * accept its namespaced operations and reject the rest with
232
+ * `E_UNKNOWN_OPERATION`.
233
+ */
234
+ export function parseOperationsEnvelope(raw, options = {}) {
235
+ const registry = options.registry ?? E1_OPERATION_REGISTRY
236
+ if (!isPlainObject(raw)) {
237
+ return {
238
+ ok: false,
239
+ operations: [],
240
+ diagnostics: [
241
+ diagnostic(DIAGNOSTIC_CODES.E_INVALID_ENVELOPE, 'operations envelope must be a JSON object.'),
242
+ ],
243
+ }
244
+ }
245
+ const envelopeDiagnostics = []
246
+ if (raw.formatVersion !== OPERATIONS_FORMAT_VERSION) {
247
+ envelopeDiagnostics.push(
248
+ diagnostic(
249
+ DIAGNOSTIC_CODES.E_INVALID_ENVELOPE,
250
+ `operations envelope formatVersion must be ${OPERATIONS_FORMAT_VERSION} (got ${JSON.stringify(raw.formatVersion)}).`,
251
+ ),
252
+ )
253
+ }
254
+ for (const key of Object.keys(raw)) {
255
+ if (!ENVELOPE_FIELDS.includes(key)) {
256
+ envelopeDiagnostics.push(
257
+ diagnostic(
258
+ DIAGNOSTIC_CODES.E_INVALID_ENVELOPE,
259
+ `operations envelope has unknown top-level field "${key}". Allowed fields: ${ENVELOPE_FIELDS.join(', ')}.`,
260
+ { path: key, detail: { field: key, allowed: [...ENVELOPE_FIELDS] } },
261
+ ),
262
+ )
263
+ }
264
+ }
265
+ if (!Array.isArray(raw.operations)) {
266
+ envelopeDiagnostics.push(
267
+ diagnostic(
268
+ DIAGNOSTIC_CODES.E_INVALID_ENVELOPE,
269
+ 'operations envelope must declare an "operations" array.',
270
+ ),
271
+ )
272
+ }
273
+ if (envelopeDiagnostics.length > 0) {
274
+ return { ok: false, operations: [], diagnostics: envelopeDiagnostics }
275
+ }
276
+ const operations = []
277
+ const diagnostics = []
278
+ for (let index = 0; index < raw.operations.length; index++) {
279
+ const op = raw.operations[index]
280
+ if (!isPlainObject(op) || typeof op.op !== 'string' || op.op.length === 0) {
281
+ diagnostics.push(
282
+ diagnostic(
283
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
284
+ `operation[${index}] must be an object with a non-empty string "op".`,
285
+ { path: `operations[${index}]` },
286
+ ),
287
+ )
288
+ continue
289
+ }
290
+ if (!operationNames(registry).includes(op.op)) {
291
+ diagnostics.push(
292
+ diagnostic(
293
+ DIAGNOSTIC_CODES.E_UNKNOWN_OPERATION,
294
+ `Unknown operation "${op.op}". E1 implements: ${OPERATION_NAMES.join(', ')}.`,
295
+ { path: `operations[${index}]`, detail: { op: op.op } },
296
+ ),
297
+ )
298
+ continue
299
+ }
300
+ // Closed per-operation field schema (E1 task 4.10): an unknown field or a
301
+ // field belonging to another operation is rejected before canonicalization.
302
+ const allowed = operationFieldSet(registry, op.op)
303
+ const unknown = Object.keys(op).filter((key) => !allowed.has(key))
304
+ if (unknown.length > 0) {
305
+ for (const key of unknown) {
306
+ diagnostics.push(
307
+ diagnostic(
308
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
309
+ `operation[${index}] (${op.op}) has unknown field "${key}". Allowed fields: ${[...allowed].join(', ')}.`,
310
+ {
311
+ path: `operations[${index}].${key}`,
312
+ detail: { op: op.op, field: key, allowed: [...allowed] },
313
+ },
314
+ ),
315
+ )
316
+ }
317
+ continue
318
+ }
319
+ operations.push(canonicalOperation(op, { registry }))
320
+ }
321
+ return { ok: diagnostics.length === 0, operations, diagnostics }
322
+ }
323
+
324
+ function requireString(op, field) {
325
+ return typeof op[field] === 'string' && op[field].length > 0
326
+ }
327
+
328
+ function fail(code, message) {
329
+ return { ok: false, diagnostics: [diagnostic(code, message)] }
330
+ }
331
+
332
+ // Shared host-slot ownership check (E1 task 3.8 / CU-05, CU-06). While an
333
+ // installed feature instance claims a host slot, the instance's `config` is the
334
+ // single presentation truth for that slot: a direct selection or reset of the
335
+ // slot is rejected with `E_SLOT_OWNED_BY_INSTANCE` and nothing is written. The
336
+ // view's stored selection stays dormant (never cleared) so removing the instance
337
+ // restores it. Claims are resolved through the single registry authority
338
+ // (`resolveMountClaims`), so the alias commands, plan/apply, and generation all
339
+ // read one resolved claim set; `options.getFeatureSpec` is the hermetic
340
+ // injection seam a test uses without shipping a generic conflicting feature.
341
+ function claimedSlots(manifest, options = {}) {
342
+ const features = Array.isArray(manifest?.features) ? manifest.features : []
343
+ return resolveMountClaims(features, options).claims
344
+ }
345
+
346
+ function slotOwnedByInstanceDiagnostic(host, slot, claim) {
347
+ return diagnostic(
348
+ DIAGNOSTIC_CODES.E_SLOT_OWNED_BY_INSTANCE,
349
+ `Slot "${host}.${slot}" is claimed by the installed feature instance "${claim.featureName}" (mount "${claim.id}"); the feature config is the only source of that slot's presentation. Configure the feature instead, or remove it to restore the view's stored selection.`,
350
+ {
351
+ path: MANIFEST_FILE,
352
+ detail: { host, slot, feature: claim.featureName, mountId: claim.id },
353
+ },
354
+ )
355
+ }
356
+
357
+ // Feature config failures keep their declared stable code (E1 task 3.5 /
358
+ // Decision 12): a feature that declares a `configRejection` (checkout) reports
359
+ // its own code for any non-empty config, and a recognized feature whose config
360
+ // violates its contract reports `E_CONFIG_INVALID`. A structural malformation
361
+ // (a `config` that is not an object) is rejected earlier as
362
+ // `E_INVALID_OPERATION`. The human message is `normalizeFeatureConfig`'s
363
+ // unchanged text, so the existing CLI wording is preserved; the structured code
364
+ // and metadata ride alongside it.
365
+ function featureConfigDiagnostic(name, normalized, requested) {
366
+ const rejection = featureConfigRejectionFor(name, requested)
367
+ const rejectedKeys =
368
+ isPlainObject(requested) && Object.keys(requested).length > 0 ? Object.keys(requested) : []
369
+ if (rejection) {
370
+ return diagnostic(rejection.code, normalized.errors.join(' '), {
371
+ path: MANIFEST_FILE,
372
+ detail: { feature: name, rejectedKeys },
373
+ })
374
+ }
375
+ return diagnostic(DIAGNOSTIC_CODES.E_CONFIG_INVALID, normalized.errors.join(' '), {
376
+ path: MANIFEST_FILE,
377
+ detail: { feature: name },
378
+ })
379
+ }
380
+
381
+ function reduceViewAdd(manifest, op, ctx) {
382
+ const name = op.view
383
+ if (!requireString(op, 'view')) {
384
+ return fail(DIAGNOSTIC_CODES.E_INVALID_OPERATION, 'view.add requires a non-empty string "view".')
385
+ }
386
+ if (!allowedViewNames().includes(name)) {
387
+ return fail(
388
+ DIAGNOSTIC_CODES.E_UNKNOWN_VIEW,
389
+ `Unknown view "${name}". Allowed views: ${allowedViewNames().join(', ')}.`,
390
+ )
391
+ }
392
+ const present = manifest.views.some((view) => view.name === name)
393
+ // Copy the caller's maps so the dormant-selection merge below never mutates
394
+ // the canonical operation object.
395
+ const intents = { ...(isPlainObject(op.intents) ? op.intents : {}) }
396
+ const variants = { ...(isPlainObject(op.variants) ? op.variants : {}) }
397
+
398
+ // A held variant can never render; reject it before any structural check so
399
+ // the diagnostic names the variant and its catalog reason (CU-07), even when
400
+ // the view is already installed and `--force` was not given.
401
+ for (const [slot, variant] of Object.entries(variants)) {
402
+ const held = getCatalogVariant(name, slot, variant)
403
+ if (held?.status === 'held') {
404
+ return fail(
405
+ DIAGNOSTIC_CODES.E_HELD_VARIANT,
406
+ `Variant "${variant}" is held for ${name}.${slot} and not supported by the curated MVP set: ${held.reason}.`,
407
+ )
408
+ }
409
+ }
410
+ if (present && !ctx.force) {
411
+ return fail(
412
+ DIAGNOSTIC_CODES.E_VIEW_ALREADY_INSTALLED,
413
+ `View "${name}" is already installed. Use --force to re-add it.`,
414
+ )
415
+ }
416
+ // One presentation truth per slot (E1 task 3.8): a `view.add` that explicitly
417
+ // selects an instance-claimed slot is a direct configuration of that slot and
418
+ // is rejected, matching `view.configure`. Claims resolve through the single
419
+ // registry authority.
420
+ const claims = claimedSlots(manifest, ctx)
421
+ for (const slot of [...Object.keys(intents), ...Object.keys(variants)]) {
422
+ const claim = claims.get(`${name}.${slot}`)
423
+ if (claim) {
424
+ return { ok: false, diagnostics: [slotOwnedByInstanceDiagnostic(name, slot, claim)] }
425
+ }
426
+ }
427
+ for (const [slot, intent] of Object.entries(intents)) {
428
+ const resolved = resolveIntent(name, slot, intent)
429
+ if (!resolved.ok) {
430
+ return fail(
431
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
432
+ `Invalid intent for ${name}.${slot}: ${resolved.errors.join(' ')}`,
433
+ )
434
+ }
435
+ }
436
+ for (const [slot, variant] of Object.entries(variants)) {
437
+ if (!isAllowedVariant(name, slot, variant)) {
438
+ return fail(
439
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
440
+ `Variant "${variant}" is not allowed for ${name}.${slot}.`,
441
+ )
442
+ }
443
+ }
444
+ // Retain the dormant stored selection of a claimed slot the operation does not
445
+ // touch, so a `view.add --force` can never silently clear it while an instance
446
+ // owns the slot (E1 task 3.8). Non-claimed slots keep the documented
447
+ // "omitted selection -> recipe defaults" behavior.
448
+ if (present) {
449
+ const existing = manifest.views.find((view) => view.name === name)
450
+ for (const claim of claims.values()) {
451
+ if (claim.host !== name) continue
452
+ if (hasOwn(intents, claim.slot) || hasOwn(variants, claim.slot)) continue
453
+ if (hasOwn(existing.intents ?? {}, claim.slot)) intents[claim.slot] = existing.intents[claim.slot]
454
+ else if (hasOwn(existing.variants ?? {}, claim.slot)) {
455
+ variants[claim.slot] = existing.variants[claim.slot]
456
+ }
457
+ }
458
+ }
459
+ const entry = normalizeView({ name, intents, variants })
460
+ const views = present
461
+ ? manifest.views.map((view) => (view.name === name ? entry : view))
462
+ : [...manifest.views, entry]
463
+ return { ok: true, manifest: { ...manifest, views } }
464
+ }
465
+
466
+ function reduceViewRemove(manifest, op) {
467
+ const name = op.view
468
+ if (!requireString(op, 'view')) {
469
+ return fail(DIAGNOSTIC_CODES.E_INVALID_OPERATION, 'view.remove requires a non-empty string "view".')
470
+ }
471
+ if (!allowedViewNames().includes(name)) {
472
+ return fail(
473
+ DIAGNOSTIC_CODES.E_UNKNOWN_VIEW,
474
+ `Unknown view "${name}". Allowed views: ${allowedViewNames().join(', ')}.`,
475
+ )
476
+ }
477
+ if (!manifest.views.some((view) => view.name === name)) {
478
+ return fail(DIAGNOSTIC_CODES.E_VIEW_NOT_INSTALLED, `View "${name}" is not installed.`)
479
+ }
480
+ return {
481
+ ok: true,
482
+ manifest: { ...manifest, views: manifest.views.filter((view) => view.name !== name) },
483
+ removals: [{ kind: 'view', name }],
484
+ }
485
+ }
486
+
487
+ function reduceViewConfigure(manifest, op, ctx) {
488
+ const name = op.view
489
+ if (!requireString(op, 'view')) {
490
+ return fail(
491
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
492
+ 'view.configure requires a non-empty string "view".',
493
+ )
494
+ }
495
+ if (!requireString(op, 'slot')) {
496
+ return fail(
497
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
498
+ 'view.configure requires a non-empty string "slot".',
499
+ )
500
+ }
501
+ const index = manifest.views.findIndex((view) => view.name === name)
502
+ if (index === -1) {
503
+ return fail(DIAGNOSTIC_CODES.E_VIEW_NOT_INSTALLED, `View "${name}" is not installed.`)
504
+ }
505
+ const selectors = ['intent', 'variant'].filter((field) => hasOwn(op, field)).length
506
+ const reset = op.reset === true
507
+ if (selectors + (reset ? 1 : 0) !== 1) {
508
+ return fail(
509
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
510
+ 'view.configure requires exactly one of "intent", "variant", or "reset": true.',
511
+ )
512
+ }
513
+ // One presentation truth per slot (E1 task 3.8 / CU-05, CU-06): while an
514
+ // installed feature instance claims the slot, its feature config is the only
515
+ // source of the slot's presentation. A direct selection OR reset of that slot
516
+ // is rejected and nothing is written; the view's stored selection stays
517
+ // dormant. Claims resolve through the single registry authority.
518
+ const claim = claimedSlots(manifest, ctx).get(`${name}.${op.slot}`)
519
+ if (claim) {
520
+ return { ok: false, diagnostics: [slotOwnedByInstanceDiagnostic(name, op.slot, claim)] }
521
+ }
522
+ const view = manifest.views[index]
523
+ const intents = { ...view.intents }
524
+ const variants = { ...view.variants }
525
+ if (hasOwn(op, 'intent')) {
526
+ const resolved = resolveIntent(name, op.slot, op.intent)
527
+ if (!resolved.ok) {
528
+ return fail(
529
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
530
+ `Invalid intent for ${name}.${op.slot}: ${resolved.errors.join(' ')}`,
531
+ )
532
+ }
533
+ if (!isSupportedVariant(name, op.slot, resolved.variant)) {
534
+ return fail(
535
+ DIAGNOSTIC_CODES.E_HELD_VARIANT,
536
+ `Intent "${op.intent}" resolves to "${resolved.variant}", which is not executable for ${name}.${op.slot}.`,
537
+ )
538
+ }
539
+ intents[op.slot] = op.intent
540
+ delete variants[op.slot]
541
+ } else if (hasOwn(op, 'variant')) {
542
+ const held = getCatalogVariant(name, op.slot, op.variant)
543
+ if (held?.status === 'held') {
544
+ return fail(
545
+ DIAGNOSTIC_CODES.E_HELD_VARIANT,
546
+ `Variant "${op.variant}" is held for ${name}.${op.slot} and not supported by the curated MVP set: ${held.reason}.`,
547
+ )
548
+ }
549
+ if (!isSupportedVariant(name, op.slot, op.variant)) {
550
+ return fail(
551
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
552
+ `Variant "${op.variant}" is not executable for ${name}.${op.slot}.`,
553
+ )
554
+ }
555
+ variants[op.slot] = op.variant
556
+ delete intents[op.slot]
557
+ } else {
558
+ delete intents[op.slot]
559
+ delete variants[op.slot]
560
+ }
561
+ const views = manifest.views.map((entry, entryIndex) =>
562
+ entryIndex === index ? { ...entry, intents, variants } : entry,
563
+ )
564
+ return { ok: true, manifest: { ...manifest, views } }
565
+ }
566
+
567
+ function reduceFeatureAdd(manifest, op, ctx) {
568
+ const name = op.feature
569
+ if (!requireString(op, 'feature')) {
570
+ return fail(
571
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
572
+ 'feature.add requires a non-empty string "feature".',
573
+ )
574
+ }
575
+ if (!allowedFeatureNames().includes(name)) {
576
+ return fail(
577
+ DIAGNOSTIC_CODES.E_UNKNOWN_FEATURE,
578
+ `Unknown feature "${name}". Allowed features: ${allowedFeatureNames().join(', ')}.`,
579
+ )
580
+ }
581
+ const present = manifest.features.some((feature) => feature.name === name)
582
+ if (present && !ctx.force) {
583
+ return fail(
584
+ DIAGNOSTIC_CODES.E_FEATURE_ALREADY_INSTALLED,
585
+ `Feature "${name}" is already installed. Use --force to re-add it.`,
586
+ )
587
+ }
588
+ // A re-add without an explicit config preserves the installed config; a fresh
589
+ // add seeds the feature's declared defaults (E1 task 3.1).
590
+ const existingConfig = present
591
+ ? (manifest.features.find((feature) => feature.name === name)?.config ?? {})
592
+ : {}
593
+ const requested = hasOwn(op, 'config') ? op.config : existingConfig
594
+ const normalized = normalizeFeatureConfig(name, isPlainObject(requested) ? requested : {})
595
+ if (!normalized.ok) {
596
+ return { ok: false, diagnostics: [featureConfigDiagnostic(name, normalized, requested)] }
597
+ }
598
+ const entry = normalizeFeature({ name, config: normalized.config })
599
+ const features = present
600
+ ? manifest.features.map((feature) => (feature.name === name ? entry : feature))
601
+ : [...manifest.features, entry]
602
+ const owned = getFeature(name)?.views ?? []
603
+ const views = [...manifest.views]
604
+ for (const viewName of owned) {
605
+ if (!views.some((view) => view.name === viewName)) views.push(normalizeView(viewName))
606
+ }
607
+ return { ok: true, manifest: { ...manifest, features, views } }
608
+ }
609
+
610
+ function reduceFeatureRemove(manifest, op) {
611
+ const name = op.feature
612
+ if (!requireString(op, 'feature')) {
613
+ return fail(
614
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
615
+ 'feature.remove requires a non-empty string "feature".',
616
+ )
617
+ }
618
+ if (!allowedFeatureNames().includes(name)) {
619
+ return fail(
620
+ DIAGNOSTIC_CODES.E_UNKNOWN_FEATURE,
621
+ `Unknown feature "${name}". Allowed features: ${allowedFeatureNames().join(', ')}.`,
622
+ )
623
+ }
624
+ if (!manifest.features.some((feature) => feature.name === name)) {
625
+ return fail(DIAGNOSTIC_CODES.E_FEATURE_NOT_INSTALLED, `Feature "${name}" is not installed.`)
626
+ }
627
+ const features = manifest.features.filter((feature) => feature.name !== name)
628
+ const stillOwned = new Set(features.flatMap((feature) => getFeature(feature.name)?.views ?? []))
629
+ const removedViews = (getFeature(name)?.views ?? []).filter((view) => !stillOwned.has(view))
630
+ const views = manifest.views.filter((view) => !removedViews.includes(view.name))
631
+ const removals = [{ kind: 'feature', name }]
632
+ for (const view of removedViews) removals.push({ kind: 'view', name: view })
633
+ return { ok: true, manifest: { ...manifest, features, views }, removals }
634
+ }
635
+
636
+ function reduceFeatureConfigure(manifest, op) {
637
+ const name = op.feature
638
+ if (!requireString(op, 'feature')) {
639
+ return fail(
640
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
641
+ 'feature.configure requires a non-empty string "feature".',
642
+ )
643
+ }
644
+ if (!manifest.features.some((feature) => feature.name === name)) {
645
+ return fail(DIAGNOSTIC_CODES.E_FEATURE_NOT_INSTALLED, `Feature "${name}" is not installed.`)
646
+ }
647
+ if (!isPlainObject(op.config)) {
648
+ return fail(
649
+ DIAGNOSTIC_CODES.E_INVALID_OPERATION,
650
+ 'feature.configure requires a "config" object.',
651
+ )
652
+ }
653
+ // REPLACE semantics: the provided config is validated and its declared
654
+ // defaults seeded; it fully replaces the installed config.
655
+ const normalized = normalizeFeatureConfig(name, op.config)
656
+ if (!normalized.ok) {
657
+ return { ok: false, diagnostics: [featureConfigDiagnostic(name, normalized, op.config)] }
658
+ }
659
+ const features = manifest.features.map((feature) =>
660
+ feature.name === name ? normalizeFeature({ name, config: normalized.config }) : feature,
661
+ )
662
+ return { ok: true, manifest: { ...manifest, features } }
663
+ }
664
+
665
+ /**
666
+ * Reduce a manifest through an ordered operation list into the complete desired
667
+ * state (E1 task 4.3). Validates the input composition, then every operation
668
+ * against the resulting state as it evolves, then the whole resulting
669
+ * composition before returning. Unlike a first-failure reducer, this collects
670
+ * the diagnostics of every invalid operation — an unknown name, an invalid
671
+ * field, a held variant, a claim conflict, or a final-composition error — so a
672
+ * caller can report all errors in one pass.
673
+ *
674
+ * An invalid operation is recorded and skipped; the remaining operations still
675
+ * reduce against the last valid state, so independent errors are all reported
676
+ * and a valid operation sequence (for example a `view.remove` followed by a
677
+ * `view.add` that restores it) is validated in order. A caller MUST NOT emit a
678
+ * plan or write when `ok` is false. Returns
679
+ * `{ ok, manifest, diagnostics, removals }`; on failure `manifest` is the
680
+ * original input and `removals` is empty.
681
+ *
682
+ * `options.force` allows re-adding an already-installed unit (the legacy
683
+ * `--force` flag); `plan`/`apply` reduce with `force: true` because their
684
+ * `*.add` operations are defined as add/refresh. `options.getFeatureSpec` is the
685
+ * hermetic registry injection seam used by tests. `options.registry` selects the
686
+ * active operation registry (defaults to E1's six), so a later change dispatches
687
+ * its own namespaced reducers through the same whole-state engine.
688
+ */
689
+ export function reduceManifest(manifest, operations, options = {}) {
690
+ const registry = options.registry ?? E1_OPERATION_REGISTRY
691
+ const input = preflightManifest(manifest, options)
692
+ if (!input.ok) {
693
+ return { ok: false, manifest, diagnostics: preflightDiagnostics(input.errors), removals: [] }
694
+ }
695
+ let next = structuredClone(manifest)
696
+ const diagnostics = []
697
+ const removals = []
698
+ for (const op of operations) {
699
+ const reducer = operationReducer(registry, op.op)
700
+ if (!reducer) {
701
+ diagnostics.push(
702
+ diagnostic(DIAGNOSTIC_CODES.E_UNKNOWN_OPERATION, `Unknown operation "${op.op}".`),
703
+ )
704
+ continue
705
+ }
706
+ const result = reducer(next, op, options)
707
+ if (!result.ok) {
708
+ diagnostics.push(...result.diagnostics)
709
+ continue
710
+ }
711
+ next = result.manifest
712
+ if (Array.isArray(result.removals)) removals.push(...result.removals)
713
+ }
714
+ const final = preflightManifest(next, options)
715
+ if (!final.ok) diagnostics.push(...preflightDiagnostics(final.errors))
716
+ if (diagnostics.length > 0) {
717
+ return { ok: false, manifest, diagnostics, removals: [] }
718
+ }
719
+ return { ok: true, manifest: next, diagnostics: [], removals }
720
+ }