@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
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ // Ownership-conflict detection (E1 task 5.1 / CU-08).
3
+ //
4
+ // A view slot renders from the selected variant's assembly fragment, which
5
+ // consumes two user-owned contract files:
6
+ //
7
+ // views/<view>/schema.ts — Zod mirrors of the block / data-shape contracts
8
+ // views/<view>/props.ts — the element props objects
9
+ //
10
+ // Those files are user-owned: a developer may customize them and they survive
11
+ // `sync`. When an operation selects a variant, the engine verifies that the
12
+ // customized contract still provides every field the variant's fragment
13
+ // requires. A customized file that dropped or renamed a required field is an
14
+ // actionable ownership conflict: the operation reports the file and the
15
+ // differing fields and writes nothing.
16
+ //
17
+ // The required field set is the *pristine template render* of the same file —
18
+ // the canonical provider of the slot's data shape (design Decision 9) — reduced
19
+ // to the schema/props identifiers the selected variant's fragments actually
20
+ // reference, transitively. This is a bounded, deterministic text inspection: no
21
+ // user code is imported or executed, and the customized file is only ever read.
22
+ // Customization that changes values, adds fields, or relaxes fields to optional
23
+ // keeps the required set satisfied and is never flagged.
24
+ //
25
+ // The detector is read-only by construction (it opens files only for reading),
26
+ // so a rejected plan/apply/alias leaves every file byte-unchanged.
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import path from 'node:path'
30
+ import { getCatalogVariant } from '../recipes/validate.mjs'
31
+ import { DEFAULT_THEME_MODE } from './manifest.mjs'
32
+ import { replacePlaceholders } from './render-templates.mjs'
33
+ import { viewTemplateDir } from './reconcile.mjs'
34
+ import { assemblyDir, hasViewAssembly } from './view-assembly.mjs'
35
+ import {
36
+ resolveFeatureMountOverrides,
37
+ resolveViewGenerationSelection,
38
+ } from './view-selection.mjs'
39
+
40
+ export const OWNERSHIP_CONFLICT_CODE = 'E_OWNERSHIP_CONFLICT'
41
+
42
+ // The user-owned contract files checked, in a stable order.
43
+ const CONTRACT_FILES = Object.freeze([
44
+ { rel: 'schema.ts', kind: 'schema' },
45
+ { rel: 'props.ts', kind: 'props' },
46
+ ])
47
+
48
+ // An identifier the selected fragment consumes from a contract file. The
49
+ // templates name block prop schemas (`...Schema`) and element props objects
50
+ // (`...Props`); nothing else is a data-shape contract.
51
+ const CONTRACT_IDENTIFIER_RE = /[A-Za-z_$][\w$]*(?:Schema|Props)/g
52
+ const IDENTIFIER_RE = /[A-Za-z_$][\w$]*/g
53
+
54
+ function isPlainObject(value) {
55
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
56
+ }
57
+
58
+ function diagnostic(message, extra = {}) {
59
+ return { code: OWNERSHIP_CONFLICT_CODE, severity: 'error', message, ...extra }
60
+ }
61
+
62
+ function toPosix(rel) {
63
+ return rel.split(path.sep).join('/')
64
+ }
65
+
66
+ // Return the text inside the bracket pair starting at `openIndex`, honoring
67
+ // strings and comments so a `}` inside a string/comment does not close early.
68
+ // Only the matching open/close pair is counted, which is sufficient for the
69
+ // balanced object literals these templates declare.
70
+ function sliceBalanced(text, openIndex) {
71
+ const open = text[openIndex]
72
+ const close = open === '{' ? '}' : open === '(' ? ')' : open === '[' ? ']' : null
73
+ if (!close) return ''
74
+ let depth = 0
75
+ let quote = null
76
+ for (let i = openIndex; i < text.length; i++) {
77
+ const ch = text[i]
78
+ const next = text[i + 1]
79
+ if (quote) {
80
+ if (ch === '\\') {
81
+ i++
82
+ continue
83
+ }
84
+ if (ch === quote) quote = null
85
+ continue
86
+ }
87
+ if (ch === '/' && next === '/') {
88
+ const nl = text.indexOf('\n', i)
89
+ i = nl === -1 ? text.length : nl
90
+ continue
91
+ }
92
+ if (ch === '/' && next === '*') {
93
+ const end = text.indexOf('*/', i + 2)
94
+ i = end === -1 ? text.length : end + 1
95
+ continue
96
+ }
97
+ if (ch === '"' || ch === "'" || ch === '`') {
98
+ quote = ch
99
+ continue
100
+ }
101
+ if (ch === open) depth++
102
+ else if (ch === close) {
103
+ depth--
104
+ if (depth === 0) return text.slice(openIndex + 1, i)
105
+ }
106
+ }
107
+ return text.slice(openIndex + 1)
108
+ }
109
+
110
+ // Split an object body into its top-level entries, respecting nested
111
+ // brackets/strings/comments so a comma inside a nested value never splits.
112
+ function topLevelEntries(body) {
113
+ const entries = []
114
+ let depth = 0
115
+ let quote = null
116
+ let start = 0
117
+ for (let i = 0; i < body.length; i++) {
118
+ const ch = body[i]
119
+ const next = body[i + 1]
120
+ if (quote) {
121
+ if (ch === '\\') {
122
+ i++
123
+ continue
124
+ }
125
+ if (ch === quote) quote = null
126
+ continue
127
+ }
128
+ if (ch === '/' && next === '/') {
129
+ const nl = body.indexOf('\n', i)
130
+ i = nl === -1 ? body.length : nl
131
+ continue
132
+ }
133
+ if (ch === '/' && next === '*') {
134
+ const end = body.indexOf('*/', i + 2)
135
+ i = end === -1 ? body.length : end + 1
136
+ continue
137
+ }
138
+ if (ch === '"' || ch === "'" || ch === '`') {
139
+ quote = ch
140
+ continue
141
+ }
142
+ if (ch === '{' || ch === '(' || ch === '[') depth++
143
+ else if (ch === '}' || ch === ')' || ch === ']') depth--
144
+ else if (ch === ',' && depth === 0) {
145
+ entries.push(body.slice(start, i))
146
+ start = i + 1
147
+ }
148
+ }
149
+ entries.push(body.slice(start))
150
+ return entries.map((entry) => entry.trim()).filter(Boolean)
151
+ }
152
+
153
+ const ENTRY_RE = /^(?:\.\.\.\s*)?(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$]*))\s*(?::([\s\S]*))?$/
154
+
155
+ function parseEntry(entry) {
156
+ const spread = /^\.\.\./.test(entry)
157
+ const match = entry.match(ENTRY_RE)
158
+ if (!match) return null
159
+ const key = match[1] ?? match[2] ?? match[3]
160
+ return { key, value: (match[4] ?? '').trim(), spread }
161
+ }
162
+
163
+ // Map each `const <id> = z.object({ ... })` to its top-level field names. A
164
+ // spread-free object literal is what these mirrors declare; anonymous nested
165
+ // objects are not named and are reached through their referencing schema.
166
+ function extractSchemaFields(text) {
167
+ const out = new Map()
168
+ const re = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*z\s*\.\s*object\s*\(/g
169
+ let match
170
+ while ((match = re.exec(text))) {
171
+ const id = match[1]
172
+ const paren = match.index + match[0].length - 1
173
+ const brace = text.indexOf('{', paren)
174
+ if (brace === -1) continue
175
+ const fields = new Map()
176
+ for (const entry of topLevelEntries(sliceBalanced(text, brace))) {
177
+ const parsed = parseEntry(entry)
178
+ if (parsed && !parsed.spread) fields.set(parsed.key, parsed.value)
179
+ }
180
+ out.set(id, fields)
181
+ }
182
+ return out
183
+ }
184
+
185
+ // Map each `const <id> = { ... }` object literal to its top-level field names.
186
+ // A spread entry (`...content`) is not a field, so it is not recorded.
187
+ function extractObjectFields(text) {
188
+ const out = new Map()
189
+ const re = /(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g
190
+ let match
191
+ while ((match = re.exec(text))) {
192
+ const id = match[1]
193
+ const brace = match.index + match[0].length - 1
194
+ const fields = new Map()
195
+ for (const entry of topLevelEntries(sliceBalanced(text, brace))) {
196
+ const parsed = parseEntry(entry)
197
+ if (parsed && !parsed.spread) fields.set(parsed.key, parsed.value)
198
+ }
199
+ out.set(id, fields)
200
+ }
201
+ return out
202
+ }
203
+
204
+ // The transitive field set reachable from a root schema identifier: the root's
205
+ // own fields plus, for every field value that references another declared
206
+ // schema (e.g. `z.array(featureItemSchema)`), that schema's fields. This is what
207
+ // lets a removed item field inside `featureItemSchema` surface through the
208
+ // `featurePropsSchema` the fragment references.
209
+ function transitiveFields(fieldSets, rootId, seen = new Set()) {
210
+ const out = new Map()
211
+ const direct = fieldSets.get(rootId)
212
+ if (!direct || seen.has(rootId)) return out
213
+ seen.add(rootId)
214
+ for (const [key, value] of direct) {
215
+ out.set(key, value)
216
+ for (const ref of value.match(IDENTIFIER_RE) ?? []) {
217
+ if (ref === rootId || !fieldSets.has(ref)) continue
218
+ for (const [nestedKey, nestedValue] of transitiveFields(fieldSets, ref, seen)) {
219
+ out.set(nestedKey, nestedValue)
220
+ }
221
+ }
222
+ }
223
+ return out
224
+ }
225
+
226
+ // Collect the contract identifiers a selected variant's fragments consume.
227
+ function referencedIdentifiers(templateDir, slot, variant) {
228
+ const dir = path.join(assemblyDir(templateDir), 'slots', slot, variant)
229
+ const ids = new Set()
230
+ for (const name of ['catalog.part', 'registry.part', 'element.part']) {
231
+ const file = path.join(dir, name)
232
+ if (!existsSync(file)) continue
233
+ for (const id of readFileSync(file, 'utf8').match(CONTRACT_IDENTIFIER_RE) ?? []) {
234
+ ids.add(id)
235
+ }
236
+ }
237
+ return ids
238
+ }
239
+
240
+ function compareContract({ relPath, kind, pristineText, customizedText, rootIds }) {
241
+ const pristineSets = kind === 'schema' ? extractSchemaFields(pristineText) : extractObjectFields(pristineText)
242
+ const customizedSets =
243
+ kind === 'schema' ? extractSchemaFields(customizedText) : extractObjectFields(customizedText)
244
+ const required = new Map()
245
+ const provided = new Map()
246
+ for (const id of rootIds) {
247
+ if (kind === 'schema') {
248
+ if (!pristineSets.has(id)) continue
249
+ for (const [key, value] of transitiveFields(pristineSets, id)) required.set(key, value)
250
+ for (const [key, value] of transitiveFields(customizedSets, id)) provided.set(key, value)
251
+ } else {
252
+ // Element props are flat: a spread (`...content`) is not a field, so a
253
+ // root with only spreads requires nothing and can never conflict.
254
+ const pristineFields = pristineSets.get(id)
255
+ if (!pristineFields) continue
256
+ for (const [key, value] of pristineFields) required.set(key, value)
257
+ for (const [key, value] of customizedSets.get(id) ?? []) provided.set(key, value)
258
+ }
259
+ }
260
+ const missing = [...required.keys()].filter((key) => !provided.has(key)).sort()
261
+ if (missing.length === 0) return null
262
+ return {
263
+ path: relPath,
264
+ fields: missing,
265
+ expected: [...required.keys()].sort(),
266
+ actual: [...provided.keys()].sort(),
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Detect ownership conflicts for the composition described by `manifest`.
272
+ *
273
+ * Returns `{ ok, diagnostics }`. A conflict is reported for every customized
274
+ * user-owned `views/<view>/schema.ts` / `props.ts` that no longer provides a
275
+ * field required by a variant the manifest selects (directly or through a
276
+ * feature mount). Nothing is written. `options.viewTemplateDir` is the hermetic
277
+ * injection seam a test uses without touching the template tree; it defaults to
278
+ * the package resolver.
279
+ */
280
+ export function detectOwnershipConflicts(projectDir, manifest, options = {}) {
281
+ const name = isPlainObject(manifest) && typeof manifest.name === 'string' ? manifest.name : null
282
+ if (!name) return { ok: true, diagnostics: [] }
283
+ const resolveTemplateDir =
284
+ typeof options.viewTemplateDir === 'function' ? options.viewTemplateDir : viewTemplateDir
285
+
286
+ const mountOverrides = resolveFeatureMountOverrides(manifest)
287
+ if (!mountOverrides.ok) return { ok: true, diagnostics: [] }
288
+
289
+ const subs = {
290
+ name,
291
+ siteName: manifest.siteName ?? name,
292
+ mode: manifest.theme?.mode ?? DEFAULT_THEME_MODE,
293
+ }
294
+ const diagnostics = []
295
+ const views = Array.isArray(manifest.views) ? manifest.views : []
296
+ for (const view of views) {
297
+ const viewName = typeof view === 'string' ? view : view?.name
298
+ if (typeof viewName !== 'string') continue
299
+ const templateDir = resolveTemplateDir(viewName)
300
+ if (!templateDir || !hasViewAssembly(templateDir)) continue
301
+ const resolved = resolveViewGenerationSelection(view, mountOverrides.byView)
302
+ if (!resolved.ok) continue
303
+ const selectedSlots = Object.keys(resolved.selection)
304
+ if (selectedSlots.length === 0) continue
305
+
306
+ const viewDir = path.join(projectDir, 'lib', name, 'views', viewName)
307
+ for (const { rel, kind } of CONTRACT_FILES) {
308
+ const abs = path.join(viewDir, rel)
309
+ const templateFile = path.join(templateDir, rel)
310
+ if (!existsSync(abs) || !existsSync(templateFile)) continue
311
+ const customizedText = readFileSync(abs, 'utf8')
312
+ const pristineText = replacePlaceholders(readFileSync(templateFile, 'utf8'), subs)
313
+ // A pristine file is the canonical contract; only a customized file can
314
+ // conflict with the selected variant.
315
+ if (customizedText === pristineText) continue
316
+ const projectRel = toPosix(path.relative(projectDir, abs))
317
+
318
+ for (const slot of selectedSlots) {
319
+ const variant = resolved.selection[slot]
320
+ const rootIds = [...referencedIdentifiers(templateDir, slot, variant)].filter((id) =>
321
+ kind === 'schema' ? /Schema$/.test(id) : /Props$/.test(id),
322
+ )
323
+ if (rootIds.length === 0) continue
324
+ const conflict = compareContract({
325
+ relPath: projectRel,
326
+ kind,
327
+ pristineText,
328
+ customizedText,
329
+ rootIds,
330
+ })
331
+ if (!conflict) continue
332
+ const dataShape = getCatalogVariant(viewName, slot, variant)?.dataShape ?? variant
333
+ diagnostics.push(
334
+ diagnostic(
335
+ `The customized user-owned file "${projectRel}" no longer provides the data shape required by ${viewName}.${slot} variant "${variant}". Missing field(s): ${conflict.fields.join(', ')}. Add the missing field(s) to the user-owned contract (or select a compatible variant), then re-plan.`,
336
+ {
337
+ path: projectRel,
338
+ detail: {
339
+ file: projectRel,
340
+ view: viewName,
341
+ slot,
342
+ variant,
343
+ dataShape,
344
+ fields: conflict.fields,
345
+ expected: conflict.expected,
346
+ actual: conflict.actual,
347
+ recovery: 'adapt-the-user-owned-contract-and-replan',
348
+ },
349
+ },
350
+ ),
351
+ )
352
+ }
353
+ }
354
+ }
355
+ return { ok: diagnostics.length === 0, diagnostics }
356
+ }