diff --git a/.changeset/views-tighten-assembled-channel.md b/.changeset/views-tighten-assembled-channel.md new file mode 100644 index 0000000000..07ac95b077 --- /dev/null +++ b/.changeset/views-tighten-assembled-channel.md @@ -0,0 +1,35 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": minor +"@objectstack/lint": patch +--- + +feat(objectql,runtime,lint): tighten `views:` to the declared container-only contract; assembled manifests travel non-container view artifacts in `viewItems:` (#5320, #8070) + +The registration loop (`registerApp` / nested-plugin seam) used to register +EVERY `views:` entry as type `view` — wider than the stack schema, which has +always declared containers only. The three gates now agree (#5320, ruled +2026-08-12): + +- **objectql**: a non-container `views:` entry (ViewItem record, flattened + overlay, inline config) is REFUSED with the ADR-0112 envelope + (`INVALID_METADATA` / 422) and the wrap-it prescription. The declared entry + for machine-assembled non-container artifacts is the new `viewItems:` + channel: each entry is validated against `AssembledViewArtifactSchema` and + the parsed body registers — declared = enforced in both directions. +- **runtime**: `GET /packages/:id/export` partitions view artifacts + (`partitionAssembledViewArtifacts`): containers travel in `views:`, expanded + items the container re-derives exactly are folded away, and standalone + ViewItems / overlays / edited expansions travel in `viewItems:`. The + export→import round trip that previously depended on the undeclared wider + acceptance now survives end to end through the declared channels. +- **lint**: the pre-parse `view-container-shape` rule reaches the same + verdicts — a `viewKind`-bearing `views:` entry is an error with the wrap-it + prescription (it previously skipped them as "registered as-is"), and a + hand-authored `viewItems:` is flagged machine-assembled-only. + +Migration: a manifest assembled by an OLDER runtime (an export product carrying +expanded `viewKind` items inside `views:`) is refused on import with the +prescription — re-export the package with a runtime that writes the +`viewItems:` channel. Authored stacks are unaffected: `defineStack` already +refused every shape the loop now refuses. diff --git a/packages/lint/src/validate-view-containers.test.ts b/packages/lint/src/validate-view-containers.test.ts index 1fc4f073cc..afc7cc8fb8 100644 --- a/packages/lint/src/validate-view-containers.test.ts +++ b/packages/lint/src/validate-view-containers.test.ts @@ -21,7 +21,11 @@ describe('validateViewContainers (defineView container shape guardrail)', () => expect(findings).toHaveLength(0); }); - it('passes an independent ViewItem (viewKind discriminator)', () => { + // [#5320] Inverted from "passes an independent ViewItem": the loader's + // as-is registration of ViewItems from `views:` was the undeclared wider + // acceptance this card removed, and the pre-parse door now reaches the same + // verdict the schema and the registration loop enforce. + it('flags an independent ViewItem in `views:` with the wrap-it prescription (#5320)', () => { const findings = validateViewContainers({ views: [ { @@ -32,7 +36,37 @@ describe('validateViewContainers (defineView container shape guardrail)', () => }, ], }); - expect(findings).toHaveLength(0); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + path: 'views[0]', + }); + expect(findings[0].where).toContain('task.pipeline'); + expect(findings[0].message).toContain('containers only'); + expect(findings[0].hint).toContain('defineView'); + expect(findings[0].hint).toContain('metadata door'); + }); + + it('flags a hand-authored `viewItems:` as machine-assembled-only (#5320)', () => { + const findings = validateViewContainers({ + viewItems: [ + { + name: 'task.pipeline', + object: 'task', + viewKind: 'list', + config: { type: 'kanban', columns: ['title'] }, + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + path: 'viewItems', + }); + expect(findings[0].message).toContain('machine-assembled'); + expect(findings[0].hint).toContain('metadata door'); }); it('flags a flat list-view object with the wrap-it hint', () => { diff --git a/packages/lint/src/validate-view-containers.ts b/packages/lint/src/validate-view-containers.ts index a60ffd18db..2094da1e80 100644 --- a/packages/lint/src/validate-view-containers.ts +++ b/packages/lint/src/validate-view-containers.ts @@ -32,8 +32,21 @@ // `os validate` stops at the schema step), `defineStack(x, { strict: false })`, // and direct API callers. // -// Independent ViewItems (`viewKind` + `config`) are legal `views: []` entries -// (the loader registers them as-is) and are not flagged. +// ## Independent ViewItems are NOT legal `views: []` entries any more (#5320) +// +// This header used to say a ViewItem (`viewKind` + `config`) "is registered +// as-is by the loader" and skip it. That was a description of the runtime +// loop's UNDECLARED wider acceptance — the exact "runtime wider than schema" +// hole #5320 records — not of the declared contract, which was always +// container-only (`stack.zod.ts`, `z.array(ViewSchema)`). The 2026-08-12 fork +// ruling tightened the loop to the declared contract, so this rule's verdict +// aligns: a `viewKind`-bearing entry in `views:` is now an ERROR with the same +// wrap-it prescription the schema and the loop carry. Standalone views are +// authored through the metadata door; runtime-ASSEMBLED manifests carry +// non-container view artifacts under the machine-only `viewItems:` channel +// (`ui/assembled-views.zod.ts`), which this rule flags when hand-authored — +// the schema refuses it too, but `os lint` never parses, so the pre-parse +// door needs its own voice. export type ViewContainerSeverity = 'error' | 'warning'; @@ -80,13 +93,51 @@ export function validateViewContainers(stack: Record): ViewCont const out: ViewContainerFinding[] = []; if (!stack || typeof stack !== 'object') return out; + // [#5320] `viewItems:` is the machine-assembled channel, never an authoring + // surface — the stack schema types it `never`, and this pre-parse door says + // the same thing to `os lint` callers the parse never reaches. + const viewItems = (stack as AnyRec).viewItems; + if (viewItems != null && asEntries(viewItems).length > 0) { + out.push({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + where: 'viewItems', + path: 'viewItems', + message: + '`viewItems` is the machine-assembled channel for non-container view artifacts in ' + + 'runtime-assembled manifests (package export, environment artifacts) — it is not an ' + + 'authoring surface.', + hint: 'Author views as defineView containers in `views:`; author a standalone view through ' + + 'the metadata door (Studio / `PUT /api/v1/meta/view`), not in stack source.', + }); + } + for (const { key, value } of asEntries((stack as AnyRec).views)) { // Non-object entries are the schema step's problem, not this rule's. if (!value || typeof value !== 'object' || Array.isArray(value)) continue; const rec = value as AnyRec; - // Independent ViewItem (`viewKind` discriminator) — registered as-is. - if (rec.viewKind != null) continue; + // [#5320] Independent ViewItem (`viewKind` discriminator) in `views:` — + // refused by the schema AND (since the tighten) by the registration loop; + // this rule now reaches the same verdict pre-parse, prescription included. + if (rec.viewKind != null) { + const label = typeof rec.name === 'string' ? ` ("${rec.name}")` : ''; + out.push({ + severity: 'error', + rule: VIEW_CONTAINER_SHAPE, + where: `views${key}${label}`, + path: `views${key}`, + message: + 'A ViewItem record is not a view container: the stack `views:` collection carries ' + + 'containers only — `viewKind` belongs to a single VIEW, not to the container. The ' + + 'registration loop refuses this entry (#5320).', + hint: 'Wrap it in a defineView container: defineView({ list: { type, data, columns, ... }, ' + + 'listViews: { ... } }) — or author the standalone view through the metadata door ' + + '(Studio / `PUT /api/v1/meta/view`). Machine-assembled manifests carry it under ' + + '`viewItems:`.', + }); + continue; + } if (containerViewCount(rec) > 0) continue; diff --git a/packages/objectql/src/engine-assembled-views-roundtrip.test.ts b/packages/objectql/src/engine-assembled-views-roundtrip.test.ts new file mode 100644 index 0000000000..d6df1bc676 --- /dev/null +++ b/packages/objectql/src/engine-assembled-views-roundtrip.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5320/#8070] The export→import round trip survives END TO END through the + * declared channels — the fork's acceptance probe, inverted. + * + * The 2026-08-12 fork measured (by execution) that the platform's own package + * export emitted `views:` entries the stack vocabulary refuses — 2 of 3 entries + * in the minimal single-container case — and the round trip survived only + * through the registration loop's undeclared wider acceptance. With the ruling + * landed (B vocabulary + A's re-aggregation + the tighten), the SAME flows must + * survive through the declared channels instead: + * + * register → read back (what `GET /packages/:id/export` reads) → partition + * (`partitionAssembledViewArtifacts`, the assembler's half) → re-import + * through `registerApp` → every view artifact is registered again. + * + * This is the executed probe, not a grep: it runs the real registration loop + * on both ends and the real partition in the middle. + */ + +import { describe, it, expect } from 'vitest'; +import { partitionAssembledViewArtifacts } from '@objectstack/spec'; +import { ObjectQL } from './engine'; + +const PKG = 'com.acme.sales'; + +/** Minimal schema-valid container — the fork probe's fixture: default list + + * default form → dual-read registers 3 registry items. */ +function accountContainer() { + return { + name: 'account', + object: 'account', + list: { type: 'grid', data: { provider: 'object', object: 'account' }, columns: [{ field: 'name' }] }, + form: { type: 'simple', data: { provider: 'object', object: 'account' }, sections: [{ label: 'Info', fields: [{ field: 'name' }] }] }, + }; +} + +/** A tenant-authored standalone ViewItem — legal branch 1 of the `view` + * metadata vocabulary; has NO container to re-aggregate from. */ +const STANDALONE = { + name: 'account.hot', + object: 'account', + viewKind: 'list', + config: { type: 'grid', columns: [{ field: 'name' }] }, +}; + +/** What the export path's `clean()` does: strip provenance decorations. */ +function clean(item: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(item)) { + if (k.startsWith('_')) continue; + out[k] = v; + } + return out; +} + +function viewNames(engine: ObjectQL): string[] { + return (engine.registry.listItems('view') ?? []).filter(Boolean).map((v: any) => v.name).sort(); +} + +describe('export→import round trip through the declared channels (#5320/#8070)', () => { + it('the minimal single-container package survives end to end — all entries land', () => { + // ── source environment ── + const source = new ObjectQL(); + source.registerApp({ id: PKG, name: 'sales', views: [accountContainer()] }); + // Tenant authors a standalone ViewItem through the metadata door. + source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG); + + const sourceNames = viewNames(source); + expect(sourceNames).toEqual(['account', 'account.default', 'account.form', 'account.hot']); + + // ── export assembly (what assemblePackageManifest now does for views) ── + const stored = (source.registry.listItems('view') ?? []).filter(Boolean).map(clean); + const { views, viewItems, folded } = partitionAssembledViewArtifacts(stored); + + // Predicted directions, stated before running (fork discipline): + // the container travels; its 2 expanded items FOLD (the import side + // re-derives them); the standalone travels in viewItems. + expect(views.map((v) => v.name)).toEqual(['account']); + expect(folded.sort()).toEqual(['account.default', 'account.form']); + expect(viewItems.map((v) => v.name)).toEqual(['account.hot']); + + // ── import into a fresh environment ── + const target = new ObjectQL(); + target.registerApp({ id: PKG, name: 'sales', views, viewItems }); + + // END TO END: every view artifact of the source is registered in the target. + expect(viewNames(target)).toEqual(sourceNames); + }); + + it('a tenant-authored standalone ViewItem survives export→import alone', () => { + const source = new ObjectQL(); + source.registerApp({ id: PKG, name: 'sales' }); + source.registry.registerItem('view', { ...STANDALONE }, 'name' as any, PKG); + + const stored = (source.registry.listItems('view') ?? []).filter(Boolean).map(clean); + const { views, viewItems } = partitionAssembledViewArtifacts(stored); + expect(views).toEqual([]); + expect(viewItems.map((v) => v.name)).toEqual(['account.hot']); + + const target = new ObjectQL(); + target.registerApp({ id: PKG, name: 'sales', viewItems }); + expect(viewNames(target)).toEqual(['account.hot']); + const round = (target.registry.listItems('view') ?? []).find((v: any) => v?.name === 'account.hot'); + expect(round.viewKind).toBe('list'); + expect(round.config).toEqual(STANDALONE.config); + }); +}); diff --git a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts index dcacbb4b7e..91d0d3dace 100644 --- a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts +++ b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts @@ -202,13 +202,18 @@ describe('the expanded per-view identities a nested plugin now produces (#7163)' }); }); -describe('control — a NON-aggregated view is unchanged by this card (#7163)', () => { +describe('a NON-container `views:` entry is REFUSED by both seams (#5320)', () => { /** - * The fix is scoped by `isAggregatedViewContainer`, which is false for an - * already-independent `ViewItem` (it carries `viewKind`). Such a view must - * register exactly once, under its own name, through BOTH seams — no - * expansion, no new keys. This is what says the change is additive and only - * on the container shape. + * [#5320] REPLACED WHOLESALE, per the fork ruling's fixture disposition. + * The block this replaces was #7163's control: it PINNED that a standalone + * ViewItem in `views:` "registers as-is, through both seams" — i.e. it + * pinned exactly the undeclared runtime-wider acceptance this card removes + * (the stack vocabulary was always container-only, `stack.zod.ts:views`). + * Keeping it would have kept a green assertion over a deleted behaviour; + * loosening it would have judged nothing. It is now the rejection pin: + * both seams refuse the entry with the ADR-0112 envelope (`code` + `status`) + * and the wrap-it prescription, and the declared travel route for + * machine-assembled non-container artifacts is the `viewItems:` channel. */ const viewItem = { name: 'account.hot', @@ -217,12 +222,58 @@ describe('control — a NON-aggregated view is unchanged by this card (#7163)', config: { type: 'grid', columns: [{ field: 'name' }] }, }; - it('registers a standalone ViewItem identically from both seams, with no expansion', () => { - const direct = boot({ id: PKG, name: 'sales', views: [viewItem] }); - const nested = boot({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] }); + /** Envelope-first assertion: `code` AND `status`, never a bare toThrow. */ + function expectRefusal(manifest: unknown) { + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + boot(manifest); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown, 'registration must refuse, not accept').toBeTruthy(); + expect(thrown!.code).toBe('INVALID_METADATA'); + expect(thrown!.status).toBe(422); + expect(thrown!.message).toMatch(/containers only/i); + expect(thrown!.message).toContain('defineView'); + return thrown!; + } + + it('refuses a standalone ViewItem in `views:` from the manifest seam, envelope + prescription', () => { + const err = expectRefusal({ id: PKG, name: 'sales', views: [viewItem] }); + // The refusal names the entry, so the author fixes the right view. + expect(err.message).toContain('account.hot'); + }); + + it('refuses identically from the nested-plugin seam (one body, one verdict — #7163 kept)', () => { + expectRefusal({ id: PKG, name: 'sales', plugins: [{ name: 'p', views: [viewItem] }] }); + }); - expect(viewNames(nested)).toEqual(['account.hot']); - expect(viewNames(nested)).toEqual(viewNames(direct)); + it('refuses a flattened overlay in `views:` too (inline config, no container slot)', () => { + expectRefusal({ + id: PKG, + name: 'sales', + views: [{ name: 'account.default', object: 'account', viewKind: 'list', type: 'grid', columns: [{ field: 'name' }] }], + }); + }); + + it('accepts the SAME artifact through the declared `viewItems:` channel', () => { + const engine = boot({ id: PKG, name: 'sales', viewItems: [viewItem] }); + expect(viewNames(engine)).toEqual(['account.hot']); + const stored = viewItems(engine).find((v: any) => v.name === 'account.hot'); + expect(stored.viewKind).toBe('list'); + expect(stored.object).toBe('account'); + }); + + it('refuses an undeclared bag in `viewItems:` with the envelope (strict channel, no passthrough)', () => { + let thrown: (Error & { code?: string; status?: number }) | undefined; + try { + boot({ id: PKG, name: 'sales', viewItems: [{ name: 'account.junk', nope: 1 }] }); + } catch (e) { + thrown = e as Error & { code?: string; status?: number }; + } + expect(thrown, 'the viewItems channel must refuse an undeclared bag').toBeTruthy(); + expect(thrown!.code).toBe('INVALID_METADATA'); + expect(thrown!.status).toBe(422); }); it('leaves a container-free manifest with no view items at all', () => { diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index ec77b7f842..9b653fea49 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -146,7 +146,16 @@ import { expandSearchToFilter } from './search-filter.js'; import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; -import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; +import { + isAggregatedViewContainer, + expandViewContainer, + // [#5320] The assembled-manifest view channel: `views:` carries containers + // only (judged by the SAME classifier the producers partition with), and + // non-container view artifacts enter through the declared `viewItems:` key. + ASSEMBLED_VIEW_ITEMS_KEY, + AssembledViewArtifactSchema, + isViewContainerShaped, +} from '@objectstack/spec'; import { bindHooksToEngine } from './hook-binder.js'; import { validateRecord, normalizeMultiValueFields, coerceBooleanFields, ValidationError, buildFieldError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; @@ -3958,6 +3967,34 @@ export class ObjectQL implements IObjectQLEngine { continue; } const toRegister = item.name === itemName ? item : { ...item, name: itemName }; + // [#5320] The `views:` tighten — containers ONLY, the contract the + // stack schema has always declared (`stack.zod.ts`, + // `z.array(ViewSchema)`). This loop used to register EVERY entry + // as type `view` — the "runtime wider than schema" hole #5320 + // records: a ViewItem or flattened overlay that `defineStack` + // refuses registered here in silence. Judged by the SAME + // classifier the manifest assemblers partition with + // (`isViewContainerShaped`), so a produced manifest cannot carry + // a `views:` entry this seam refuses. Deliberately a SHAPE-CLASS + // judgement, not a full `ViewSchema.parse`: which class an entry + // belongs to is this seam's contract; whether a container's + // internals are well-formed stays the authoring/publish doors' + // job (defineStack, `os validate`, the metadata door). + if (key === 'views' && !isViewContainerShaped(toRegister)) { + const err: Error & { code?: string; status?: number } = new Error( + `Invalid \`views:\` entry '${itemName}' from ${sourceLabel} '${ownerId}': the stack ` + + '`views:` collection carries view CONTAINERS only. `viewKind`/`config`/inline view ' + + 'config belong to a single VIEW, not to the container — wrap it: ' + + '`defineView({ list: { type, data, columns, … } })`, or name it — ' + + '`defineView({ listViews: { my_view: { … } } })`. A machine-assembled manifest ' + + `(package export, environment artifact) carries non-container view artifacts under ` + + `\`${ASSEMBLED_VIEW_ITEMS_KEY}:\` instead — re-export the package with a runtime that ` + + 'writes that channel.', + ); + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, ownerId); // "Object has-many View" (ADR-0017): a `defineView` document // aggregates an object's views. Register the container under the @@ -3975,6 +4012,46 @@ export class ObjectQL implements IObjectQLEngine { } } } + + // [#5320] The `viewItems:` channel — the declared entry for the + // NON-container view artifacts a runtime-ASSEMBLED manifest carries + // (tenant-authored standalone ViewItems, flattened overlays, expanded + // items a travelling container cannot re-derive). Written by the manifest + // assemblers (`partitionAssembledViewArtifacts` — package export, the + // artifact factories); refused at the authoring door (`defineStack` types + // the key `never`), so only machine-assembled manifests legitimately + // reach here carrying it. Strictly schema'd: each entry is judged by + // `AssembledViewArtifactSchema` and the PARSED body is what registers, so + // an undeclared bag neither passes nor rides through — declared = + // enforced, in both directions. + const assembledItems = (source as any)?.[ASSEMBLED_VIEW_ITEMS_KEY]; + if (Array.isArray(assembledItems) && assembledItems.length > 0) { + this.logger.debug(`Registering ${ASSEMBLED_VIEW_ITEMS_KEY} from ${sourceLabel}`, { id: ownerId, count: assembledItems.length }); + for (const item of assembledItems) { + const parsed = AssembledViewArtifactSchema.safeParse(item); + if (!parsed.success) { + const itemName = resolveMetadataItemName('views', item) ?? '(unnamed)'; + const err: Error & { code?: string; status?: number } = new Error( + `Invalid \`${ASSEMBLED_VIEW_ITEMS_KEY}:\` entry '${itemName}' from ${sourceLabel} '${ownerId}': ` + + 'the assembled-manifest channel carries non-container view artifacts only — a ViewItem ' + + 'record (`viewKind` + `config`) or a flattened list/form overlay ' + + '(`AssembledViewArtifactSchema`, @objectstack/spec). A view CONTAINER travels in `views:`. ' + + `First issue: ${parsed.error.issues[0]?.message ?? 'no issue detail'}`, + ); + err.code = 'INVALID_METADATA'; + err.status = 422; + throw err; + } + const body = parsed.data as Record; + const itemName = resolveMetadataItemName('views', body); + if (!itemName) { + this.logger.warn('Skipping viewItems entry without a derivable name', { id: ownerId }); + continue; + } + const toRegister = body.name === itemName ? body : { ...body, name: itemName }; + this._registry.registerItem('view', toRegister, 'name' as any, ownerId); + } + } } /** diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 7451a141f5..e508171893 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -11,6 +11,13 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared'; +// [#5320] A's mechanical half of the fork ruling: on export, view artifacts +// are PARTITIONED — containers travel in `views:`, expanded items a travelling +// container re-derives exactly are folded away (the import side's own +// expansion recreates them), and everything else (tenant-authored standalone +// ViewItems, flattened overlays, edited expanded items) travels under the +// declared `viewItems:` channel the registration loop ingests. +import { ASSEMBLED_VIEW_ITEMS_KEY, partitionAssembledViewArtifacts } from '@objectstack/spec/ui'; import { shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, } from '@objectstack/core'; @@ -953,6 +960,20 @@ context: HttpProtocolContext, continue; } if (items.length === 0) continue; + // [#5320] `views` is partitioned rather than dumped: the registry's + // ADR-0017 dual-read returns the container AND its expanded per-view + // items, and the stack `views:` vocabulary (container-only) refuses the + // expanded ones. Containers go to `views:`; expanded items the + // container re-derives exactly are dropped (`folded` — the importing + // loop's own expansion recreates them); the rest — standalone + // ViewItems, overlays, edited expansions — go to `viewItems:`. + if (plural === 'views') { + const { views, viewItems } = partitionAssembledViewArtifacts(items.map(clean)); + if (views.length > 0) manifest[plural] = views; + if (viewItems.length > 0) manifest[ASSEMBLED_VIEW_ITEMS_KEY] = viewItems; + total += views.length + viewItems.length; + continue; + } manifest[plural] = items.map(clean); total += items.length; }