From d77c39439c9dda9f6616a3328135a4a8f6f598db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 12:45:46 +0000 Subject: [PATCH] fix(objectql): expand an aggregated `views` container from the nested-plugin seam too (#7163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0017 makes the loader dual-read: an aggregated `defineView` container is registered under the bare `` key for back-compatible reads AND expanded into independent `ViewItem`s under `.`. Only the expanded items carry `viewKind`, and `getViewsByObject()` filters on exactly that — so the expanded layer, not the container, is what `GET /meta/view?object=`, the view switcher and Studio's package attribution read. `engine.ts` reaches the registration seam from two entry points and only the manifest one expanded. One container measured through each: via manifest → ['account', 'account.all_accounts', 'account.form'] via nested plugin → ['account'] No refusal, no diagnostic — a package shipping views through `manifest.plugins[]` had no views as far as every reader of the expanded layer was concerned. The direction was measured, not assumed. ADR-0017 states the dual-read as a property of "the loader" at load time rather than of one entry point; the other loader (`MetadataPlugin`'s artifact/HMR path) expands too, which is why the shared implementation was pushed into `@objectstack/spec` "so the two loaders cannot drift"; every authored stack in the tree ships `views` at manifest top level, so removing the manifest expansion would take the switcher from all of them; and no in-tree package ships `views` through a nested plugin, so the seam that gains behaviour breaks nobody. One direction is load-bearing, the other is not. So the copies are gone rather than reconciled: both seams now run one `registerMetadataCollections()`. #7049 hoisted the shared `METADATA_ARRAY_KEYS` and recorded that the bodies still differed in a per-key `debug` line, this expansion, and a warn-on-nameless-item. Sharing the list made the seams' collection SET unanswerable-differently; sharing the body does the same for what they DO with a collection both see — a nested plugin now also emits the nameless-item warning it used to swallow. Pins: manifest-vs-nested parity for an aggregated container, the expanded per-view identities (name, `viewKind`, object, isDefault, order, config, ADR-0010 provenance stamped to the parent package), the ADR-0017 dual-read back-compat half, and a control that a non-aggregated `ViewItem` still registers identically through both seams with no expansion. `engine-nested-plugin-collections.test.ts`'s `views` exclusion row — the only one that was ever a behaviour difference rather than a retired kind — is removed with the divergence, and `views` rejoins its cross-seam property test. Refs: #7163, #7049, #6242, #5870, ADR-0017, ADR-0010. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BPVc5WY75PkeXoA5NFiEEk --- .../nested-plugin-view-container-expansion.md | 55 +++++ .../engine-nested-plugin-collections.test.ts | 24 +- ...ngine-nested-plugin-view-expansion.test.ts | 231 ++++++++++++++++++ packages/objectql/src/engine.ts | 143 +++++++---- 4 files changed, 394 insertions(+), 59 deletions(-) create mode 100644 .changeset/nested-plugin-view-container-expansion.md create mode 100644 packages/objectql/src/engine-nested-plugin-view-expansion.test.ts diff --git a/.changeset/nested-plugin-view-container-expansion.md b/.changeset/nested-plugin-view-container-expansion.md new file mode 100644 index 0000000000..1373c611c5 --- /dev/null +++ b/.changeset/nested-plugin-view-container-expansion.md @@ -0,0 +1,55 @@ +--- +"@objectstack/objectql": patch +--- + +fix(objectql): a nested plugin expands an aggregated `views` container — its per-view items reached no registry + +**This changes boot behaviour for packages that ship views through +`manifest.plugins[]`.** "Object has-many View" (ADR-0017 §2, §3.2) makes the +loader **dual-read**: an aggregated `defineView` container is registered under +the bare `` key for back-compatible reads AND expanded into independent +`ViewItem`s under `.`. Only the expanded items carry +`viewKind`, and `getViewsByObject()` filters on exactly that — so the expanded +layer, not the container, is what `GET /meta/view?object=`, the runtime view +switcher and Studio's package attribution actually read. + +`engine.ts` reaches the registration seam from two entry points, and only the +manifest one expanded. One container measured through each: + +``` +via manifest → ['account', 'account.all_accounts', 'account.form'] +via nested plugin → ['account'] +``` + +No refusal and no diagnostic: a package whose views arrived through a nested +plugin registered the container and nothing else, so every reader of the +expanded layer saw an object with no views at all. After this change both seams +answer the same, and those packages' view switchers begin working. Anything that +has been compensating for the silence — a duplicate `views:` hoisted to the +top-level manifest — now finds the views already registered. + +The direction was measured rather than assumed, because the divergence had two +coherent readings. ADR-0017 states the dual-read as a property of "the loader" +at load time, not of one entry point; the OTHER loader agrees with the manifest +seam (`MetadataPlugin`'s artifact/HMR path expands too, which is why the shared +implementation lives in `@objectstack/spec` — "so the two loaders cannot +drift"); every authored stack in the tree ships `views` at manifest top level, +so removing the manifest seam's expansion would take the switcher away from all +of them; and no in-tree package ships `views` through a nested plugin, so the +seam that GAINS behaviour here breaks nobody. One direction is load-bearing for +real consumers and the other is not. + +So, as with #7049, the copies are gone rather than reconciled: both seams now +run one `registerMetadataCollections()` body. #7049 hoisted the shared +`METADATA_ARRAY_KEYS` and measured the loop bodies on the way past, recording +that they still differed in a per-key `debug` line, this view expansion, and a +warn-on-nameless-item — sharing the list made "which collections does a seam +see?" unanswerable-differently while leaving "what does a seam DO with a +collection both see?" answered in two places. Both remaining differences had the +same structure, a body copied then improved on one side only, so the body is +shared too: a nested plugin now also emits the skipping-a-nameless-item warning +it used to swallow. `engine-nested-plugin-collections.test.ts`'s `views` +exclusion row — the only one that was ever a behaviour difference rather than a +retired kind — is removed with the divergence. + +Refs: #7163, #7049, #6242, #5870, ADR-0017, ADR-0010. diff --git a/packages/objectql/src/engine-nested-plugin-collections.test.ts b/packages/objectql/src/engine-nested-plugin-collections.test.ts index 1db531dd2a..edec624773 100644 --- a/packages/objectql/src/engine-nested-plugin-collections.test.ts +++ b/packages/objectql/src/engine-nested-plugin-collections.test.ts @@ -156,14 +156,14 @@ describe('the two registration seams enumerate ONE collection list (#7049)', () * make the test pass — see each entry. */ const NOT_COMPARABLE: ReadonlyArray = [ - // `views` has no top-level `name` and the manifest seam additionally expands - // an aggregated container into per-view items (ADR-0017); that expansion is - // a LOOP-BODY difference between the seams, not an enumeration difference, - // and closing it changes what a nested plugin serves. Measured while closing - // this card and filed as #7163 rather than folded in: one container - // registers `['account', 'account.all_accounts', 'account.form']` from a - // manifest and `['account']` from a nested plugin. - ['views', 'manifest seam additionally expands aggregated containers (ADR-0017) — #7163'], + // `views` used to sit here: the manifest seam expanded an aggregated + // container into per-view items (ADR-0017) and the nested seam did not — a + // LOOP-BODY difference rather than an enumeration one, so this card filed it + // as #7163 instead of folding it in. #7163 closed it by sharing the body + // (`registerMetadataCollections()`), so `views` is comparable now and is + // back in `CANDIDATES` below; the aggregated-container half of its parity is + // pinned in `engine-nested-plugin-view-expansion.test.ts`. + // // Retired kinds the loop still iterates; the schema rejects the keys long // before either seam runs, so a fixture cannot exercise them (the gate // carries them as an `extra` waiver row for the same reason). @@ -184,7 +184,7 @@ describe('the two registration seams enumerate ONE collection list (#7049)', () * behaviour and not the same literal the implementation reads. */ const CANDIDATES = [ - 'actions', 'pages', 'dashboards', 'reports', 'datasets', 'themes', + 'actions', 'views', 'pages', 'dashboards', 'reports', 'datasets', 'themes', 'flows', 'webhooks', 'jobs', 'permissions', 'capabilities', 'sharingRules', 'agents', 'tools', 'skills', 'apis', @@ -215,8 +215,12 @@ describe('the two registration seams enumerate ONE collection list (#7049)', () it('records why each excluded collection is not comparable, rather than dropping it silently', () => { for (const [, why] of NOT_COMPARABLE) expect(why.length).toBeGreaterThan(0); + // `views` left this list in #7163 — the only entry that was ever excluded + // for a BEHAVIOUR difference rather than a retired-kind one. Every survivor + // is a kind the schema rejects before either seam runs. expect(NOT_COMPARABLE.map(([k]) => k)).toEqual([ - 'views', 'workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines', + 'workflows', 'approvals', 'roles', 'profiles', 'policies', 'ragPipelines', ]); + expect(NOT_COMPARABLE.map(([k]) => k)).not.toContain('views'); }); }); diff --git a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts new file mode 100644 index 0000000000..5b9b87fbd7 --- /dev/null +++ b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7163] A nested plugin EXPANDS an aggregated view container, exactly as a + * manifest does. + * + * ## What was broken + * + * "Object has-many View" (ADR-0017 §2, §3.2) makes the loader **dual-read**: an + * aggregated `defineView` container is registered under the bare `` key + * for back-compatible reads AND expanded into independent `ViewItem`s under + * `.`. Only the expanded items carry `viewKind`, and + * `getViewsByObject()` filters on exactly that — so the expanded layer, not the + * container, is what `GET /meta/view?object=`, the runtime view switcher and + * Studio's package attribution actually read. + * + * `engine.ts` reaches the registration seam from two entry points. #7049 hoisted + * the shared `METADATA_ARRAY_KEYS` so both seams ENUMERATE `views`, but only the + * manifest seam's loop body expanded: + * + * via manifest → ['account', 'account.all_accounts', 'account.form'] + * via nested plugin → ['account'] + * + * No refusal and no diagnostic — a package shipping its views through + * `manifest.plugins[]` simply had no views as far as every reader of the + * expanded layer was concerned. + * + * ## Why the nested seam gained the expansion, rather than the manifest seam + * ## losing it + * + * The divergence had two coherent readings and the card deliberately picked + * neither. Measured on `main` before choosing: + * + * - ADR-0017 §2 states the dual-read as a property of "the loader" at load + * time, not of one entry point, and §3.2 spells out that it registers BOTH. + * - The OTHER loader agrees with the manifest seam: `MetadataPlugin`'s artifact + * /HMR path expands too (`packages/metadata/src/plugin.ts`), which is why + * the shared implementation was pushed down into `@objectstack/spec` in the + * first place — "so the two loaders cannot drift" (`ui/view.zod.ts`). + * - Every authored stack in the tree ships `views` at manifest TOP level + * (~51 files: `examples/app-crm`, `app-todo`, `app-showcase`, + * `packages/qa/downstream-contract`, …). Removing the manifest seam's + * expansion would take the view switcher away from all of them. + * - No in-tree package ships `views` through a nested plugin — swept across + * `examples/`, `apps/` and `packages/`, zero hits. So the seam that gains + * behaviour here breaks nobody in-tree, while the seam that would lose it + * breaks everybody. + * + * One direction is load-bearing for real consumers and the other is not, so the + * nested seam catches up — the same direction #7049 took for the enumeration. + * + * Refs: #7163, #7049 (the enumeration half + the exclusion row this retires), + * ADR-0017 (Object has-many View), ADR-0010 (provenance envelope). + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine'; + +const PKG = 'com.acme.sales'; + +/** + * The card's measured fixture: one aggregated container for `account`, with a + * named list view and a default form. Shaped like `examples/app-crm`'s view + * modules — the container carries no top-level `name`, so its registry key is + * resolved from `list.data.object` (ADR-0017 §3.1: `ViewSchema` has no `name`). + * + * The default `list` deliberately RESTATES `listViews.all_accounts` verbatim — + * the common "default == the named view" authoring pattern the expander + * collapses by structural signature. That is what makes the expansion exactly + * the card's measured `['account', 'account.all_accounts', 'account.form']` + * rather than carrying a separate `account.default`. + */ +function accountContainer() { + const allAccounts = { + label: 'All Accounts', + type: 'grid', + data: { provider: 'object', object: 'account' }, + columns: [{ field: 'name' }], + }; + return { + list: { ...allAccounts }, + listViews: { all_accounts: { ...allAccounts } }, + form: { + type: 'simple', + data: { provider: 'object', object: 'account' }, + sections: [{ label: 'Info', fields: [{ field: 'name' }] }], + }, + }; +} + +/** The same container declared directly on the manifest — the reference path. */ +function viaManifest() { + return { id: PKG, name: 'sales', views: [accountContainer()] }; +} + +/** …and arriving ONLY through a nested plugin (`manifest.plugins[]`). */ +function viaNestedPlugin() { + return { id: PKG, name: 'sales', plugins: [{ name: 'sales-nested', views: [accountContainer()] }] }; +} + +function viewItems(engine: ObjectQL): any[] { + return (engine.registry.listItems('view') ?? []) + .map((i: any) => i?.content ?? i) + .filter(Boolean); +} + +function viewNames(engine: ObjectQL): string[] { + return viewItems(engine).map((v: any) => v.name).sort(); +} + +function boot(manifest: unknown): ObjectQL { + const engine = new ObjectQL(); + engine.registerApp(manifest as any); + return engine; +} + +describe('aggregated view container — the two seams register the SAME thing (#7163)', () => { + it('expands a nested plugin\'s container into per-view items', () => { + // Before #7163 this was exactly `['account']` — the container alone. + expect(viewNames(boot(viaNestedPlugin()))).toEqual([ + 'account', + 'account.all_accounts', + 'account.form', + ]); + }); + + it('registers identically whether the container arrives via manifest or nested plugin', () => { + // The parity pin — the card's whole point. Stated as manifest-vs-nested + // equality rather than against a literal, so a change to the expansion + // rules moves BOTH seams or fails here. + expect(viewNames(boot(viaNestedPlugin()))).toEqual(viewNames(boot(viaManifest()))); + }); + + it('keeps the bare container registered alongside the expansion (ADR-0017 dual-read)', () => { + // Back-compat half: expanding must not replace the container. Both seams. + for (const manifest of [viaManifest(), viaNestedPlugin()]) { + const names = viewNames(boot(manifest)); + expect(names).toContain('account'); + expect(names.filter((n) => n.startsWith('account.'))).not.toHaveLength(0); + } + }); +}); + +describe('the expanded per-view identities a nested plugin now produces (#7163)', () => { + const items = viewItems(boot(viaNestedPlugin())); + const byName = Object.fromEntries(items.map((v: any) => [v.name, v])); + + it('gives every expanded item the `viewKind` `getViewsByObject()` filters on', () => { + // This is the property the whole card turns on: the container carries NO + // `viewKind`, so a registry holding only the container answers empty to + // `getViewsByObject()` / `GET /meta/view?object=` — silently. + expect(byName['account'].viewKind).toBeUndefined(); + expect(byName['account.all_accounts'].viewKind).toBe('list'); + expect(byName['account.form'].viewKind).toBe('form'); + + const readable = items.filter((v: any) => v.viewKind && v.object === 'account'); + expect(readable.map((v: any) => v.name).sort()).toEqual([ + 'account.all_accounts', + 'account.form', + ]); + }); + + it('binds each expanded item to its object and stamps scope=package', () => { + for (const name of ['account.all_accounts', 'account.form']) { + expect(byName[name].object).toBe('account'); + expect(byName[name].scope).toBe('package'); + } + }); + + it('carries the container\'s config through to the expanded item', () => { + expect(byName['account.all_accounts'].config.type).toBe('grid'); + expect(byName['account.all_accounts'].label).toBe('All Accounts'); + expect(byName['account.all_accounts'].config.columns).toEqual([{ field: 'name' }]); + expect(byName['account.form'].config.sections[0].label).toBe('Info'); + // The named entry absorbed the structurally identical default `list`, so it + // is the declared default of its family. + expect(byName['account.all_accounts'].isDefault).toBe(true); + }); + + it('stamps ADR-0010 provenance on the expanded items, owned by the PARENT package', () => { + // A nested plugin contributes under its parent's ownership — the parent + // already claimed the namespace (same rule #7049 pinned for the four + // collections). The expansion must not bypass that stamp. + for (const name of ['account', 'account.all_accounts', 'account.form']) { + expect(byName[name]._packageId, `'${name}' reached the registry unstamped`).toBe(PKG); + expect(byName[name]._provenance).toBe('package'); + } + }); + + it('produces the same expanded identities the manifest seam produces', () => { + const fromManifest = Object.fromEntries( + viewItems(boot(viaManifest())).map((v: any) => [v.name, v]), + ); + for (const name of ['account.all_accounts', 'account.form']) { + expect(byName[name].viewKind).toBe(fromManifest[name].viewKind); + expect(byName[name].object).toBe(fromManifest[name].object); + expect(byName[name].isDefault).toBe(fromManifest[name].isDefault); + expect(byName[name].order).toBe(fromManifest[name].order); + expect(byName[name].config).toEqual(fromManifest[name].config); + } + }); +}); + +describe('control — a NON-aggregated view is unchanged by this card (#7163)', () => { + /** + * 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. + */ + const viewItem = { + name: 'account.hot', + object: 'account', + viewKind: 'list', + 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] }] }); + + expect(viewNames(nested)).toEqual(['account.hot']); + expect(viewNames(nested)).toEqual(viewNames(direct)); + }); + + it('leaves a container-free manifest with no view items at all', () => { + expect(viewNames(boot({ id: PKG, name: 'sales' }))).toEqual([]); + expect(viewNames(boot({ id: PKG, name: 'sales', plugins: [{ name: 'p' }] }))).toEqual([]); + }); +}); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 76ca759c79..980716a574 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1240,7 +1240,8 @@ export type EngineMiddleware = ( /** * The stack collections the engine decomposes into individual registry items — - * ONE list, read by BOTH registration seams (the manifest seam in + * ONE list, read by the ONE body both registration seams run + * (`registerMetadataCollections()`, called from the manifest seam in * `registerApp()` and the nested-plugin seam in `registerPlugin()`). * * ## Why this is one constant and not two lists (#7049) @@ -1255,13 +1256,23 @@ export type EngineMiddleware = ( * of the two lists, which is how the remaining four survived it. * * The two loops were measured against each other before this was merged. They - * differ in four ways — which object they read (`manifest` vs `plugin`), which + * differed in four ways — which object they read (`manifest` vs `plugin`), which * package id they stamp (both resolve to the SAME parent package: a nested * plugin contributes under its parent's ownership), a per-key `debug` line, and * the manifest seam's aggregated-view expansion plus its warn-on-nameless-item. - * Every one of those lives in the loop BODY. Not one of them is a reason for - * the two seams to enumerate different collections, so the enumeration is - * shared and the divergence is now unrepresentable rather than merely unnoticed. + * Every one of those lived in the loop BODY. Not one of them was a reason for + * the two seams to enumerate different collections, so the enumeration was + * shared and that divergence is now unrepresentable rather than merely unnoticed. + * + * ## …and why the BODY is shared too (#7163) + * + * The last two entries on that list were not stylistic. The aggregated-view + * expansion is what makes ADR-0017's dual-read work, so the seam that lacked it + * under-registered every nested plugin's views silently — see + * `registerMetadataCollections()`, which is now the single body both seams run, + * for the measurement. Sharing the list made the seams' collection SET + * unanswerable-differently; sharing the body does the same for what they DO + * with a collection they both see. * * `check:stack-collection-maps` pins this list against * `ObjectStackDefinitionSchema` in both directions (#6242); it used to pin the @@ -3435,40 +3446,10 @@ export class ObjectQL implements IObjectQLEngine { }); } - // 5. Register all other metadata types generically. - // The collection list is `METADATA_ARRAY_KEYS` (module scope) and is - // SHARED with the nested-plugin seam in `registerPlugin()` — see the - // constant's docblock for why the two seams may not enumerate - // different collections (#7049). - for (const key of METADATA_ARRAY_KEYS) { - const items = (manifest as any)[key]; - if (Array.isArray(items) && items.length > 0) { - this.logger.debug(`Registering ${key} from manifest`, { id, count: items.length }); - for (const item of items) { - const itemName = resolveMetadataItemName(key, item); - if (itemName) { - const toRegister = item.name === itemName ? item : { ...item, name: itemName }; - this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, id); - // "Object has-many View" (ADR-0017): a `defineView` document - // aggregates an object's views. Register the container under - // the bare key (above, back-compat) AND expand it - // into independent ViewItems registered under ., - // so `getViewsByObject()` / `GET /meta/view?object=` surface - // the per-view `package` layer the switcher + Studio consume. - if (key === 'views' && isAggregatedViewContainer(toRegister)) { - for (const vi of expandViewContainer(itemName, toRegister)) { - for (const w of vi._diagnostics?.warnings ?? []) { - this.logger.warn(`View expansion warning for '${vi.name}': ${w.message}`, { from: id }); - } - this._registry.registerItem('view', vi, 'name' as any, id); - } - } - } else { - this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id }); - } - } - } - } + // 5. Register all other metadata types generically — the SAME seam the + // nested-plugin path uses (`registerMetadataCollections`), so the two + // entry points cannot answer differently for one collection (#7163). + this.registerMetadataCollections(manifest, id, 'manifest'); // 6. Register seed data as metadata (keyed by target object name) const seedData = (manifest as any).data; @@ -3582,21 +3563,85 @@ export class ObjectQL implements IObjectQLEngine { } } - // Register metadata arrays (actions, views, triggers, etc.) from the SAME - // list the manifest seam uses — same stamping seam, one level down: a + // Register metadata arrays (actions, views, triggers, etc.) through the + // SAME seam the manifest path uses — same stamping seam, one level down: a // nested plugin's declarations must carry the parent package's ADR-0010 // provenance too, or the declared-≠-enforced hole reopens for packages // that ship a collection from a nested plugin (`capabilities` #5870; // `jobs` / `emailTemplates` / `tools` / `skills` #7049, which is why the - // list is no longer copied here to be patched one name at a time). + // list is no longer copied here to be patched one name at a time; the + // aggregated-view expansion, #7163, for why the loop BODY is no longer + // copied here either). + this.registerMetadataCollections(plugin, ownerId, 'nested plugin'); + } + + /** + * Register one source's stack collections into the registry — the SINGLE + * body both registration seams run. + * + * ## Why this is one method and not two loops (#7163) + * + * It used to be two, and #7049 only got halfway: that card hoisted the + * ENUMERATION (`METADATA_ARRAY_KEYS`) out of the two seams after they had + * drifted four collections apart, and measured the loop bodies against each + * other on the way past — recording that they still differed in a per-key + * `debug` line, the manifest seam's aggregated-view expansion, and its + * warn-on-nameless-item. Sharing the list made "which collections does a + * seam see?" unanswerable-differently; it left "what does a seam DO with a + * collection both see?" still answered in two places. + * + * `views` is where that cost was measurable. "Object has-many View" + * (ADR-0017 §2, §3.2) makes the loader **dual-read**: an aggregated + * `defineView` container is registered under the bare `` key for + * back-compatible reads AND expanded into independent `ViewItem`s under + * `.`. Only the expanded items carry `viewKind`, and + * `getViewsByObject()` (`metadata-manager.ts`) filters on exactly that — so + * a container that is registered but never expanded is invisible to it, and + * to `GET /meta/view?object=` and the view switcher above it. The manifest + * seam expanded; the nested-plugin seam did not, so one container registered + * `['account', 'account.all_accounts', 'account.form']` through the manifest + * and `['account']` through a nested plugin. No refusal, no diagnostic: a + * package shipping its views through `manifest.plugins[]` simply had no + * views, as far as every reader of the expanded layer was concerned. + * + * Both differences had the same structure — a loop body copied, then + * improved on one side only — so the body is shared rather than reconciled, + * for the reason #7049 gave for the list: a divergence that cannot be + * written down cannot be re-introduced by the next hand patch. What legally + * varies between the seams is passed in: which object is read, which package + * id is stamped (both resolve to the SAME owning package — a nested plugin + * contributes under its parent's ownership), and the label the `debug` line + * names the source with. + * + * @param source The manifest or nested-plugin config to read collections from. + * @param ownerId The owning package id — stamped as ADR-0010 provenance. + * @param sourceLabel Human-readable source name for the `debug` line. + */ + private registerMetadataCollections(source: any, ownerId: string, sourceLabel: string) { for (const key of METADATA_ARRAY_KEYS) { - const items = (plugin as any)[key]; - if (Array.isArray(items) && items.length > 0) { - for (const item of items) { - const itemName = resolveMetadataItemName(key, item); - if (itemName) { - const toRegister = item.name === itemName ? item : { ...item, name: itemName }; - this._registry.registerItem(pluralToSingular(key), toRegister, 'name' as any, ownerId); + const items = (source as any)?.[key]; + if (!Array.isArray(items) || items.length === 0) continue; + this.logger.debug(`Registering ${key} from ${sourceLabel}`, { id: ownerId, count: items.length }); + for (const item of items) { + const itemName = resolveMetadataItemName(key, item); + if (!itemName) { + this.logger.warn(`Skipping ${pluralToSingular(key)} without a derivable name`, { id: ownerId }); + continue; + } + const toRegister = item.name === itemName ? item : { ...item, name: itemName }; + 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 + // bare key (above, back-compat) AND expand it into + // independent ViewItems registered under ., so + // `getViewsByObject()` / `GET /meta/view?object=` surface the + // per-view `package` layer the switcher + Studio consume. + if (key === 'views' && isAggregatedViewContainer(toRegister)) { + for (const vi of expandViewContainer(itemName, toRegister)) { + for (const w of vi._diagnostics?.warnings ?? []) { + this.logger.warn(`View expansion warning for '${vi.name}': ${w.message}`, { from: ownerId }); + } + this._registry.registerItem('view', vi, 'name' as any, ownerId); } } }