diff --git a/.changeset/28248-validate-residual-picklist.md b/.changeset/28248-validate-residual-picklist.md new file mode 100644 index 00000000000..50887c90b25 --- /dev/null +++ b/.changeset/28248-validate-residual-picklist.md @@ -0,0 +1,23 @@ +--- +'@objectstack/cli': patch +'@objectstack/metadata': patch +'@objectstack/spec': patch +--- + +fix(cli)!: `os validate` and `os build` refuse a composed stack's top-level picklist that the boot refuses (#28248) + +Clause-②: no (narrowing) — `os validate` and `os build` refuse a composed stack whose top level declares a picklist that no package body declares and no object field the engine registers names, which they accepted before; the boot already refuses every such stack, so nothing that boots is newly refused, and nothing is widened +Metadata-protocol: none + + + +**BREAKING** for `os validate` / `os build` on a composed stack only (one that carries `packages[]`), and only for a stack the boot already refuses. + +A composed stack (one that carries `packages[]`) whose top level declares a picklist that no package body declares, and that no object field the engine registers names, is refused at boot (`INVALID_METADATA`). `os validate` and `os build` used to pass the same stack, so the author learned of it only at boot. + +- **Now.** Both doors refuse it as `picklist-residual-unreferenced`, located at the picklist's top-level index. The message and prescription are the boot's own words: declare it inside the `packages[]` entry of the package whose objects use it; or name it from a field of the object meant to use it; or remove it from the top level. +- **What counts as naming it** is the boot's set: a field of a body's objects or `objectExtensions`, or of a top-level object no body declares. A top-level `objectExtensions` entry, a top-level copy of an object a body declares, and a top-level `plugins[]` entry name nothing, because no door registers them in a composed stack. +- **Unchanged.** A residual picklist that a top-level object's field names is not refused (the boot lists that object and does not serve it). A body field naming a list only the top level declares is `picklist-reference-unknown`, as before. A body-declared picklist and a stack without `packages[]` are not affected. +- **Prose.** The residual-rule header in `@objectstack/metadata` and the permission-set owner gate's docblock in `@objectstack/spec` now state the picklist exception. No schema changes. + +No stack that boots is newly refused: every stack this refuses, the boot already refuses. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 23231c97fa8..4e5724f9511 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -74,7 +74,7 @@ import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-s import { findViewContainerNameRefusals } from '../utils/view-container-names.js'; // A field `picklist` that names no picklist the stack declares — the walk // `os validate` step 2d runs, over the same judge. -import { judgePicklistReferences, printPicklistReferenceNotices } from '../utils/picklist-references.js'; +import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js'; export default class Compile extends Command { static override description = 'Compile ObjectStack configuration to JSON artifact'; @@ -509,9 +509,8 @@ export default class Compile extends Command { await emitJson({ success: false, errors: picklistJudgement.refusals, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); this.exit(1); } - const n = picklistJudgement.refusals.length; console.log(''); - printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); + printError(picklistRefusalHeading(picklistJudgement.refusals)); printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index b87bd0dbaf1..6133472813c 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -76,7 +76,7 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js' // A field `picklist` that names no picklist the stack declares — refused, or // reported when the declaring package depends on packages outside the stack. // Walked the way the load path registers, like the refusal above. -import { judgePicklistReferences, printPicklistReferenceNotices } from '../utils/picklist-references.js'; +import { judgePicklistReferences, picklistRefusalHeading, printPicklistReferenceNotices } from '../utils/picklist-references.js'; export default class Validate extends Command { static override description = @@ -479,9 +479,8 @@ export default class Validate extends Command { }); this.exit(1); } - const n = picklistJudgement.refusals.length; console.log(''); - printError(`A picklist reference names a picklist this stack does not declare (${n} reference${n > 1 ? 's' : ''})`); + printError(picklistRefusalHeading(picklistJudgement.refusals)); printAuthoringRuleErrors(picklistJudgement.refusals, { remedy: JSON_FULL_LIST_REMEDY }); this.exit(1); } diff --git a/packages/cli/src/utils/picklist-references.boot-parity.test.ts b/packages/cli/src/utils/picklist-references.boot-parity.test.ts new file mode 100644 index 00000000000..c42677a21aa --- /dev/null +++ b/packages/cli/src/utils/picklist-references.boot-parity.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0130 D4] `os validate` / `os build` foretell the boot: a composed + * stack's top-level picklist that no package body declares and no object field + * the engine registers names is refused by the boot (the engine's `manifest` + * service, `INVALID_METADATA`), and `judgePicklistReferences` refuses the same + * stacks as `picklist-residual-unreferenced`, in the same words. + * + * The engine does not export its judgement, so the author-time door restates + * it. This file is what keeps the two from drifting. Each stack is judged by + * the door and booted on a real kernel with the real `ObjectQLPlugin`, the way + * `AppPlugin` hands a config boot's stack to the `manifest` service + * (`{ ...manifest, ...stack }`). Where the door refuses one list, the boot + * throws `${message} ${hint}` byte for byte; where the door raises no such + * refusal, the boot does not throw this one. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectKernel, type Plugin, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; + +import { judgePicklistReferences, PICKLIST_RESIDUAL_UNREFERENCED } from './picklist-references.js'; + +type AnyRec = Record; + +const STACK_ID = 'com.example.parity.release'; +const manifest = (id: string) => ({ id, name: id.split('.').pop(), version: '1.0.0', type: 'app' }); +const status = { name: 'parity_status', label: 'Status', options: [{ label: 'Open', value: 'open' }] }; +const unused = { name: 'parity_unused', label: 'Unused', options: [{ label: 'A', value: 'a' }] }; +const statusField = { status: { type: 'select', label: 'Status', picklist: 'parity_status' } }; +const caseObj = (fields: AnyRec = { subject: { type: 'text', label: 'Subject' } }) => + ({ name: 'parity_case', label: 'Case', sharingModel: 'private', fields }); +const noteObj = (fields: AnyRec = { title: { type: 'text', label: 'Title' } }) => + ({ name: 'parity_note', label: 'Note', sharingModel: 'private', fields }); + +/** Two bodies, a manifest naming neither, and `top` on the top level. */ +const composed = (top: AnyRec, coreExtra: AnyRec = {}): AnyRec => ({ + manifest: manifest(STACK_ID), + ...top, + packages: [ + { manifest: { ...manifest('com.example.parity.core'), objects: [caseObj()], ...coreExtra } }, + { manifest: { ...manifest('com.example.parity.app'), picklists: [status] } }, + ], +}); + +/** Every stack the door's pins name, the refused and the controls alike. */ +const STACKS: Record = { + 'a list nothing names': composed({ picklists: [unused] }), + 'a list only a top-level `objectExtensions` entry names': composed({ + picklists: [unused], objectExtensions: [{ extend: 'parity_case', fields: { u: { type: 'select', label: 'U', picklist: 'parity_unused' } } }], + }), + 'a list only a diverging top-level copy of a body object names': composed({ + picklists: [unused], objects: [caseObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })], + }), + 'a list only a body `picklistExtensions` entry extends': composed({ picklists: [unused] }, { + picklistExtensions: [{ extend: 'parity_unused', options: [{ label: 'Closed', value: 'closed' }] }], + }), + 'a list only a top-level `plugins[]` entry\'s object names': composed({ + picklists: [unused], plugins: [{ name: 'parity_plugin', objects: [noteObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })] }], + }), + 'CONTROL: a list a residual object\'s field names': composed({ + picklists: [unused], objects: [noteObj({ u: { type: 'select', label: 'U', picklist: 'parity_unused' } })], + }), + 'CONTROL: a body-declared list repeated on the top level': composed({ picklists: [status] }), + 'CONTROL: a body-declared list a body field names': composed({}, { objects: [caseObj(statusField)] }), + 'CONTROL: a stack without `packages[]`': { manifest: manifest(STACK_ID), picklists: [unused], objects: [noteObj()] }, +}; + +const kernels: ObjectKernel[] = []; +afterEach(async () => { + while (kernels.length) { + const kernel = kernels.pop()!; + if (kernel.getState() === 'running') await kernel.shutdown(); + } +}); + +/** Boot the stack through the `manifest` service; the boot's refusal, or `undefined`. */ +async function bootRefusal(stack: AnyRec): Promise<(Error & { code?: string }) | undefined> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + await kernel.use(new ObjectQLPlugin()); + await kernel.use({ + name: 'test.parity.app', + dependencies: ['com.objectstack.engine.objectql'], + init: async (ctx: PluginContext) => { + await ctx.getService<{ register(m: unknown): unknown }>('manifest') + .register({ ...(stack.manifest as AnyRec), ...stack }); + }, + } as Plugin); + return kernel.bootstrap().then(() => undefined, (e: Error & { code?: string }) => e); +} + +describe('picklist-residual-unreferenced — the author-time door and the boot agree', () => { + it.each(Object.keys(STACKS))('%s', async (label) => { + const stack = STACKS[label]; + const door = judgePicklistReferences(stack).refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED); + const boot = await bootRefusal(stack); + if (label.startsWith('CONTROL')) { + expect(door).toEqual([]); + expect(boot?.message ?? '').not.toContain('is declared on the top level of stack'); + return; + } + expect(door).toHaveLength(1); + expect(boot?.code).toBe('INVALID_METADATA'); + expect(boot?.message).toBe(`${door[0].message} ${door[0].hint}`); + }); +}); diff --git a/packages/cli/src/utils/picklist-references.test.ts b/packages/cli/src/utils/picklist-references.test.ts index a6732b68304..2252fdb0682 100644 --- a/packages/cli/src/utils/picklist-references.test.ts +++ b/packages/cli/src/utils/picklist-references.test.ts @@ -18,8 +18,10 @@ import { describe, expect, it } from 'vitest'; import { judgePicklistReferences, + picklistRefusalHeading, PICKLIST_REFERENCE_UNKNOWN, PICKLIST_REFERENCE_UNVERIFIED, + PICKLIST_RESIDUAL_UNREFERENCED, } from './picklist-references.js'; const manifest = (id: string, extra: Record = {}) => ({ @@ -329,3 +331,116 @@ describe('judgePicklistReferences — a `picklistExtensions[].extend` in a `pack })).toEqual({ refusals: [], notices: [] }); }); }); + +/** + * [ADR-0130 D4] A composed stack's top-level picklist that no package body + * declares and no object field the boot registers names: the boot refuses the + * stack (`INVALID_METADATA`), so this door refuses it too, as + * `picklist-residual-unreferenced`, in the boot's words. The boot's own text is + * held equal in `picklist-references.boot-parity.test.ts`. + */ +describe('judgePicklistReferences — a composed stack\'s residual picklist that nothing names', () => { + const STACK_ID = 'com.example.release'; + const status = { name: 'status_list', label: 'Status', options: [{ label: 'Open', value: 'open' }] }; + const unused = { name: 'unused_list', label: 'Unused', options: [{ label: 'A', value: 'a' }] }; + const statusField = { status: { type: 'select', label: 'Status', picklist: 'status_list' } }; + const caseObj = (fields: Record = { subject: { type: 'text', label: 'Subject' } }) => + ({ name: 'pk_case', label: 'Case', fields }); + const noteObj = (fields: Record = { title: { type: 'text', label: 'Title' } }) => + ({ name: 'pk_note', label: 'Note', fields }); + /** Two bodies, a manifest naming neither, and `top` on the top level. */ + const composed = (top: Record, coreExtra: Record = {}) => ({ + manifest: manifest(STACK_ID), + ...top, + packages: [ + { manifest: { ...manifest('com.example.core'), objects: [caseObj()], ...coreExtra } }, + { manifest: { ...manifest('com.example.app'), objects: [account('industry')], picklists: [industry] } }, + ], + }); + const residualRefusals = (parsed: Record) => + judgePicklistReferences(parsed).refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED); + + it('refuses one, naming the list, the stack and the boot\'s prescription, located at its top-level index', () => { + const { refusals, notices } = judgePicklistReferences(composed({ picklists: [industry, unused] })); + expect(notices).toEqual([]); + expect(refusals).toHaveLength(1); + expect(refusals[0]).toMatchObject({ + severity: 'error', + rule: PICKLIST_RESIDUAL_UNREFERENCED, + where: 'picklist "unused_list"', + path: 'picklists[1]', + }); + expect(refusals[0].message).toContain(`Picklist 'unused_list' is declared on the top level of stack '${STACK_ID}'`); + expect(refusals[0].message).toContain('no package body declares it and no object field the engine registers names it'); + expect(refusals[0].hint).toContain('Declare it inside the `packages[]` entry of the package whose objects use it'); + expect(refusals[0].hint).toContain('name it from a field of the object meant to use it'); + expect(refusals[0].hint).toContain('remove it from the top level'); + }); + + it('refuses each such list as its own finding', () => { + expect(residualRefusals(composed({ picklists: [unused, { ...unused, name: 'spare_list' }] })).map((r) => r.where)) + .toEqual(['picklist "unused_list"', 'picklist "spare_list"']); + }); + + it('refuses one that only a top-level `objectExtensions` entry names — no door registers it in a composed stack', () => { + expect(residualRefusals(composed({ picklists: [status], objectExtensions: [{ extend: 'pk_case', fields: statusField }] }))) + .toHaveLength(1); + }); + + it('refuses one that only a top-level copy of a body-declared object names, its fields diverging from the body\'s', () => { + expect(residualRefusals(composed({ picklists: [status], objects: [caseObj(statusField)] }))).toHaveLength(1); + }); + + it('refuses one that only a top-level `plugins[]` entry\'s object names', () => { + expect(residualRefusals(composed({ picklists: [status], plugins: [{ name: 'p', objects: [noteObj(statusField)] }] }))) + .toHaveLength(1); + }); + + it('refuses one that only a body\'s `picklistExtensions` entry extends', () => { + expect(residualRefusals(composed({ picklists: [status] }, { + picklistExtensions: [{ extend: 'status_list', options: [{ label: 'Closed', value: 'closed' }] }], + }))).toHaveLength(1); + }); + + it('CONTROL: one a residual object\'s field names is not this refusal (the boot lists that object and does not serve it)', () => { + expect(judgePicklistReferences(composed({ picklists: [status], objects: [noteObj(statusField)] }))) + .toEqual({ refusals: [], notices: [] }); + }); + + it('CONTROL: one a body object\'s field names is not this refusal — that field is `picklist-reference-unknown`, as before', () => { + const { refusals } = judgePicklistReferences(composed({ picklists: [status] }, { objects: [caseObj(statusField)] })); + expect(refusals.map((r) => r.rule)).toEqual([PICKLIST_REFERENCE_UNKNOWN]); + }); + + it('CONTROL: a body-declared picklist, repeated on the top level or not, is not residual', () => { + for (const top of [{ picklists: [industry] }, {}]) { + expect(judgePicklistReferences(composed(top))).toEqual({ refusals: [], notices: [] }); + } + }); + + it('CONTROL: a stack without `packages[]` has no residual — its unnamed picklist is not refused', () => { + expect(judgePicklistReferences({ manifest: manifest('com.example.pk'), picklists: [industry, unused], objects: [account('industry')] })) + .toEqual({ refusals: [], notices: [] }); + }); +}); + +describe('picklistRefusalHeading — the text face\'s heading names what each rule refused', () => { + const finding = (rule: string) => ({ severity: 'error' as const, rule, where: 'w', path: 'p', message: 'm', hint: 'h' }); + + it('CONTROL: reference refusals alone keep the heading they always had', () => { + expect(picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_REFERENCE_UNKNOWN)])) + .toBe('A picklist reference names a picklist this stack does not declare (2 references)'); + }); + + it('a residual refusal is headed as the boot\'s refusal, not as a reference', () => { + const heading = picklistRefusalHeading([finding(PICKLIST_RESIDUAL_UNREFERENCED)]); + expect(heading).toBe('A top-level picklist of this composed stack is one the boot refuses: no package body declares it ' + + 'and no object field the engine registers names it (1 picklist)'); + }); + + it('both kinds together are both named', () => { + const heading = picklistRefusalHeading([finding(PICKLIST_REFERENCE_UNKNOWN), finding(PICKLIST_RESIDUAL_UNREFERENCED)]); + expect(heading).toContain('(1 reference)'); + expect(heading).toContain('(1 picklist)'); + }); +}); diff --git a/packages/cli/src/utils/picklist-references.ts b/packages/cli/src/utils/picklist-references.ts index 9fac1e1f5f8..11aebfa01f0 100644 --- a/packages/cli/src/utils/picklist-references.ts +++ b/packages/cli/src/utils/picklist-references.ts @@ -42,6 +42,15 @@ * (ADR-0130), so a list a SIBLING package in the same artifact owns resolves * here exactly as one the declaring package owns. * + * ## A composed stack's top-level picklist that nothing names: refused + * + * The boot keeps a composed stack's residual picklist (a top-level one no + * body declares) as metadata only, and refuses the stack when no object field + * the engine registers names it (ADR-0130 D4). This door gives the same + * refusal, in the boot's words, as {@link PICKLIST_RESIDUAL_UNREFERENCED}, so + * `os validate` and `os build` foretell it. See + * `findUnreferencedResidualPicklists`. + * * ## Declared dependencies outside the stack: reported, never refused * * A package may use a picklist another package owns — `picklistExtensions` @@ -83,6 +92,7 @@ import chalk from 'chalk'; import type { AuthoringFinding } from '@objectstack/lint'; +import { unclaimedTopLevel } from '@objectstack/metadata'; import { artifactPackages } from './artifact-packages.js'; import { printInfo } from './format.js'; @@ -96,6 +106,12 @@ export const PICKLIST_REFERENCE_UNKNOWN = 'picklist-reference-unknown'; /** Same, but the declaring package names dependencies outside the stack, which this command cannot read. */ export const PICKLIST_REFERENCE_UNVERIFIED = 'picklist-reference-unverified'; +/** + * A composed stack's top-level picklist that no package body declares and no + * object field the boot registers names: the boot refuses the stack. + */ +export const PICKLIST_RESIDUAL_UNREFERENCED = 'picklist-residual-unreferenced'; + /** What {@link judgePicklistReferences} hands both doors. */ export interface PicklistReferenceJudgement { /** `severity: 'error'` — the door exits 1 with these as its `errors`. */ @@ -315,9 +331,108 @@ export function judgePicklistReferences(parsed: AnyRec): PicklistReferenceJudgem } } } + refusals.push(...findUnreferencedResidualPicklists(parsed)); return { refusals, notices }; } +/** + * The picklist names a manifest-shaped source's fields carry: its `objects` + * and `objectExtensions`, in either field spelling (a name-keyed record or an + * array), and the same of each nested `plugins[]` entry that is a plain + * object. That is the reading the boot's `collectManifestPicklistReferences` + * (`@objectstack/objectql`) makes of a source it registers. The stack parse + * refuses a `plugins` key inside a `packages[]` body today; it is walked anyway + * so this set stays the engine's. + */ +function fieldPicklistNamesOf(source: unknown): string[] { + const names: string[] = []; + const rec = asRec(source); + if (!rec) return names; + const nested = Array.isArray(rec.plugins) ? rec.plugins.map(asRec).filter((p): p is AnyRec => p !== undefined) : []; + for (const s of [rec, ...nested]) { + for (const collection of ['objects', 'objectExtensions'] as const) { + const raw = s[collection]; + const entries = Array.isArray(raw) ? raw : (asRec(raw) ? Object.values(raw as AnyRec) : []); + for (const entry of entries) { + const fields = asRec(entry)?.fields; + const defs = Array.isArray(fields) ? fields : (asRec(fields) ? Object.values(fields as AnyRec) : []); + for (const def of defs) { + const picklist = asRec(def)?.picklist; + if (typeof picklist === 'string' && picklist !== '') names.push(picklist); + } + } + } + } + return names; +} + +/** + * The boot's judgement of an unreferenced residual picklist, for one list, in + * the boot's words: what `ObjectQLPlugin`'s `manifest` service throws + * (`unreferencedResidualPicklistRefusal`, `INVALID_METADATA`) is + * `${judgement} ${prescription}`. Restated here because the engine does not + * export it; `picklist-references.boot-parity.test.ts` boots the engine on the + * same stacks and holds the two equal. + */ +function residualPicklistWording(name: string, owner: string): { judgement: string; prescription: string } { + return { + judgement: + `Picklist '${name}' is declared on the top level of stack '${owner}', and no package body declares it and no ` + + 'object field the engine registers names it (in a composed stack, a top-level `objectExtensions` entry, a ' + + 'top-level copy of an object a package body declares and a top-level `plugins[]` entry are not registered). ' + + 'The engine registers a top-level picklist only through a package body, so this list would be missing from ' + + 'the engine with nothing said.', + prescription: + 'Declare it inside the `packages[]` entry of the package whose objects use it; or name it from a field of ' + + 'the object meant to use it (a top-level object that does is then listed and not served until both move ' + + 'into a package body); or remove it from the top level.', + }; +} + +/** + * [ADR-0130 D4] Every top-level picklist of a composed stack (one that carries + * `packages[]`) that no package body declares and no object field the boot + * registers names — each one the boot refuses (`INVALID_METADATA`) before + * anything of the stack registers. `[]` for a stack without `packages[]`, + * whose top level IS its one package. + * + * Which items are residual is the residual rule's answer (`unclaimedTopLevel`, + * `@objectstack/metadata`), never re-derived here. "Named" is the boot's set: + * a field of a body's `objects` / `objectExtensions` / nested `plugins[]`, or + * of a residual object. A top-level `objectExtensions` entry, a top-level copy + * of an object a body declares and a top-level `plugins[]` entry name nothing, + * because no door registers them in a composed stack. + * + * A residual picklist that a field DOES name is not this judge's: a residual + * object's field makes that object listed and not served at boot, and a + * body's field is {@link PICKLIST_REFERENCE_UNKNOWN} above. + */ +function findUnreferencedResidualPicklists(parsed: AnyRec): AuthoringFinding[] { + if (parsed.packages === undefined) return []; + const residual = unclaimedTopLevel(parsed); + const lists = residual?.items.filter((entry) => entry.type === 'picklist') ?? []; + if (!residual || lists.length === 0) return []; + const named = new Set(); + for (const pkg of artifactPackages(parsed)) for (const name of fieldPicklistNamesOf(pkg.body)) named.add(name); + const residualObjects = residual.items.filter((entry) => entry.type === 'object').map((entry) => entry.item); + for (const name of fieldPicklistNamesOf({ objects: residualObjects })) named.add(name); + const owner = residual.ownerId ?? ''; + return lists + .map((entry) => ({ entry, name: String(asRec(entry.item)?.name) })) + .filter(({ name }) => !named.has(name)) + .map(({ entry, name }) => { + const wording = residualPicklistWording(name, owner); + return { + severity: 'error' as const, + rule: PICKLIST_RESIDUAL_UNREFERENCED, + where: `picklist "${name}"`, + path: `${entry.field}[${entry.index}]`, + message: wording.judgement, + hint: wording.prescription, + }; + }); +} + /** * The text face of the notices for `os validate` and `os build`, printed at the * step that computes them so they show on the failing paths too. @@ -328,3 +443,22 @@ export function printPicklistReferenceNotices(notices: readonly AuthoringFinding console.log(chalk.dim(` → ${n.hint}`)); } } + +/** + * The text face's heading over {@link judgePicklistReferences}'s refusals, for + * `os validate` and `os build` alike: one line naming what was refused, by the + * rules that refused it. + */ +export function picklistRefusalHeading(refusals: readonly AuthoringFinding[]): string { + const residual = refusals.filter((r) => r.rule === PICKLIST_RESIDUAL_UNREFERENCED).length; + const references = refusals.length - residual; + const parts: string[] = []; + if (references > 0) { + parts.push(`a picklist reference names a picklist this stack does not declare (${references} reference${references > 1 ? 's' : ''})`); + } + if (residual > 0) { + parts.push(`a top-level picklist of this composed stack is one the boot refuses: no package body declares it and no object field the engine registers names it (${residual} picklist${residual > 1 ? 's' : ''})`); + } + const line = parts.join('; and '); + return line.charAt(0).toUpperCase() + line.slice(1); +} diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index d00dfc23040..e14ec11817a 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -327,6 +327,10 @@ const NOT_A_GATE: Readonly> = { // already produced; the same records ride `--json` whether this runs or // not. 'printPicklistReferenceNotices', + // The text face's heading over the refusals `judgePicklistReferences` + // (a gate above) already produced; `--json` carries the same refusals + // without it. + 'picklistRefusalHeading', // [#18780] `compile.ts`' local once-guard around the line above it. It // decides WHEN that printer is called — after the per-package pass has // appended its survivors, so the closing `N author-time warning(s) — see diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 94e90635a8f..dbbc705d7ee 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -278,6 +278,16 @@ import type { IHttpServer } from '@objectstack/spec/contracts'; // cannot register without refusing the boot (a name another package already // owns, a field naming a picklist no body declares) stays listed here and // unserved there, and the engine names it at boot. +// +// The residual's PICKLISTS are the one exception to "registered under the +// stack's id": this rule lists them, and the engine never registers them, so +// a residual picklist is metadata only. When a residual object's field names +// it, that object is listed and not served, as above; when a body's field +// names it, the boot's picklist audit refuses that field (`os validate` / +// `os build` report it as `picklist-reference-unknown`). When no object field +// the engine registers names it, the engine refuses the stack +// before anything of it registers (`INVALID_METADATA`), and `os validate` / +// `os build` refuse the same stack first. /** * Where one door's registrations land. The artifact door writes its in-memory diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 03dbd16586b..e876baf5b56 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -231,6 +231,14 @@ function applyApiEndpointGates( * `@objectstack/metadata`; a permission set's slot there is its name). A * top-level set that a body declares is a copy of that body's set and is never * registered from the top level, so the top level does not judge it. + * The residual rule has one exception: a residual PICKLIST is kept as + * metadata only, never registered with the engine. When a residual object's + * field names it, the boot lists that object and does not serve it. When a + * package body's field names it, the boot's `kernel:ready` picklist audit + * refuses that field, and `os validate` / `os build` report it as + * `picklist-reference-unknown`. When no object field the engine registers + * names it, the boot refuses the stack, and `os validate` / `os build` + * refuse it first. */ function applyPermissionSetOwnerBindingGate( config: { manifest?: { id?: unknown } | undefined; permissions?: unknown; packages?: unknown },