From 2019fdc8d02f53a8e3eabbfcea940c6c822a7dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:31:11 +0000 Subject: [PATCH] test(objectql): measure the ADR-0130 cross-package matrix over nine object-naming item classes, navigationContributions group semantics and analytics binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends #14122 §4's four measured cross-package rules to the nine object-naming item classes a real product split needs, plus the `navigationContributions[].group` semantics and the analytics binding. Same method as §4: two packages installed as same-artifact co-owners of one namespace through the real load path (`manifest.register()` on a booted kernel, ADR-0130 D1 / #14354), one pin per item class, accept and refuse both recorded. Measurement only — no runtime behaviour changes. Each row reads BOTH doors, because §4's four verdicts do not all come from the same one: the authoring gate (`defineStack`'s `validateCrossReferences`, which sees one stack and so cannot tell a co-owner's object from a typo) and the install gate (`registerApp` → `installPackage`, which validates no object reference on any of these classes). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- ...egistry-cross-package-item-classes.test.ts | 567 ++++++++++++++++++ ...y-nav-contribution-group-semantics.test.ts | 328 ++++++++++ 2 files changed, 895 insertions(+) create mode 100644 packages/objectql/src/registry-cross-package-item-classes.test.ts create mode 100644 packages/objectql/src/registry-nav-contribution-group-semantics.test.ts diff --git a/packages/objectql/src/registry-cross-package-item-classes.test.ts b/packages/objectql/src/registry-cross-package-item-classes.test.ts new file mode 100644 index 0000000000..511c920723 --- /dev/null +++ b/packages/objectql/src/registry-cross-package-item-classes.test.ts @@ -0,0 +1,567 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 — the cross-package ACCEPT/REFUSE matrix, extended from the four + * rules #14122 §4 measured to the nine object-naming item classes a real + * product split needs, plus the analytics binding (#14454 items 1 and 4). + * + * ## What one row of this matrix asks + * + * Two packages, ONE artifact, ONE namespace — the ADR-0130 D1 co-ownership + * shape now in `main` (#14354). Package A owns `crm_account`. Package B names + * `crm_account` from an item of class X. Does the platform accept that, and if + * it refuses, WHERE and with what? + * + * ## Why every row measures TWO gates, and why one gate is not enough + * + * #14122 §4 reported four verdicts without saying which door produced them, and + * the four are not from the same door. Measured here: + * + * • The **authoring gate** — `defineStack` (`@objectstack/spec`), which runs + * `validateCrossReferences` against the ONE stack in front of it. A package + * cannot see its co-owner's objects at authoring time, so for the classes + * this gate covers, "B names A's object" is indistinguishable from a typo + * and is refused. This is where §4's two REFUSE rows came from — their + * message text is `stack.zod.ts`'s, verbatim. + * • The **install gate** — `registerApp` → `SchemaRegistry.installPackage`, + * driven here through the REAL load path (`manifest.register()` on a booted + * kernel) exactly as `registry-artifact-co-ownership.test.ts` does. + * + * Reading only the install gate would report a uniform "ACCEPTED" for all nine + * classes and be useless: it is TRUE (the loader validates no object reference + * on any of these classes) but it answers a question nobody split a product + * over. Reading only the authoring gate would miss that the runtime enforces + * nothing, which is the other half a module author needs. So each row records + * both, and the matrix's verdict is the EFFECTIVE one — refused at authoring + * means the module cannot be written, whatever the registry would have done. + * + * ## ⚠️ The authoring gate throws a BARE `Error` — there is no ADR-0112 envelope + * + * `defineStack` aggregates its cross-reference errors into `new Error(...)`. + * There is no `code` and no `status` to assert, so these rows assert the + * message — which IS the contract here, since the message is the only thing + * that distinguishes one refusal from another — and then assert the ABSENCE of + * the envelope explicitly, in one place, so the gap is pinned rather than + * merely unmentioned. Same shape of gap as #14367 (`registerObject`'s bare + * `Error`), one door over. + * + * ⛔ If `ENVELOPE ABSENCE` below goes red, an envelope has ARRIVED. That is an + * improvement: update this pin and the #14122 §4 matrix row. Do not delete the + * assertion to make it green. + * + * ## This file measures. It does not prescribe. + * + * #14454 is a measurement card: no runtime behaviour changes here. Rows that + * read "ACCEPTED (unenforced)" record what the platform does today — a dangling + * cross-package reference on those classes reaches no check at either door and + * fails, if at all, at first use. Whether any of them SHOULD be enforced is a + * spec decision the readings exist to inform (the PR body carries the flagged + * ones). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { defineStack } from '@objectstack/spec'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; + +/** The error shape every rejection assertion below reads. */ +type Envelope = Error & { code?: string; status?: number }; + +const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService('objectql'); + +/** + * The object package B reaches across the boundary for, and the ONLY object + * either gate could resolve `crm_account` to. + */ +const CROSS = 'crm_account'; + +/** Package A — owns `crm_account` and the dataset item 4's widgets bind. */ +const packageA = () => ({ + id: 'com.acme.crm', + name: 'acme_crm', + version: '1.0.0', + type: 'app', + namespace: 'crm', + objects: [ + { name: CROSS, label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } }, + ], + // Item 4's target: an ADR-0021 semantic-layer dataset owned by A. B's + // dashboard widget and report bind THIS by name. + datasets: [ + { + name: 'crm_account_ds', + label: 'Accounts', + object: CROSS, + dimensions: [{ name: 'by_name', field: 'name' }], + measures: [{ name: 'cnt', aggregate: 'count' }], + }, + ], +}); + +/** + * Package B — a co-owning `module` under the SAME namespace, owning its own + * object, plus whichever item class this row is measuring. + */ +const packageB = (item: Record) => ({ + id: 'com.acme.crm.billing', + name: 'acme_crm_billing', + version: '1.0.0', + type: 'module', + namespace: 'crm', + objects: [ + { name: 'crm_invoice', label: 'Invoice', fields: { total: { name: 'total', label: 'Total', type: 'number' } } }, + ], + ...item, +}); + +/** The artifact wrapper the ADR-0130 D5 load path reads (`packages[]`). */ +const artifactOf = (...manifests: unknown[]) => ({ packages: manifests.map((manifest) => ({ manifest })) }); + +/** + * Run package B's item class past the AUTHORING gate, as its own stack. + * + * This is what a module author actually writes: `defineStack` sees B's manifest + * and B's objects, and nothing of A's — the same view it has in a split repo, + * where each module compiles alone. + */ +const authoringVerdict = (item: Record, ownObjects = false): Envelope | undefined => { + const b = packageB(item); + try { + defineStack({ + manifest: { id: b.id, name: b.name, version: b.version, type: b.type, namespace: b.namespace }, + // The `ownObjects` control declares `crm_account` locally, which is the + // ONLY difference between a refused row and its control. It is what makes + // each refusal a reading about the PACKAGE BOUNDARY rather than about the + // fixture being malformed in some unrelated way. + objects: ownObjects ? [...b.objects, packageA().objects[0]] : b.objects, + ...item, + } as never); + return undefined; + } catch (e) { + return e as Envelope; + } +}; + +const kernels: ObjectKernel[] = []; + +const freshKernel = async (): Promise => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + return kernel; +}; + +/** + * Install A and B as co-owners of one artifact through the real load path, and + * hand back whatever the gate did plus the registry to read. + * + * `manifest.register()` may reject OR throw synchronously — a refusal raised + * inside `installPackage` propagates out of the non-async `register` before the + * promise exists. Catching both is the honest spelling; `rejects` alone MISSES + * the synchronous throw and reports it as a test error rather than a refusal + * (the reason `registry-artifact-co-ownership.test.ts` spells it the same way). + */ +const installVerdict = async ( + item: Record, +): Promise<{ refusal?: Envelope; kernel: ObjectKernel }> => { + const kernel = await freshKernel(); + try { + await (kernel.getService('manifest') as ManifestService).register(artifactOf(packageA(), packageB(item))); + return { kernel }; + } catch (e) { + return { refusal: e as Envelope, kernel }; + } +}; + +/** + * The reading every ACCEPTED row makes at the registry: B's item is really + * there, really stamped to B, and still carries A's object name — and the name + * it carries really does resolve, to A's definition, through the namespace the + * two packages co-own. + * + * "Registers AND resolves" is one proposition with two halves and both are + * asserted, because either alone is satisfiable by an accident: an item can + * register with a name that resolves to nothing, and an object can resolve + * while the item that named it was dropped on the floor. + */ +const expectRegisteredAndResolves = ( + kernel: ObjectKernel, + type: string, + itemName: string, + readRef: (item: Record) => unknown, +): void => { + const registry = engineOf(kernel).registry; + const item = registry.getItem(type, itemName) as Record | undefined; + expect(item, `${type} '${itemName}' did not register`).toBeDefined(); + // ADR-0010 provenance: the item belongs to B, not to the package that owns + // the object it names. + expect(item?._packageId).toBe('com.acme.crm.billing'); + // The foreign reference survived registration verbatim — not rewritten, not + // namespaced away, not dropped. + expect(readRef(item!)).toBe(CROSS); + // …and it points at something real: A's object, through the co-owned namespace. + const resolved = registry.resolveObject(CROSS) as { label?: string } | undefined; + expect(resolved?.label).toBe('Account'); + expect(registry.getObjectOwner(CROSS)?.packageId).toBe('com.acme.crm'); + // The co-ownership the whole reading rests on. + expect(registry.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.acme.crm.billing']); +}; + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Continuity control — reproduce a §4 row with THIS method before trusting the +// nine new ones. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#14122 §4 continuity — the method reproduces an already-measured rule', () => { + const hookItem = { + hooks: [{ + name: 'acct_hook', + object: CROSS, + events: ['afterInsert'], + body: { language: 'expression', source: 'true' }, + }], + }; + + it('R4 `hooks[].object` still REFUSES at the authoring gate, with §4\'s message verbatim', () => { + // If this row ever disagrees with #14122 §4, the nine rows below are + // measuring something other than what §4 measured and the matrix must not + // be folded into §4 until that is explained. It is the control that makes + // the rest of this file comparable to the four rules it extends. + const refused = authoringVerdict(hookItem); + expect(refused).toBeDefined(); + expect(refused?.message).toContain( + `Hook 'acct_hook' references object '${CROSS}' which is not defined in objects.`, + ); + + // The boundary is the whole reason: same hook, object declared locally, accepted. + expect(authoringVerdict(hookItem, true)).toBeUndefined(); + }); + + it('ENVELOPE ABSENCE — the authoring gate carries no ADR-0112 `code` / `status`', () => { + // Pinned once, here, rather than repeated on every refusing row. See the + // file header: red here means an envelope ARRIVED (good) — update the pin + // and the §4 matrix, do not delete the assertion. + const refused = authoringVerdict(hookItem); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBeUndefined(); + expect(refused?.status).toBeUndefined(); + expect(refused?.message).toContain('defineStack cross-reference validation failed'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Item 1 — the four classes the AUTHORING gate refuses across the boundary. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#14454 item 1 — classes REFUSED at the authoring gate (module cannot name a co-owner\'s object)', () => { + /** + * Each row: refused for B, accepted for the same B that declares the object + * itself, and — separately — ACCEPTED by the install gate, which is the fact + * that says the refusal is an authoring-door policy and not a runtime one. + */ + const rows: Array<{ + label: string; + item: Record; + message: string; + type: string; + itemName: string; + readRef: (i: Record) => unknown; + }> = [ + { + label: 'action `objectName`', + item: { actions: [{ name: 'bill_account', label: 'Bill', type: 'url', target: '/billing', objectName: CROSS }] }, + message: `Action 'bill_account' references object '${CROSS}' which is not defined in objects.`, + type: 'action', + itemName: 'bill_account', + readRef: (i) => i.objectName, + }, + { + label: 'permission set `objects`', + item: { permissions: [{ name: 'billing_ops', objects: { [CROSS]: { allowRead: true } } }] }, + message: `Permission 'billing_ops' grants on object '${CROSS}' which is not defined in objects.`, + type: 'permission', + itemName: 'billing_ops', + readRef: (i) => Object.keys(i.objects as Record)[0], + }, + { + label: 'seed dataset `object`', + item: { data: [{ object: CROSS, records: [{ name: 'Seeded' }] }] }, + message: `Seed data references object '${CROSS}' which is not defined in objects.`, + type: 'data', + itemName: CROSS, + readRef: (i) => i.object, + }, + { + label: 'import mapping `targetObject`', + item: { mappings: [{ name: 'acct_import', targetObject: CROSS, fieldMapping: [{ source: 'Name', target: 'name' }] }] }, + message: `Mapping 'acct_import' targets object '${CROSS}' which is not defined in objects.`, + type: 'mapping', + itemName: 'acct_import', + readRef: (i) => i.targetObject, + }, + ]; + + for (const row of rows) { + it(`${row.label} — REFUSED at authoring, ACCEPTED (unenforced) at install`, async () => { + const refused = authoringVerdict(row.item); + expect(refused, `${row.label} was expected to be refused at the authoring gate`).toBeDefined(); + expect(refused?.message).toContain(row.message); + + // Control: the SAME item, with the object declared locally, is accepted. + // Without this the row cannot distinguish "the boundary refuses it" from + // "the fixture is malformed". + expect(authoringVerdict(row.item, true)).toBeUndefined(); + + // The other door. Nothing on the load path re-asks the authoring gate's + // question, so a manifest that reaches `registerApp` — a machine-assembled + // artifact, a `strict: false` stack — installs clean. + const { refusal, kernel } = await installVerdict(row.item); + expect(refusal).toBeUndefined(); + expectRegisteredAndResolves(kernel, row.type, row.itemName, row.readRef); + }); + } + + it('view `data.object` — REFUSED at authoring, ACCEPTED (unenforced) at install', async () => { + // Split out because a `views:` entry is a CONTAINER keyed by its target + // object, not by a `name` — so its registry key and its reference are the + // same string, and the generic row helper's `readRef` would be asserting a + // tautology. Read the container's `list.data.object` instead. + const item = { + views: [{ list: { data: { provider: 'object', object: CROSS }, columns: [{ field: 'name' }] } }], + }; + const refused = authoringVerdict(item); + expect(refused?.message).toContain( + `View[0].list references object '${CROSS}' which is not defined in objects.`, + ); + expect(authoringVerdict(item, true)).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(item); + expect(refusal).toBeUndefined(); + const registry = engineOf(kernel).registry; + const container = registry.getItem('view', CROSS) as Record | undefined; + expect(container).toBeDefined(); + expect(container?._packageId).toBe('com.acme.crm.billing'); + expect(container?.list?.data?.object).toBe(CROSS); + // The container registered under A's object name while being owned by B — + // the cross-package shape, live in the registry. + expect(registry.getObjectOwner(CROSS)?.packageId).toBe('com.acme.crm'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Item 1 — the five classes NEITHER gate checks. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#14454 item 1 — classes ACCEPTED (unenforced): no object cross-reference check at either gate', () => { + /** + * ⚠️ "ACCEPTED (unenforced)" is not "ACCEPTED". These classes register and + * resolve across the boundary — which is what a module split needs — but they + * would equally register a reference to an object that exists NOWHERE. Both + * halves are asserted per row: the cross-package reference works, AND the + * same class swallows a dangling name in silence. The second half is the one + * that makes the row a finding rather than a green light. + */ + const rows: Array<{ + label: string; + item: Record; + dangling: Record; + type: string; + itemName: string; + readRef: (i: Record) => unknown; + }> = [ + { + label: 'page record `object`', + item: { pages: [{ name: 'acct_billing', label: 'Account Billing', type: 'record', object: CROSS }] }, + dangling: { pages: [{ name: 'acct_billing', label: 'Account Billing', type: 'record', object: 'crm_nowhere' }] }, + type: 'page', + itemName: 'acct_billing', + readRef: (i) => i.object, + }, + { + label: 'dataset `object`', + item: { + datasets: [{ + name: 'billing_by_account', label: 'Billing by Account', object: CROSS, + dimensions: [{ name: 'by_name', field: 'name' }], measures: [{ name: 'cnt', aggregate: 'count' }], + }], + }, + dangling: { + datasets: [{ + name: 'billing_by_account', label: 'Billing by Account', object: 'crm_nowhere', + dimensions: [{ name: 'by_name', field: 'name' }], measures: [{ name: 'cnt', aggregate: 'count' }], + }], + }, + type: 'dataset', + itemName: 'billing_by_account', + readRef: (i) => i.object, + }, + { + label: 'sharing rule `object`', + item: { + sharingRules: [{ + name: 'acct_share', object: CROSS, type: 'criteria', condition: 'record.name != ""', + sharedWith: { type: 'team', value: 'sales' }, accessLevel: 'read', + }], + }, + dangling: { + sharingRules: [{ + name: 'acct_share', object: 'crm_nowhere', type: 'criteria', condition: 'record.name != ""', + sharedWith: { type: 'team', value: 'sales' }, accessLevel: 'read', + }], + }, + // `sharingRules` registers under the SNAKE_CASE singular `sharing_rule` + // (`pluralToSingular`), not `sharingRule` — a reader looking the item back + // up under the camelCase spelling finds nothing and would mis-read this + // row as "dropped". + type: 'sharing_rule', + itemName: 'acct_share', + readRef: (i) => i.object, + }, + ]; + + for (const row of rows) { + it(`${row.label} — ACCEPTED (unenforced) at both gates`, async () => { + expect(authoringVerdict(row.item)).toBeUndefined(); + // The finding: the same gate accepts a name that exists nowhere at all. + expect( + authoringVerdict(row.dangling), + `${row.label}: a DANGLING name was refused — this class is enforced after all, re-read the row`, + ).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(row.item); + expect(refusal).toBeUndefined(); + expectRegisteredAndResolves(kernel, row.type, row.itemName, row.readRef); + }); + } + + it('page related-list `dataSource.object` — ACCEPTED (unenforced) at both gates', async () => { + // Split out: the reference is not a top-level key but a per-element data + // binding inside the page's component tree (`ElementDataSourceSchema`), so + // reading it back means walking the page rather than reading a field. This + // is the shape a module's own record page uses to show a related list of a + // co-owner's records — the split's most common page-level crossing. + const relatedList = (objectName: string) => ({ + pages: [{ + name: 'invoice_record', label: 'Invoice', type: 'record', object: 'crm_invoice', + regions: [{ + name: 'main', + components: [{ type: 'record:related_list', dataSource: { object: objectName } }], + }], + }], + }); + + expect(authoringVerdict(relatedList(CROSS))).toBeUndefined(); + expect(authoringVerdict(relatedList('crm_nowhere'))).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(relatedList(CROSS)); + expect(refusal).toBeUndefined(); + const registry = engineOf(kernel).registry; + const page = registry.getItem('page', 'invoice_record') as Record | undefined; + expect(page?._packageId).toBe('com.acme.crm.billing'); + expect(page?.regions?.[0]?.components?.[0]?.dataSource?.object).toBe(CROSS); + expect((registry.resolveObject(CROSS) as { label?: string } | undefined)?.label).toBe('Account'); + expect(registry.getObjectOwner(CROSS)?.packageId).toBe('com.acme.crm'); + }); + + it('flow node `config.objectName` — ACCEPTED (unenforced) at both gates', async () => { + // Split out for the same reason as the related list: a record-change flow + // binds its object on the START node's `config`, which `FlowNodeSchema` + // types as an open `z.record(z.string(), z.unknown())`. Nothing walks into + // it — so this class is unenforced twice over: no cross-reference check, + // and no schema on the value either. + const flow = (objectName: string) => ({ + flows: [{ + name: 'acct_flow', label: 'Account Flow', type: 'record_change', edges: [], + nodes: [{ id: 'start', type: 'start', label: 'Start', config: { objectName, triggerType: 'after_insert' } }], + }], + }); + + expect(authoringVerdict(flow(CROSS))).toBeUndefined(); + expect(authoringVerdict(flow('crm_nowhere'))).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(flow(CROSS)); + expect(refusal).toBeUndefined(); + const registry = engineOf(kernel).registry; + const registered = registry.getItem('flow', 'acct_flow') as Record | undefined; + expect(registered?._packageId).toBe('com.acme.crm.billing'); + expect(registered?.nodes?.[0]?.config?.objectName).toBe(CROSS); + expect((registry.resolveObject(CROSS) as { label?: string } | undefined)?.label).toBe('Account'); + expect(registry.getObjectOwner(CROSS)?.packageId).toBe('com.acme.crm'); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Item 4 — analytics binding across the boundary. +// ──────────────────────────────────────────────────────────────────────────── + +describe('#14454 item 4 — dashboard widget and report binding a co-owner\'s dataset', () => { + /** + * The question this decides, in the card's own words: *can a module carry its + * own dashboard?* The dataset lives in package A; B's widget and report name + * it. This is item 1's shape one layer up — a dataset name rather than an + * object name — and the answer is the same at both doors, for the same + * reason: nothing checks. + */ + const FOREIGN_DATASET = 'crm_account_ds'; + + const dashboard = (dataset: string) => ({ + dashboards: [{ + name: 'billing_overview', label: 'Billing Overview', + widgets: [{ id: 'accounts_by_name', type: 'bar', dataset, values: ['cnt'], dimensions: ['by_name'] }], + }], + }); + + const report = (dataset: string) => ({ + reports: [{ name: 'billing_accounts', label: 'Billing Accounts', dataset, values: ['cnt'] }], + }); + + it('dashboard widget `dataset` — ACCEPTED: the widget registers under B and A\'s dataset resolves', async () => { + expect(authoringVerdict(dashboard(FOREIGN_DATASET))).toBeUndefined(); + // …and a dataset that exists nowhere is accepted identically. The binding is + // unenforced, not merely permitted across packages. + expect(authoringVerdict(dashboard('ds_nowhere'))).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(dashboard(FOREIGN_DATASET)); + expect(refusal).toBeUndefined(); + const registry = engineOf(kernel).registry; + + const widget = registry.getItem('dashboard', 'billing_overview') as Record | undefined; + expect(widget?._packageId).toBe('com.acme.crm.billing'); + expect(widget?.widgets?.[0]?.dataset).toBe(FOREIGN_DATASET); + + // The other half of "resolves": A's dataset really is in the shared + // registry, owned by A, and its base object is A's object. + const ds = registry.getItem('dataset', FOREIGN_DATASET) as Record | undefined; + expect(ds).toBeDefined(); + expect(ds?._packageId).toBe('com.acme.crm'); + expect(ds?.object).toBe(CROSS); + }); + + it('report `dataset` — ACCEPTED: the report registers under B and A\'s dataset resolves', async () => { + expect(authoringVerdict(report(FOREIGN_DATASET))).toBeUndefined(); + expect(authoringVerdict(report('ds_nowhere'))).toBeUndefined(); + + const { refusal, kernel } = await installVerdict(report(FOREIGN_DATASET)); + expect(refusal).toBeUndefined(); + const registry = engineOf(kernel).registry; + + const registered = registry.getItem('report', 'billing_accounts') as Record | undefined; + expect(registered?._packageId).toBe('com.acme.crm.billing'); + expect(registered?.dataset).toBe(FOREIGN_DATASET); + + const ds = registry.getItem('dataset', FOREIGN_DATASET) as Record | undefined; + expect(ds?._packageId).toBe('com.acme.crm'); + expect(ds?.object).toBe(CROSS); + }); +}); diff --git a/packages/objectql/src/registry-nav-contribution-group-semantics.test.ts b/packages/objectql/src/registry-nav-contribution-group-semantics.test.ts new file mode 100644 index 0000000000..6d75fcad01 --- /dev/null +++ b/packages/objectql/src/registry-nav-contribution-group-semantics.test.ts @@ -0,0 +1,328 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0029 D7 / ADR-0130 — `navigationContributions[].group` semantics, + * measured across a package boundary (#14454 item 2). + * + * ## Why these three, and why they are load-bearing + * + * #14122 §4 measured that an app's OWN `navigation` may not name another + * package's object (rule R3, refused), so a module split converts every such + * entry into a `navigationContributions` entry owned by the module. hotcrm's + * split plan converts 17 nav nodes this way, and that conversion preserves the + * product's information architecture only if all three of these hold: + * + * 1. `group` resolves against a group node the TARGET app declares; + * 2. a contribution into a group that does not exist fails VISIBLY, rather + * than vanishing; + * 3. several packages contributing into one group are ordered by `priority`, + * not by registration order. + * + * ## What was measured — 1 and 3 hold; 2 does NOT, in a third way + * + * ⚠️ Proposition 2 is neither confirmed nor refuted as posed, because the + * platform does a third thing: a contribution naming a missing group is + * **relocated to the app's top level**. It does not vanish (so "silent drop", + * the failure mode the card feared, is not what happens) and it does not fail + * (no throw, no `code`, no `console.warn`) — the only trace is ONE + * `console.log` line emitted by the registry's own `log()`, which is gated at + * `logLevel` `info`/`debug`. The shipped default IS `info`, so a default + * deployment prints it once at boot; a deployment running at `warn` — which is + * what `OS_REGISTRY_LOG` exists to select, and what this very package's + * `vitest.config.ts` selects — emits NOTHING at all. + * + * For the conversion this is the worse half of the two outcomes the card + * considered. A dropped entry is missing and someone notices; a relocated entry + * is present, looks fine, and has silently changed the information architecture + * — which is the exact property the 17-node conversion exists to preserve. A + * typo'd `group` therefore converts a nested entry into a top-level one and + * nothing red ever appears. + * + * ⛔ This card is measurement only — no runtime behaviour is changed here. The + * pins below record what the platform does TODAY. If the PM rules that a + * missing group must refuse (or at minimum `console.warn`), the pin in + * `PROPOSITION 2` is what changes, in that card, deliberately. + * + * ## The log assertions set `logLevel` explicitly, and must + * + * `packages/objectql/vitest.config.ts` sets `OS_REGISTRY_LOG: 'warn'` for the + * whole package (#13517), which is BELOW the level `log()` writes at. A test + * that read the default here would observe silence and mis-report it as "the + * platform emits nothing", when what it actually measured was the harness. So + * each log reading names the level it is reading at, and the silence at `warn` + * is asserted as its own case rather than assumed. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { SchemaRegistry } from './registry.js'; +import { ObjectQLPlugin } from './plugin.js'; +import type { ObjectQL } from './engine.js'; + +type ManifestService = { register(m: unknown): void | Promise }; +type NavItem = { id?: string; type?: string; label?: string; objectName?: string; children?: NavItem[] }; + +const engineOf = (kernel: ObjectKernel): ObjectQL => kernel.getService('objectql'); + +/** The app-owning package: declares the app and the `sales_group` container. */ +const hostPackage = () => ({ + id: 'com.acme.crm', + name: 'acme_crm', + version: '1.0.0', + type: 'app', + namespace: 'crm', + objects: [ + { name: 'crm_account', label: 'Account', fields: { name: { name: 'name', label: 'Name', type: 'text' } } }, + ], + apps: [{ + name: 'crm_app', + label: 'CRM', + navigation: [ + { + id: 'sales_group', + type: 'group', + label: 'Sales', + children: [{ id: 'nav_accounts', type: 'object', objectName: 'crm_account', label: 'Accounts' }], + }, + ], + }], +}); + +/** + * A co-owning module that contributes ONE nav item into the host app. + * + * `short` names both the module's own object and its nav item, so a reading of + * the merged tree can name which package put which entry where. + */ +const contributorPackage = ( + short: string, + contribution: { group?: string; priority?: number }, +) => ({ + id: `com.acme.crm.${short}`, + name: `acme_crm_${short}`, + version: '1.0.0', + type: 'module', + namespace: 'crm', + objects: [ + { name: `crm_${short}`, label: short, fields: { note: { name: 'note', label: 'Note', type: 'text' } } }, + ], + navigationContributions: [{ + app: 'crm_app', + ...(contribution.group === undefined ? {} : { group: contribution.group }), + ...(contribution.priority === undefined ? {} : { priority: contribution.priority }), + items: [{ id: `nav_${short}`, type: 'object', objectName: `crm_${short}`, label: short }], + }], +}); + +const artifactOf = (...manifests: unknown[]) => ({ packages: manifests.map((manifest) => ({ manifest })) }); + +const kernels: ObjectKernel[] = []; + +/** Install the host plus contributors as ONE artifact and read the merged app. */ +const mergedNav = async (...contributors: unknown[]): Promise => { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + await (kernel.getService('manifest') as ManifestService).register(artifactOf(hostPackage(), ...contributors)); + const app = engineOf(kernel).registry.getApp('crm_app') as { navigation?: NavItem[] }; + return app.navigation ?? []; +}; + +const groupOf = (nav: NavItem[], id: string): NavItem | undefined => nav.find((n) => n.id === id); +const idsOf = (items: NavItem[] | undefined): string[] => (items ?? []).map((i) => i.id ?? '(unnamed)'); + +afterEach(async () => { + while (kernels.length) { + const k = kernels.pop()!; + if (k.getState() === 'running') await k.shutdown(); + } +}); + +describe('#14454 item 2 — `navigationContributions[].group` semantics across a package boundary', () => { + it('PROPOSITION 1 (HOLDS) — `group` resolves against a group node the TARGET app declares', async () => { + // The module names a group id it does not own and cannot see at authoring + // time. The merge finds it by depth-first search over the host app's own + // navigation tree (`findNavGroup`, matching `id` AND `type === 'group'`) + // and appends into that group's children. + const nav = await mergedNav(contributorPackage('cpq', { group: 'sales_group' })); + + const group = groupOf(nav, 'sales_group'); + expect(group, 'the host app\'s declared group survived the merge').toBeDefined(); + expect(idsOf(group?.children)).toEqual(['nav_accounts', 'nav_cpq']); + // …and the contribution did NOT also land at the top level: resolution is + // placement, not duplication. + expect(idsOf(nav)).toEqual(['sales_group']); + }); + + it('PROPOSITION 1 (scope) — the id must name a `type: "group"` node, not merely an id that exists', async () => { + // `findNavGroup` requires both `id` AND `type === 'group'`. An `object`-type + // nav item sharing the name is NOT a container, so this falls through to + // the missing-group path measured below. Worth pinning separately: it is + // the difference between "the group id was wrong" and "the group id named + // the wrong KIND of node", and both arrive at the same silent relocation. + const nav = await mergedNav(contributorPackage('cpq', { group: 'nav_accounts' })); + + expect(idsOf(groupOf(nav, 'sales_group')?.children)).toEqual(['nav_accounts']); + expect(idsOf(nav)).toEqual(['sales_group', 'nav_cpq']); + }); + + it('PROPOSITION 2 (DOES NOT HOLD AS POSED) — a missing group does not vanish and does not fail: it is RELOCATED to the top level', async () => { + // The card asked whether a contribution into a non-existent group fails + // VISIBLY rather than vanishing. Measured: neither. `applyNavContributions` + // appends the items at the app's top level and continues. + // + // ⚠️ This is the reading that matters for a 17-node conversion: the entry + // is PRESENT, so no smoke test misses it, but it sits one level up from + // where the author put it — the information architecture changed, silently. + const nav = await mergedNav(contributorPackage('cpq', { group: 'group_that_does_not_exist' })); + + // Not dropped… + expect(idsOf(nav)).toEqual(['sales_group', 'nav_cpq']); + // …and not placed anywhere near the group it named. + expect(idsOf(groupOf(nav, 'sales_group')?.children)).toEqual(['nav_accounts']); + }); + + it('PROPOSITION 2 (visibility) — the ONLY trace is a `console.log` gated at `info`; nothing throws, nothing warns', () => { + // Driven directly on a `SchemaRegistry` rather than through the kernel so + // the level under test is the one this assertion names — the package's + // vitest config pins `OS_REGISTRY_LOG=warn` for every registry constructed + // from the environment (see the file header). + const read = (level: 'silent' | 'warn' | 'info' | 'debug') => { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.logLevel = level; + registry.registerApp( + { name: 'crm_app', label: 'CRM', navigation: [{ id: 'sales_group', type: 'group', label: 'Sales', children: [] }] }, + 'com.acme.crm', + ); + registry.registerAppNavContribution( + { app: 'crm_app', group: 'group_that_does_not_exist', items: [{ id: 'nav_cpq', type: 'object', objectName: 'crm_cpq', label: 'cpq' }] }, + 'com.acme.crm.cpq', + ); + + const logs: string[] = []; + const warns: string[] = []; + const errors: string[] = []; + const [ol, ow, oe] = [console.log, console.warn, console.error]; + console.log = (...a: unknown[]) => { logs.push(a.map(String).join(' ')); }; + console.warn = (...a: unknown[]) => { warns.push(a.map(String).join(' ')); }; + console.error = (...a: unknown[]) => { errors.push(a.map(String).join(' ')); }; + let threw: unknown; + let app: { navigation?: NavItem[] } | undefined; + try { + app = registry.getApp('crm_app') as { navigation?: NavItem[] }; + } catch (e) { + threw = e; + } finally { + console.log = ol; console.warn = ow; console.error = oe; + } + return { logs, warns, errors, threw, nav: app?.navigation ?? [] }; + }; + + // At the SHIPPED default level, one line — and it is honest about what it did. + const atInfo = read('info'); + expect(atInfo.threw).toBeUndefined(); + expect(atInfo.logs).toHaveLength(1); + expect(atInfo.logs[0]).toContain('targets missing group "group_that_does_not_exist"'); + expect(atInfo.logs[0]).toContain('appending at top level'); + expect(atInfo.logs[0]).toContain('com.acme.crm.cpq'); + // ⚠️ Not a warning and not an error — the two channels an operator filters + // for. This is the visibility gap, pinned. + expect(atInfo.warns).toEqual([]); + expect(atInfo.errors).toEqual([]); + + // …and at `warn` — the level `OS_REGISTRY_LOG` selects, and the level this + // package's own test harness runs at — the relocation is COMPLETELY silent + // while still happening. + const atWarn = read('warn'); + expect(atWarn.threw).toBeUndefined(); + expect(atWarn.logs).toEqual([]); + expect(atWarn.warns).toEqual([]); + expect(atWarn.errors).toEqual([]); + expect(idsOf(atWarn.nav)).toEqual(['sales_group', 'nav_cpq']); + }); + + it('PROPOSITION 2 (authoring door) — the authoring gate cannot catch it either: `group` reaches no cross-reference check', async () => { + // Completes the "visibly?" question across both doors. `group` names a node + // in an app the contributing package does not own, so there is nothing for + // `validateCrossReferences` to resolve it against — and indeed + // `navigationContributions` appears in no cross-reference rule at all. The + // observable consequence is that a typo'd group id survives BOTH doors: it + // installs, and it relocates. + // + // Asserted at the install door, where the consequence is observable: the + // contribution is recorded verbatim, group id and all, with no diagnostic. + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + + await (kernel.getService('manifest') as ManifestService).register( + artifactOf(hostPackage(), contributorPackage('cpq', { group: 'group_that_does_not_exist' })), + ); + + const recorded = engineOf(kernel).registry.getAppNavContributions('crm_app'); + expect(recorded).toHaveLength(1); + expect(recorded[0]?.group).toBe('group_that_does_not_exist'); + expect(recorded[0]?.packageId).toBe('com.acme.crm.cpq'); + }); + + it('PROPOSITION 3 (HOLDS) — several packages contributing into one group are ordered by `priority`, not by registration order', async () => { + // Registration order and priority order are deliberately OPPOSED here: the + // artifact lists `cpq` (priority 300) before `order` (priority 100), so a + // merge that honoured arrival would read `nav_cpq, nav_order`. Without the + // opposition this assertion would pass against a registration-ordered + // implementation and pin nothing. + const nav = await mergedNav( + contributorPackage('cpq', { group: 'sales_group', priority: 300 }), + contributorPackage('order', { group: 'sales_group', priority: 100 }), + ); + + expect(idsOf(groupOf(nav, 'sales_group')?.children)).toEqual(['nav_accounts', 'nav_order', 'nav_cpq']); + }); + + it('PROPOSITION 3 (tie-break) — equal priorities fall back to registration order, and the default is 200', async () => { + // The other half of "ordered by priority": what happens when priority does + // NOT distinguish. `applyNavContributions` sorts with `Array#sort`, stable + // since ES2019, so ties keep arrival order — which is the sane fallback, + // but it is a fallback and a module split must not lean on it for IA. + // + // `margin` declares no `priority` at all and lands between the explicit 100 + // and 300, which is what pins the schema default of 200 from the merge side. + const nav = await mergedNav( + contributorPackage('cpq', { group: 'sales_group', priority: 100 }), + contributorPackage('order', { group: 'sales_group', priority: 100 }), + contributorPackage('margin', { group: 'sales_group' }), + contributorPackage('quote', { group: 'sales_group', priority: 300 }), + ); + + expect(idsOf(groupOf(nav, 'sales_group')?.children)) + .toEqual(['nav_accounts', 'nav_cpq', 'nav_order', 'nav_margin', 'nav_quote']); + }); + + it('merging is a READ-time fold — the stored app is never mutated, so repeated reads are identical', async () => { + // Why this belongs with the three: the conversion turns 17 owned nav nodes + // into contributions from several packages, and the merge runs on EVERY + // read of the app. If the fold mutated the stored app, the second read + // would show each contribution twice and the IA would drift with traffic + // rather than with metadata. + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + kernels.push(kernel); + await (kernel.getService('manifest') as ManifestService).register( + artifactOf(hostPackage(), contributorPackage('cpq', { group: 'sales_group' })), + ); + + const registry = engineOf(kernel).registry; + const first = registry.getApp('crm_app') as { navigation?: NavItem[] }; + const second = registry.getApp('crm_app') as { navigation?: NavItem[] }; + + expect(idsOf(groupOf(first.navigation ?? [], 'sales_group')?.children)).toEqual(['nav_accounts', 'nav_cpq']); + expect(idsOf(groupOf(second.navigation ?? [], 'sales_group')?.children)).toEqual(['nav_accounts', 'nav_cpq']); + // Distinct objects, equal content — a fold over a stored value, not an + // accumulation into it. + expect(second).not.toBe(first); + expect(second.navigation).toEqual(first.navigation); + }); +});