@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -1,13 +1,41 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { dirname, join } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { loadCatalog } from './catalog.mjs'
6
+ import { validateAgainstSchema } from '../scripts/schema-validate.mjs'
5
7
 
6
8
  const here = dirname(fileURLToPath(import.meta.url))
7
9
 
8
- const recipes = JSON.parse(readFileSync(join(here, 'recipes.json'), 'utf8'))
9
- const sections = JSON.parse(readFileSync(join(here, 'sections.json'), 'utf8'))
10
- const archetypes = JSON.parse(readFileSync(join(here, 'archetypes.json'), 'utf8'))
10
+ // Layout-agnostic registry location: at the package root the JSON registry lives
11
+ // beside this module (`recipes/`); in an installed skill it is distributed under
12
+ // `skills/sf-view-gen/assets/recipes/` and addressed by relative path. Both hold
13
+ // the same synced copies, so the registry has one source.
14
+ const registryDir = existsSync(join(here, 'recipes.json'))
15
+ ? here
16
+ : join(here, '..', 'assets', 'recipes')
17
+
18
+ const recipes = JSON.parse(readFileSync(join(registryDir, 'recipes.json'), 'utf8'))
19
+ const sections = JSON.parse(readFileSync(join(registryDir, 'sections.json'), 'utf8'))
20
+ const archetypes = JSON.parse(readFileSync(join(registryDir, 'archetypes.json'), 'utf8'))
21
+ const features = JSON.parse(readFileSync(join(registryDir, 'features.json'), 'utf8'))
22
+
23
+ // The semantic catalog is the single source of the executable/held
24
+ // classification. `supported` (what the CLI presents as selectable) is derived
25
+ // from `status === 'executable'`; held variants are never selectable. The
26
+ // catalog is loaded once, read-only.
27
+ const catalog = loadCatalog()
28
+
29
+ function isPlainObject(value) {
30
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
31
+ }
32
+
33
+ // Own-property lookup: inherited keys (`constructor`, `__proto__`, `toString`)
34
+ // must never be treated as a declared slot/intent, or an unsupported input
35
+ // would resolve to an inherited member and throw instead of being reported.
36
+ function hasOwn(object, key) {
37
+ return Object.prototype.hasOwnProperty.call(object, key)
38
+ }
11
39
 
12
40
  export function loadRecipes() {
13
41
  return recipes
@@ -17,11 +45,279 @@ export function loadArchetypes() {
17
45
  return archetypes
18
46
  }
19
47
 
48
+ export function loadFeatures() {
49
+ return features
50
+ }
51
+
52
+ export function getFeature(name) {
53
+ return features.features.find((f) => f.name === name) ?? null
54
+ }
55
+
56
+ export function allowedFeatureNames() {
57
+ return features.features.map((f) => f.name)
58
+ }
59
+
60
+ export function isAllowedFeature(name) {
61
+ return allowedFeatureNames().includes(name)
62
+ }
63
+
64
+ /**
65
+ * The per-feature configuration contract declared by the registry, or null when
66
+ * the feature is unknown or declares none. The contract is a JSON Schema in the
67
+ * dependency-free subset (`scripts/schema-validate.mjs`); its `default`
68
+ * annotations seed absent config fields.
69
+ */
70
+ export function featureConfigContract(featureName) {
71
+ const contract = getFeature(featureName)?.config
72
+ return isPlainObject(contract) ? contract : null
73
+ }
74
+
75
+ /**
76
+ * The stable diagnostic a feature declares for configuration it does not accept
77
+ * (E1 task 3.5). `checkout` declares `E_UNPROVEN_CHECKOUT_OPTION`: E1 has proven
78
+ * no checkout variant or route option, so any non-empty key is rejected with the
79
+ * code while an empty `{}` stays the existing no-op. Returns null when the
80
+ * feature declares none, so its config contract alone governs.
81
+ */
82
+ export function featureConfigRejection(featureName) {
83
+ const rejection = getFeature(featureName)?.configRejection
84
+ if (!isPlainObject(rejection) || typeof rejection.code !== 'string') return null
85
+ return rejection
86
+ }
87
+
88
+ // A capability is verified only when the catalog explicitly says so; a missing
89
+ // entry or any other state is unverified. Presence of a method is not proof.
90
+ function isCapabilityVerified(capabilityId) {
91
+ const capabilities = isPlainObject(catalog.capabilities) ? catalog.capabilities : {}
92
+ return hasOwn(capabilities, capabilityId) && capabilities[capabilityId]?.verification === 'verified'
93
+ }
94
+
95
+ /**
96
+ * The rejection that applies to a specific config value (E1 task 3.5 extended by
97
+ * E2 task 4.12). A rejection with no `when` applies to any non-empty config
98
+ * (checkout's existing behavior). A rejection with `when: { field, equals }`
99
+ * applies only when `config[field] === equals`, so a feature may accept most of
100
+ * its config while still refusing one unprovable option. When the rejection
101
+ * declares a `capability`, it applies only while that catalog capability is not
102
+ * `verified`: a configured-but-unproven option fails loudly until the backend
103
+ * proves it, and is never silently coerced to its default. Returns the rejection
104
+ * spec when it applies, else null.
105
+ */
106
+ export function featureConfigRejectionFor(featureName, config) {
107
+ const rejection = featureConfigRejection(featureName)
108
+ if (!rejection || !isPlainObject(config)) return null
109
+ const when = isPlainObject(rejection.when) ? rejection.when : null
110
+ if (when) {
111
+ if (typeof when.field !== 'string' || !hasOwn(config, when.field)) return null
112
+ if (config[when.field] !== when.equals) return null
113
+ } else if (Object.keys(config).length === 0) {
114
+ return null
115
+ }
116
+ if (typeof rejection.capability === 'string' && isCapabilityVerified(rejection.capability)) {
117
+ return null
118
+ }
119
+ return rejection
120
+ }
121
+
122
+ // Stable diagnostics for feature instance mounts (E1 task 3.7 / CU-06). A mount
123
+ // declares `{ id, host, slot }`; `id` is unique across installed features and a
124
+ // host slot is claimed by at most one instance. These are stable strings so the
125
+ // CLI, the plan/apply engine, and tests assert the same contract.
126
+ export const MOUNT_CONFLICT_CODE = 'E_MOUNT_CONFLICT'
127
+ export const DUPLICATE_MOUNT_ID_CODE = 'E_DUPLICATE_MOUNT_ID'
128
+
129
+ /**
130
+ * Resolve the host-slot claims declared by a set of installed feature entries
131
+ * against the registry (E1 task 3.7 / CU-06). The registry is the single
132
+ * authority for which mounts a feature declares; `options.getFeatureSpec`
133
+ * defaults to `getFeature` and is the seam a hermetic test uses to inject a
134
+ * second registry claim without adding a generic registry feature.
135
+ *
136
+ * Invariants enforced here, before any write:
137
+ * - every mount declares non-empty string `id`, `host`, and `slot`;
138
+ * - a mount `id` is unique across installed features (`E_DUPLICATE_MOUNT_ID`);
139
+ * - at most one instance claims a given `(host, slot)` (`E_MOUNT_CONFLICT`).
140
+ *
141
+ * Returns `{ ok, errors, claims }`, where `claims` maps `"<host>.<slot>"` to
142
+ * `{ id, host, slot, featureName }` so generation and later tasks read one
143
+ * resolved claim set instead of re-deriving it.
144
+ */
145
+ export function resolveMountClaims(featureEntries, options = {}) {
146
+ const resolve =
147
+ typeof options.getFeatureSpec === 'function' ? options.getFeatureSpec : getFeature
148
+ const errors = []
149
+ const claims = new Map()
150
+ const ids = new Map()
151
+ const entries = Array.isArray(featureEntries) ? featureEntries : []
152
+ for (const entry of entries) {
153
+ if (!isPlainObject(entry) || typeof entry.name !== 'string') continue
154
+ const spec = resolve(entry.name)
155
+ const mounts = Array.isArray(spec?.mounts) ? spec.mounts : []
156
+ for (const mount of mounts) {
157
+ if (!isPlainObject(mount)) {
158
+ errors.push(
159
+ `Feature "${entry.name}" declares a malformed mount; expected { id, host, slot }.`,
160
+ )
161
+ continue
162
+ }
163
+ const { id, host, slot } = mount
164
+ if (
165
+ typeof id !== 'string' || id.length === 0 ||
166
+ typeof host !== 'string' || host.length === 0 ||
167
+ typeof slot !== 'string' || slot.length === 0
168
+ ) {
169
+ errors.push(
170
+ `Feature "${entry.name}" mount must declare non-empty "id", "host", and "slot".`,
171
+ )
172
+ continue
173
+ }
174
+ if (ids.has(id)) {
175
+ errors.push(
176
+ `${DUPLICATE_MOUNT_ID_CODE}: mount id "${id}" is declared by feature "${ids.get(id)}" and again by feature "${entry.name}"; instance ids must be unique across installed features.`,
177
+ )
178
+ continue
179
+ }
180
+ ids.set(id, entry.name)
181
+ const slotKey = `${host}.${slot}`
182
+ const existing = claims.get(slotKey)
183
+ if (existing) {
184
+ errors.push(
185
+ `${MOUNT_CONFLICT_CODE}: host slot "${slotKey}" is already claimed by mount "${existing.id}" of feature "${existing.featureName}"; mount "${id}" of feature "${entry.name}" cannot claim the same (host, slot).`,
186
+ )
187
+ continue
188
+ }
189
+ claims.set(slotKey, { id, host, slot, featureName: entry.name })
190
+ }
191
+ }
192
+ return { ok: errors.length === 0, errors, claims }
193
+ }
194
+
195
+ /**
196
+ * The legacy `surfaces` alias for a registry feature, derived from its declared
197
+ * `mounts` when it has any (E1 task 3.7). A mount carries no `element`: the
198
+ * element is generation-owned, read here from the feature's `presentations` map
199
+ * for its default presentation, so `mounts` stays the single source and
200
+ * `surfaces` is a read-only alias. A feature that declares no mounts (the held
201
+ * `recommendations` entry) keeps its existing `surfaces`.
202
+ */
203
+ export function featureSurfaces(spec) {
204
+ if (!isPlainObject(spec)) return []
205
+ const mounts = Array.isArray(spec.mounts) ? spec.mounts : []
206
+ if (mounts.length === 0) {
207
+ return Array.isArray(spec.surfaces) ? spec.surfaces : []
208
+ }
209
+ const presentations = isPlainObject(spec.presentations) ? spec.presentations : {}
210
+ const contract = isPlainObject(spec.config) ? spec.config : {}
211
+ const properties = isPlainObject(contract.properties) ? contract.properties : {}
212
+ const field = isPlainObject(properties.presentation) ? properties.presentation : {}
213
+ const element =
214
+ typeof field.default === 'string' && typeof presentations[field.default] === 'string'
215
+ ? presentations[field.default]
216
+ : Object.values(presentations).find((value) => typeof value === 'string')
217
+ return mounts
218
+ .filter(
219
+ (mount) =>
220
+ isPlainObject(mount) &&
221
+ typeof mount.host === 'string' &&
222
+ typeof mount.slot === 'string',
223
+ )
224
+ .map((mount) => ({
225
+ host: mount.host,
226
+ slot: mount.slot,
227
+ ...(typeof element === 'string' ? { element } : {}),
228
+ }))
229
+ }
230
+
231
+ // One stable, actionable line for a rejected non-empty config: the declared
232
+ // code and message plus the rejected key(s), so the CLI and the persisted
233
+ // sync/doctor preflight report the same diagnostic.
234
+ function configRejectionError(rejection, config) {
235
+ // A conditional rejection names only its offending field; a whole-config
236
+ // rejection (checkout) names every supplied key.
237
+ const field =
238
+ isPlainObject(rejection.when) && typeof rejection.when.field === 'string'
239
+ ? rejection.when.field
240
+ : null
241
+ const keys = field
242
+ ? JSON.stringify(field)
243
+ : Object.keys(config)
244
+ .map((key) => JSON.stringify(key))
245
+ .join(', ')
246
+ return `${rejection.code}: ${rejection.message}. Rejected key(s): ${keys}.`
247
+ }
248
+
249
+ /**
250
+ * Normalize and validate a config against a feature config contract. Declared
251
+ * `default` annotations seed absent fields; unknown fields are rejected when the
252
+ * contract sets `additionalProperties: false`. Returns `{ ok, config, errors }`.
253
+ *
254
+ * Pure over its arguments so it is fixture-testable and reusable by the later
255
+ * plan/apply operation engine (E1 task 3.1); the registry-backed wrapper is
256
+ * `normalizeFeatureConfig`.
257
+ */
258
+ export function normalizeConfig(contract, config) {
259
+ const shape = isPlainObject(contract) ? contract : {}
260
+ if (!isPlainObject(config)) {
261
+ return { ok: false, config: {}, errors: ['config must be a JSON object.'] }
262
+ }
263
+ const errors = validateAgainstSchema(config, shape, 'config')
264
+ const properties = isPlainObject(shape.properties) ? shape.properties : {}
265
+ const normalized = { ...config }
266
+ for (const [key, field] of Object.entries(properties)) {
267
+ if (!hasOwn(normalized, key) && isPlainObject(field) && hasOwn(field, 'default')) {
268
+ // Clone array/object defaults so a caller mutating the returned config can
269
+ // never mutate the registry's loaded contract.
270
+ normalized[key] =
271
+ Array.isArray(field.default) || isPlainObject(field.default)
272
+ ? structuredClone(field.default)
273
+ : field.default
274
+ }
275
+ }
276
+ return { ok: errors.length === 0, config: normalized, errors }
277
+ }
278
+
279
+ /**
280
+ * Registry-backed config normalization: resolve the feature's declared contract
281
+ * and validate/default the caller's config against it. An unknown feature is an
282
+ * error, so a config can never be persisted for a feature the registry does not
283
+ * declare. Returns `{ ok, config, errors }`.
284
+ */
285
+ export function normalizeFeatureConfig(featureName, config) {
286
+ const spec = getFeature(featureName)
287
+ if (!spec) {
288
+ return {
289
+ ok: false,
290
+ config: {},
291
+ errors: [
292
+ `Unknown feature "${featureName}". Allowed features: ${allowedFeatureNames().join(', ')}.`,
293
+ ],
294
+ }
295
+ }
296
+ const contract = isPlainObject(spec.config)
297
+ ? spec.config
298
+ : { type: 'object', additionalProperties: false, properties: {} }
299
+ const result = normalizeConfig(contract, config)
300
+ // A feature that declares no proven configuration (E1 task 3.5) rejects any
301
+ // non-empty config with its stable code instead of a generic schema error; the
302
+ // empty object remains the existing no-op. E2 task 4.12 extends this to a
303
+ // conditional, capability-gated rejection: only the declared offending field
304
+ // value is refused while its capability is unverified, and the rest of the
305
+ // feature's config stays valid.
306
+ const rejection = featureConfigRejectionFor(featureName, config)
307
+ const errors = rejection ? [configRejectionError(rejection, config)] : result.errors
308
+ return {
309
+ ok: errors.length === 0,
310
+ config: result.config,
311
+ errors: errors.map((error) => `Feature "${featureName}" ${error}`),
312
+ }
313
+ }
314
+
20
315
  /**
21
316
  * Resolve a slot id ("<view>.<slot>") to its archetype definition, or null.
22
317
  */
23
318
  export function getSlotArchetype(viewName, slot) {
24
- return archetypes.slots[`${viewName}.${slot}`] ?? null
319
+ const key = `${viewName}.${slot}`
320
+ return hasOwn(archetypes.slots, key) ? archetypes.slots[key] : null
25
321
  }
26
322
 
27
323
  /**
@@ -35,17 +331,21 @@ export function resolveIntent(viewName, slot, intentName) {
35
331
  errors.push(`Slot "${slot}" has no archetype for view "${viewName}".`)
36
332
  return { ok: false, errors }
37
333
  }
38
- const intent = slotArchetype.intents[intentName]
39
- if (!intent) {
334
+ const intents = isPlainObject(slotArchetype.intents) ? slotArchetype.intents : {}
335
+ if (!hasOwn(intents, intentName)) {
40
336
  errors.push(
41
- `Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(slotArchetype.intents).join(', ')}.`
337
+ `Unknown intent "${intentName}" for slot "${slot}" in view "${viewName}". Allowed intents: ${Object.keys(intents).join(', ')}.`
42
338
  )
43
339
  return { ok: false, errors }
44
340
  }
45
- if (!isAllowedVariant(viewName, slot, intent.variant)) {
46
- const allowed = getRecipe(viewName)?.allowedVariants[slot] ?? []
341
+ const intent = intents[intentName]
342
+ if (!isAllowedVariant(viewName, slot, intent?.variant)) {
343
+ const allowedBySlot = isPlainObject(getRecipe(viewName)?.allowedVariants)
344
+ ? getRecipe(viewName).allowedVariants
345
+ : {}
346
+ const allowed = hasOwn(allowedBySlot, slot) && Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
47
347
  errors.push(
48
- `Intent "${intentName}" resolves to variant "${intent.variant}", which is not allowed for slot "${slot}" in view "${viewName}". Allowed variants: ${allowed.join(', ') || '(none)'}.`
348
+ `Intent "${intentName}" resolves to variant "${intent?.variant}", which is not allowed for slot "${slot}" in view "${viewName}". Allowed variants: ${allowed.join(', ') || '(none)'}.`
49
349
  )
50
350
  return { ok: false, errors }
51
351
  }
@@ -56,6 +356,105 @@ export function loadSections() {
56
356
  return sections
57
357
  }
58
358
 
359
+ // The editorial section registry declares each entry's `status` — `executable`
360
+ // (shipped) or `held` (bounded curation). This mirrors the semantic catalog's
361
+ // `status`/`reason` convention (`recipes/catalog.json`): a held entry MUST carry
362
+ // a non-empty `reason`; there is no separate held list, so the registry stays the
363
+ // single source of truth the validation and the CLI introspection read.
364
+ export const SECTION_STATUSES = ['executable', 'held']
365
+
366
+ /**
367
+ * The invariant errors in the section registry the JSON schema subset cannot
368
+ * express: every entry declares a known `status`; a `held` entry names its
369
+ * bounded reason; an `executable` entry declares its element type, variants, and
370
+ * a default variant drawn from those variants. Returns a list of messages (empty
371
+ * when the registry is well-formed).
372
+ */
373
+ export function sectionStatusErrors() {
374
+ const errors = []
375
+ const list = Array.isArray(sections.sections) ? sections.sections : []
376
+ for (const section of list) {
377
+ if (!isPlainObject(section) || typeof section.name !== 'string' || section.name.length === 0) {
378
+ errors.push('A section registry entry must declare a non-empty "name".')
379
+ continue
380
+ }
381
+ const { name } = section
382
+ if (!SECTION_STATUSES.includes(section.status)) {
383
+ errors.push(
384
+ `Section "${name}" must declare a status of ${SECTION_STATUSES.join(' | ')}.`
385
+ )
386
+ continue
387
+ }
388
+ if (section.status === 'held' && !(typeof section.reason === 'string' && section.reason.length > 0)) {
389
+ errors.push(`Held section "${name}" must declare a non-empty reason.`)
390
+ }
391
+ if (section.status === 'executable') {
392
+ if (!(typeof section.elementType === 'string' && section.elementType.length > 0)) {
393
+ errors.push(`Executable section "${name}" must declare an "elementType".`)
394
+ }
395
+ const variants = Array.isArray(section.variants) ? section.variants : []
396
+ if (variants.length === 0) {
397
+ errors.push(`Executable section "${name}" must declare at least one variant.`)
398
+ } else if (!variants.includes(section.defaultVariant)) {
399
+ errors.push(
400
+ `Executable section "${name}" default "${section.defaultVariant}" is not a declared variant.`
401
+ )
402
+ }
403
+ }
404
+ }
405
+ return errors
406
+ }
407
+
408
+ /** The registry entries whose status is `executable`, in registry order. */
409
+ export function availableSections() {
410
+ const list = Array.isArray(sections.sections) ? sections.sections : []
411
+ return list.filter((section) => section.status === 'executable')
412
+ }
413
+
414
+ /** The names of the executable section types, in registry order. */
415
+ export function availableSectionNames() {
416
+ return availableSections().map((section) => section.name)
417
+ }
418
+
419
+ /** The registry entries whose status is `held`, in registry order. */
420
+ export function heldSections() {
421
+ const list = Array.isArray(sections.sections) ? sections.sections : []
422
+ return list.filter((section) => section.status === 'held')
423
+ }
424
+
425
+ /** The names of the held section types, in registry order. */
426
+ export function heldSectionNames() {
427
+ return heldSections().map((section) => section.name)
428
+ }
429
+
430
+ /** The declared status of a section type, or null when it is not registered. */
431
+ export function sectionStatus(name) {
432
+ const section = sections.sections.find((entry) => entry.name === name)
433
+ return section ? section.status : null
434
+ }
435
+
436
+ /** The bounded-curation reason of a held section type, or null otherwise. */
437
+ export function heldSectionReason(name) {
438
+ const section = sections.sections.find((entry) => entry.name === name)
439
+ return section?.status === 'held' && typeof section.reason === 'string' ? section.reason : null
440
+ }
441
+
442
+ /**
443
+ * The section registry projected for introspection: each type with its status
444
+ * and, for a held type, its bounded reason. Held types are visibly held rather
445
+ * than presented as available.
446
+ */
447
+ export function sectionRegistry() {
448
+ const list = Array.isArray(sections.sections) ? sections.sections : []
449
+ return list.map((section) => ({
450
+ name: section.name,
451
+ status: section.status,
452
+ ...(section.status === 'held' && typeof section.reason === 'string'
453
+ ? { reason: section.reason }
454
+ : {}),
455
+ }))
456
+ }
457
+
59
458
  export function getRecipe(name) {
60
459
  return recipes.views.find((v) => v.name === name) ?? null
61
460
  }
@@ -64,18 +463,159 @@ export function allowedViewNames() {
64
463
  return recipes.views.map((v) => v.name)
65
464
  }
66
465
 
466
+ /**
467
+ * The curated MVP unit set. Only these units are shipped and buildable today;
468
+ * everything else is a traced post-MVP backlog item. The set is declared on the
469
+ * registry entries themselves (`mvp: true`) so introspection and validation read
470
+ * one source of truth.
471
+ */
472
+ export function curatedViewNames() {
473
+ return recipes.views.filter((v) => v.mvp === true).map((v) => v.name)
474
+ }
475
+
476
+ export function isCuratedView(name) {
477
+ return getRecipe(name)?.mvp === true
478
+ }
479
+
480
+ export function curatedFeatureNames() {
481
+ return features.features.filter((f) => f.mvp === true).map((f) => f.name)
482
+ }
483
+
484
+ export function isCuratedFeature(name) {
485
+ return getFeature(name)?.mvp === true
486
+ }
487
+
488
+ /**
489
+ * The catalog entry for one slot ("<view>.<slot>"), or null when the slot is
490
+ * outside the catalog scope (a held, non-curated view). The catalog is the
491
+ * single classification source; nothing here hardcodes a variant.
492
+ */
493
+ export function getCatalogSlot(viewName, slot) {
494
+ const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
495
+ const key = `${viewName}.${slot}`
496
+ return hasOwn(slots, key) ? slots[key] : null
497
+ }
498
+
499
+ /**
500
+ * The catalog entry for one variant of a slot, or null when the slot or the
501
+ * variant is outside the catalog scope. Used to name a held variant's reason at
502
+ * the point a selection is rejected (E1 task 1.4 / CU-07).
503
+ */
504
+ export function getCatalogVariant(viewName, slot, variant) {
505
+ const slotEntry = getCatalogSlot(viewName, slot)
506
+ const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
507
+ return hasOwn(variants, variant) ? variants[variant] : null
508
+ }
509
+
510
+ /**
511
+ * The variant names the catalog classifies as `executable` for a slot — the
512
+ * only selectable set. Held variants are excluded by construction.
513
+ */
514
+ export function executableVariants(viewName, slot) {
515
+ const slotEntry = getCatalogSlot(viewName, slot)
516
+ const variants = isPlainObject(slotEntry?.variants) ? slotEntry.variants : {}
517
+ return Object.entries(variants)
518
+ .filter(([, entry]) => isPlainObject(entry) && entry.status === 'executable')
519
+ .map(([variantName]) => variantName)
520
+ }
521
+
522
+ /**
523
+ * The catalog slice for a view: every declared slot ("<view>.<slot>") with its
524
+ * variants and their executable/held status. Introspection reports this so a
525
+ * held variant is visible with its reason and never presented as selectable.
526
+ */
527
+ export function viewCatalog(viewName) {
528
+ const slots = isPlainObject(catalog.slots) ? catalog.slots : {}
529
+ const out = {}
530
+ for (const [slotId, slot] of Object.entries(slots)) {
531
+ if (slotId.startsWith(`${viewName}.`)) out[slotId] = slot
532
+ }
533
+ return out
534
+ }
535
+
536
+ function slotNameFromId(viewName, slotId) {
537
+ return slotId.slice(viewName.length + 1)
538
+ }
539
+
540
+ /**
541
+ * Selectable variants per slot, derived from the catalog executable set.
542
+ * Returns { slot: [variant, ...] }. Slots outside the catalog are omitted, so
543
+ * a held view never advertises a selectable variant.
544
+ */
545
+ export function allowedVariantsForView(viewName) {
546
+ const out = {}
547
+ for (const slotId of Object.keys(viewCatalog(viewName))) {
548
+ const slot = slotNameFromId(viewName, slotId)
549
+ out[slot] = executableVariants(viewName, slot)
550
+ }
551
+ return out
552
+ }
553
+
554
+ /**
555
+ * The supported (executable) default variant per slot, derived from the
556
+ * catalog. Legacy single-value alias of the executable set. Returns
557
+ * { slot: variant }.
558
+ */
559
+ export function supportedVariantsForView(viewName) {
560
+ const out = {}
561
+ for (const [slotId, slot] of Object.entries(viewCatalog(viewName))) {
562
+ if (typeof slot.default === 'string') {
563
+ out[slotNameFromId(viewName, slotId)] = slot.default
564
+ }
565
+ }
566
+ return out
567
+ }
568
+
569
+ /**
570
+ * The supported default variant for a slot (legacy single-value accessor). The
571
+ * default is executable by contract; null when the slot is outside the catalog.
572
+ */
573
+ export function supportedVariant(viewName, slot) {
574
+ const slotEntry = getCatalogSlot(viewName, slot)
575
+ return typeof slotEntry?.default === 'string' ? slotEntry.default : null
576
+ }
577
+
578
+ export function isSupportedVariant(viewName, slot, variant) {
579
+ return executableVariants(viewName, slot).includes(variant)
580
+ }
581
+
582
+ /**
583
+ * Supported intent names per slot: intents whose resolved variant is
584
+ * executable. Returns { slot: [intent, ...] }.
585
+ */
586
+ export function supportedIntents(viewName) {
587
+ const out = {}
588
+ for (const slotId of Object.keys(viewCatalog(viewName))) {
589
+ const slot = slotNameFromId(viewName, slotId)
590
+ const slotArchetype = getSlotArchetype(viewName, slot)
591
+ const intents = isPlainObject(slotArchetype?.intents) ? slotArchetype.intents : {}
592
+ const executable = new Set(executableVariants(viewName, slot))
593
+ out[slot] = Object.entries(intents)
594
+ .filter(([, intent]) => executable.has(intent?.variant))
595
+ .map(([intentName]) => intentName)
596
+ }
597
+ return out
598
+ }
599
+
600
+ export function isSupportedIntent(viewName, slot, intentName) {
601
+ const supported = supportedIntents(viewName)
602
+ return Array.isArray(supported[slot]) && supported[slot].includes(intentName)
603
+ }
604
+
67
605
  export function isAllowedVariant(viewName, slot, variant) {
68
606
  const recipe = getRecipe(viewName)
69
607
  if (!recipe) return false
70
- const allowed = recipe.allowedVariants[slot]
71
- if (!allowed) return false
72
- return allowed.includes(variant)
608
+ const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
609
+ if (!hasOwn(allowedBySlot, slot)) return false
610
+ const allowed = allowedBySlot[slot]
611
+ return Array.isArray(allowed) && allowed.includes(variant)
73
612
  }
74
613
 
75
614
  export function isAllowedSection(viewName, sectionName) {
76
615
  const recipe = getRecipe(viewName)
77
616
  if (!recipe) return false
78
- if (!recipe.optionalSections.includes(sectionName)) return false
617
+ const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
618
+ if (!optionalSections.includes(sectionName)) return false
79
619
  return sections.sections.some((s) => s.name === sectionName)
80
620
  }
81
621
 
@@ -86,40 +626,242 @@ export function isAllowedSection(viewName, sectionName) {
86
626
  */
87
627
  export function validateComposition(composition) {
88
628
  const errors = []
89
- const recipe = getRecipe(composition.view)
629
+ const viewName = isPlainObject(composition) ? composition.view : undefined
630
+ const recipe = getRecipe(viewName)
90
631
  if (!recipe) {
632
+ errors.push(`Unknown view "${viewName}". Allowed views: ${allowedViewNames().join(', ')}.`)
633
+ return { ok: false, errors }
634
+ }
635
+ if (!isCuratedView(viewName)) {
91
636
  errors.push(
92
- `Unknown view "${composition.view}". Allowed views: ${allowedViewNames().join(', ')}.`
637
+ `View "${viewName}" is outside the curated MVP set (${curatedViewNames().join(', ')}). It is held post-MVP.`
93
638
  )
94
639
  return { ok: false, errors }
95
640
  }
96
641
 
97
- for (const [slot, intentName] of Object.entries(composition.intents ?? {})) {
98
- const resolved = resolveIntent(composition.view, slot, intentName)
99
- if (!resolved.ok) errors.push(...resolved.errors)
642
+ const intents = isPlainObject(composition.intents) ? composition.intents : {}
643
+ for (const [slot, intentName] of Object.entries(intents)) {
644
+ const resolved = resolveIntent(viewName, slot, intentName)
645
+ if (!resolved.ok) {
646
+ errors.push(...resolved.errors)
647
+ continue
648
+ }
649
+ if (!isSupportedIntent(viewName, slot, intentName)) {
650
+ const supported = supportedIntents(viewName)[slot] ?? []
651
+ errors.push(
652
+ `Intent "${intentName}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}". Supported intents: ${supported.join(', ') || '(none)'}.`
653
+ )
654
+ }
100
655
  }
101
656
 
102
- for (const [slot, variant] of Object.entries(composition.variants ?? {})) {
103
- if (!isAllowedVariant(composition.view, slot, variant)) {
104
- const allowed = recipe.allowedVariants[slot] ?? []
657
+ const variants = isPlainObject(composition.variants) ? composition.variants : {}
658
+ const allowedBySlot = isPlainObject(recipe.allowedVariants) ? recipe.allowedVariants : {}
659
+ for (const [slot, variant] of Object.entries(variants)) {
660
+ if (!hasOwn(allowedBySlot, slot)) {
105
661
  errors.push(
106
- `Variant "${variant}" is not allowed for slot "${slot}" in view "${composition.view}". Allowed: ${allowed.join(', ') || '(none)'}.`
662
+ `Slot "${slot}" has no variants for view "${viewName}". Allowed slots: ${Object.keys(allowedBySlot).join(', ') || '(none)'}.`
107
663
  )
664
+ continue
108
665
  }
666
+ if (!isAllowedVariant(viewName, slot, variant)) {
667
+ const allowed = Array.isArray(allowedBySlot[slot]) ? allowedBySlot[slot] : []
668
+ errors.push(
669
+ `Variant "${variant}" is not allowed for slot "${slot}" in view "${viewName}". Allowed: ${allowed.join(', ') || '(none)'}.`
670
+ )
671
+ continue
672
+ }
673
+ if (!isSupportedVariant(viewName, slot, variant)) {
674
+ const supported = executableVariants(viewName, slot)
675
+ // A held variant is rejected with its catalog reason so the diagnostic is
676
+ // actionable; an allowed-but-unknown variant keeps the legacy wording.
677
+ const heldEntry = getCatalogVariant(viewName, slot, variant)
678
+ const heldDetail =
679
+ heldEntry?.status === 'held' && typeof heldEntry.reason === 'string'
680
+ ? ` Held reason: ${heldEntry.reason}.`
681
+ : ''
682
+ errors.push(
683
+ `Variant "${variant}" is not supported by the curated MVP set for slot "${slot}" in view "${viewName}".${heldDetail} Supported variants: ${supported.join(', ') || '(none)'}.`
684
+ )
685
+ }
686
+ }
687
+
688
+ // A slot selected by both an intent and a variant has two competing truths;
689
+ // reject it before any write (E1 task 2.2 / the `view.add` operation contract).
690
+ for (const slot of Object.keys(intents)) {
691
+ if (hasOwn(variants, slot)) {
692
+ errors.push(
693
+ `Slot "${slot}" in view "${viewName}" is selected by both an intent and a variant; choose exactly one.`
694
+ )
695
+ }
696
+ }
697
+
698
+ // The ordered per-view composition (E3): a new-form entry is
699
+ // `{ id, type, variant? }` — a registered-slot entry uses the reserved
700
+ // `id === "slot:" + type` form and an editorial instance a persisted id that
701
+ // does not start with `slot:`. The legacy `{ name, variant }` shape stays
702
+ // accepted until the deterministic migration rewrites it, and is still held
703
+ // post-MVP. The cross-entry invariants the published schema cannot express
704
+ // (reserved form, registered slot, allowed type, duplicate id, the editorial
705
+ // `maxSections` bound) are enforced here so a hand-edited manifest is refused
706
+ // before any write.
707
+ const sectionsList = Array.isArray(composition.sections) ? composition.sections : []
708
+ const optionalSections = Array.isArray(recipe.optionalSections) ? recipe.optionalSections : []
709
+ const allowedSections = Array.isArray(recipe.allowedSections) ? recipe.allowedSections : []
710
+ const registeredSlots = Array.isArray(recipe.registeredSlots) ? recipe.registeredSlots : []
711
+ const maxSections = typeof recipe.maxSections === 'number' ? recipe.maxSections : null
712
+ const seenSectionIds = new Set()
713
+ let editorialCount = 0
714
+ for (const section of sectionsList) {
715
+ if (!isPlainObject(section)) {
716
+ errors.push(`A section entry in view "${viewName}" must be an object.`)
717
+ continue
718
+ }
719
+ const hasId = typeof section.id === 'string' && section.id.length > 0
720
+ const hasType = typeof section.type === 'string' && section.type.length > 0
721
+ if (hasId || hasType) {
722
+ if (!hasId || !hasType) {
723
+ errors.push(
724
+ `A section entry in view "${viewName}" must declare both "id" and "type" (or a legacy "name").`
725
+ )
726
+ continue
727
+ }
728
+ if (section.id.startsWith('slot:')) {
729
+ if (section.id !== `slot:${section.type}`) {
730
+ errors.push(
731
+ `Reserved slot id "${section.id}" in view "${viewName}" must equal "slot:${section.type}".`
732
+ )
733
+ } else if (!registeredSlots.includes(section.type)) {
734
+ errors.push(
735
+ `Slot "${section.type}" is not a registered slot of view "${viewName}".`
736
+ )
737
+ }
738
+ } else {
739
+ editorialCount += 1
740
+ if (!allowedSections.includes(section.type)) {
741
+ errors.push(
742
+ `Section "${section.type}" is not an allowed section for view "${viewName}". Allowed: ${allowedSections.join(', ') || '(none)'}.`
743
+ )
744
+ }
745
+ }
746
+ if (seenSectionIds.has(section.id)) {
747
+ errors.push(`Duplicate section id "${section.id}" in view "${viewName}".`)
748
+ } else {
749
+ seenSectionIds.add(section.id)
750
+ }
751
+ continue
752
+ }
753
+
754
+ const sectionName = section.name
755
+ if (!isAllowedSection(viewName, sectionName)) {
756
+ errors.push(
757
+ `Section "${sectionName}" is not allowed in view "${viewName}". Allowed: ${optionalSections.join(', ') || '(none)'}.`
758
+ )
759
+ continue
760
+ }
761
+ errors.push(
762
+ `Section "${sectionName}" is not supported by the curated MVP set for view "${viewName}". Sections are held post-MVP.`
763
+ )
764
+ }
765
+ if (maxSections !== null && editorialCount > maxSections) {
766
+ errors.push(
767
+ `View "${viewName}" declares ${editorialCount} editorial sections, exceeding maxSections ${maxSections}.`
768
+ )
769
+ }
770
+
771
+ return { ok: errors.length === 0, errors }
772
+ }
773
+
774
+ /**
775
+ * Validate a complete composition manifest against the registry.
776
+ *
777
+ * Every installed view must be a known recipe whose intents, variants, and
778
+ * sections resolve; every installed feature must be a known registry feature;
779
+ * and feature/view dependencies must be consistent:
780
+ * - a view owned by a feature cannot be installed on its own (it must arrive
781
+ * through `storefront feature add <feature>`), and
782
+ * - an installed feature must have all of its owned views installed.
783
+ *
784
+ * Returns { ok, errors }.
785
+ */
786
+ export function validateCompositionManifest(manifest, options = {}) {
787
+ const errors = []
788
+ const views = Array.isArray(manifest?.views) ? manifest.views : []
789
+ const featureList = Array.isArray(manifest?.features) ? manifest.features : []
790
+
791
+ for (const view of views) {
792
+ if (!isPlainObject(view) || typeof view.name !== 'string') continue
793
+ const result = validateComposition({
794
+ view: view.name,
795
+ intents: view.intents ?? {},
796
+ variants: view.variants ?? {},
797
+ sections: view.sections ?? [],
798
+ })
799
+ if (!result.ok) errors.push(...result.errors)
109
800
  }
110
801
 
111
- for (const section of composition.sections ?? []) {
112
- if (!isAllowedSection(composition.view, section.name)) {
802
+ const installedFeatures = new Set()
803
+ for (const feature of featureList) {
804
+ if (!isPlainObject(feature) || typeof feature.name !== 'string') continue
805
+ if (!isAllowedFeature(feature.name)) {
113
806
  errors.push(
114
- `Section "${section.name}" is not allowed in view "${composition.view}". Allowed: ${recipe.optionalSections.join(', ') || '(none)'}.`
807
+ `Unknown feature "${feature.name}". Allowed features: ${allowedFeatureNames().join(', ')}.`
115
808
  )
116
809
  continue
117
810
  }
118
- if (section.variant) {
119
- const def = sections.sections.find((s) => s.name === section.name)
120
- if (def && !def.variants.includes(section.variant)) {
811
+ // Validate the persisted config against the feature's declared contract
812
+ // before any write, independent of the feature's executable/held status
813
+ // (E1 task 3.2 / CU-06): a manually edited or untrusted manifest must never
814
+ // reach generation with a config that violates the contract. A held feature
815
+ // still reports its held gate below, so a valid config never unholds it, and
816
+ // an invalid config is named by field even when the feature is held. A
817
+ // feature that accepts no configuration (E1 task 3.5) reports its stable
818
+ // rejection code here too, so `sync`/`doctor` reject a persisted non-empty
819
+ // checkout config before writing.
820
+ const configResult = normalizeFeatureConfig(
821
+ feature.name,
822
+ hasOwn(feature, 'config') ? feature.config : {}
823
+ )
824
+ if (!configResult.ok) {
825
+ errors.push(...configResult.errors)
826
+ }
827
+ if (!isCuratedFeature(feature.name)) {
828
+ errors.push(
829
+ `Feature "${feature.name}" is outside the curated MVP set (${curatedFeatureNames().join(', ')}). It is held post-MVP.`
830
+ )
831
+ continue
832
+ }
833
+ installedFeatures.add(feature.name)
834
+ }
835
+
836
+ // Host-slot claim rules (E1 task 3.7 / CU-06): a mount id is unique across
837
+ // installed features and a `(host, slot)` is claimed by at most one instance.
838
+ // Resolved through the same registry authority (`options.getFeatureSpec` is
839
+ // the injection seam), so `sync`/`doctor`/`feature add` reject a conflicting
840
+ // claim before any write.
841
+ const claims = resolveMountClaims(featureList, options)
842
+ if (!claims.ok) errors.push(...claims.errors)
843
+
844
+ const installedViews = new Set(
845
+ views.filter((view) => isPlainObject(view) && typeof view.name === 'string').map((view) => view.name)
846
+ )
847
+
848
+ for (const viewName of installedViews) {
849
+ const owner = features.features.find(
850
+ (feature) => Array.isArray(feature.views) && feature.views.includes(viewName)
851
+ )
852
+ if (owner && !installedFeatures.has(owner.name)) {
853
+ errors.push(
854
+ `View "${viewName}" is owned by feature "${owner.name}" and cannot be installed directly. Run \`storefront feature add ${owner.name}\` instead.`
855
+ )
856
+ }
857
+ }
858
+
859
+ for (const featureName of installedFeatures) {
860
+ const owned = getFeature(featureName)?.views
861
+ for (const viewName of Array.isArray(owned) ? owned : []) {
862
+ if (!installedViews.has(viewName)) {
121
863
  errors.push(
122
- `Section variant "${section.variant}" is not allowed for section "${section.name}". Allowed: ${def.variants.join(', ')}.`
864
+ `Feature "${featureName}" requires view "${viewName}", which is not installed. Run \`storefront feature add ${featureName}\` to install it.`
123
865
  )
124
866
  }
125
867
  }
@@ -179,8 +921,18 @@ function main() {
179
921
  return
180
922
  }
181
923
 
924
+ if (command === 'sections') {
925
+ const errors = sectionStatusErrors()
926
+ if (errors.length > 0) {
927
+ console.error(errors.join('\n'))
928
+ process.exit(1)
929
+ }
930
+ console.log(JSON.stringify(sectionRegistry(), null, 2))
931
+ return
932
+ }
933
+
182
934
  console.error(
183
- 'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes'
935
+ 'Usage: validate.mjs list | show <view> | validate <composition.json> | resolve <view> <slot> <intent> | archetypes | sections'
184
936
  )
185
937
  process.exit(1)
186
938
  }