@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.
- package/README.md +321 -152
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +159 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +45 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +439 -0
- package/assets/templates/views/account/schema.ts +53 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +162 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +58 -33
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /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
|
+
}
|