diff --git a/.changeset/19785-merge-actions-shape-refusal.md b/.changeset/19785-merge-actions-shape-refusal.md new file mode 100644 index 00000000000..62432ad9ae1 --- /dev/null +++ b/.changeset/19785-merge-actions-shape-refusal.md @@ -0,0 +1,26 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope + +**BREAKING** — `defineStack`, a public root export, now refuses under `strict: false` a class of input it used to crash on or hand on unusable: a non-array `objects`, or an `objects` array holding an entry that is not an object. + +The non-strict door skips the parse and hands the normalized input to the action merge that ends every `defineStack` call. That merge read `objects` with no shape guard. Measured before this change: + +| `objects` under `strict: false` | before | after | +| :--- | :--- | :--- | +| a number or a string (`5`, `'abc'`) | bare `TypeError: config.objects.map is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | +| `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | +| an array holding `null` (`[null, obj]`) | bare `TypeError` reading `actions` off `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | +| an array holding another non-object (`[obj, 7]`, `[obj, 'x']`) | returned with the entry in place, a success whose objects are not all objects | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', index]` | + +`strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. The refusal carries the code the strict parse raises for the same authored mistake, with the zod issue on `issues` — `path: ['objects']`, `expected: 'array'` for the collection, `path: ['objects', index]`, `expected: 'object'` for each non-object entry — the same line `composeStacks` draws for a non-array `objects`. Every row narrows: nothing that used to be refused is accepted now. An absent `objects` (`undefined`) is not malformed and behaves as before; the map form (`{ name: { … } }`) is still normalized to an array first and accepted. + +Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run. + +No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`. + + + +Clause-②: no (narrowing) diff --git a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts index e3c4816f42c..09739e8dfd5 100644 --- a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts +++ b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts @@ -81,10 +81,13 @@ const rows: Array<{ label: string; build: () => ObjectStackDefinition }> = [ { label: 'a hand-built stack whose `objects` is a map', build: () => handBuilt({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } }) }, { label: 'a hand-built stack whose `objects` is a number', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 5 }) }, { label: 'a hand-built stack whose `objects` is a Set of objects', build: () => handBuilt({ manifest: mf('com.example.b'), objects: new Set([obj('b_item')]) }) }, - { label: '`strict: false` with `objects: null`', build: () => unparsed({ manifest: mf('com.example.b'), objects: null }) }, - { label: "`strict: false` with `objects: ''`", build: () => unparsed({ manifest: mf('com.example.b'), objects: '' }) }, - { label: '`strict: false` with `objects: 0`', build: () => unparsed({ manifest: mf('com.example.b'), objects: 0 }) }, - { label: '`strict: false` with `objects: false`', build: () => unparsed({ manifest: mf('com.example.b'), objects: false }) }, + // The falsy rows reach composition hand-built: since #19785 the `strict: + // false` door refuses them itself (`define-stack-non-strict-objects-shape-refusal.test.ts`), + // so it can no longer carry them this far. + { label: 'a hand-built stack whose `objects` is null', build: () => handBuilt({ manifest: mf('com.example.b'), objects: null }) }, + { label: "a hand-built stack whose `objects` is ''", build: () => handBuilt({ manifest: mf('com.example.b'), objects: '' }) }, + { label: 'a hand-built stack whose `objects` is 0', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 0 }) }, + { label: 'a hand-built stack whose `objects` is false', build: () => handBuilt({ manifest: mf('com.example.b'), objects: false }) }, ]; describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-0112 envelope', () => { diff --git a/packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts b/packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts new file mode 100644 index 00000000000..a8be070c6ed --- /dev/null +++ b/packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack(config, { strict: false })` refuses a non-array `objects` with + * an ADR-0112 envelope (#19785). + * + * ## What was wrong + * + * The non-strict door skips the parse and hands the normalized input straight + * to `mergeActionsIntoObjects`, whose only guard was + * `if (!config.objects || config.objects.length === 0)`. A truthy non-array + * (`5`, `'abc'`) went on to `config.objects.map(…)` and raised a bare + * `TypeError` — `code` and `status` both `undefined`, outside the envelope + * every other refusal in the file carries — and a `null` ENTRY raised one + * reading `actions` off it. A falsy non-array (`null`, `''`, `0`, `false`) was + * handed on untouched, to be refused one door later by `composeStacks`. + * + * ## What is pinned + * + * `strict: false` skips validation; it never promised to accept a shape the + * merge cannot read. So every non-array `objects` except an absent one is + * refused here with the strict parse's own envelope — `STACK_SCHEMA_INVALID`, + * `status: 422`, the zod issue on `issues` at `path: ['objects']` — the same + * line `composeStacks` step 2 draws for the same key. + * + * A non-object ENTRY is refused with the same envelope, one zod issue per + * entry at `path: ['objects', index]` (`expected: 'object'`): handing it on + * would return a success whose objects are not all objects, and the next + * consumer (plugin-object registration) drops every object after it. + * + * Every refusal has its CONTROL: the same stack with `objects` authored as an + * array, or as the map form, is accepted and its bound actions are merged. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; + +type Envelope = Error & { + code?: string; + status?: number; + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>; +}; + +/** The thrown value, or `null` when the call is accepted. */ +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const manifest = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'app' as const }; + +const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } }); + +const bound = { name: 'b_open', label: 'Open', type: 'url' as const, target: 'https://example.com', objectName: 'b_item' }; + +const nonStrict = (overrides: Record) => + defineStack({ manifest, ...overrides } as never, { strict: false }); + +const rows: Array<{ label: string; objects: unknown }> = [ + { label: 'a number', objects: 5 }, + { label: 'a string', objects: 'abc' }, + { label: 'null', objects: null }, + { label: "''", objects: '' }, + { label: '0', objects: 0 }, + { label: 'false', objects: false }, +]; + +describe('#19785 — defineStack strict: false refuses a non-array `objects` with an ADR-0112 envelope', () => { + for (const row of rows) { + it(`\`objects\` as ${row.label} is refused with code STACK_SCHEMA_INVALID and status 422, the issue at ['objects']`, () => { + const refused = refusal(() => nonStrict({ objects: row.objects, actions: [bound] })); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]?.path).toEqual(['objects']); + expect(refused?.issues?.[0]?.code).toBe('invalid_type'); + expect(refused?.issues?.[0]?.expected).toBe('array'); + expect(refused?.message).toContain("'objects'"); + }); + } + + it('the control — `objects` as an array is accepted and the bound action is merged into its object', () => { + const stack = nonStrict({ objects: [obj('b_item')], actions: [bound] }); + expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']); + }); + + it('the control — the map form is normalized first and accepted the same way', () => { + const stack = nonStrict({ objects: { b_item: obj('b_item') }, actions: [bound] }); + expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_open']); + }); + + it('an ABSENT `objects` is not a malformed one — accepted', () => { + const stack = nonStrict({ actions: [bound] }); + expect(stack.objects).toBeUndefined(); + expect(stack.actions?.map((a) => a.name)).toEqual(['b_open']); + }); + + it('a non-object ENTRY is refused with the same envelope, one issue per entry at [objects, index] — never handed on, never a bare TypeError', () => { + const refused = refusal(() => nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] })); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues?.map((issue) => issue.path)).toEqual([ + ['objects', 0], + ['objects', 2], + ]); + expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === 'object')).toBe(true); + }); + + it('a single non-object entry beside a good one is refused too — the card\'s `[null, obj]` repro', () => { + const refused = refusal(() => nonStrict({ objects: [null, obj('b_item')] })); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues?.map((issue) => issue.path)).toEqual([['objects', 0]]); + }); + + it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => { + const refused = refusal(() => defineStack({ manifest, objects: 5 } as never)); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9b46ea3999b..ac2a452f57d 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2947,6 +2947,54 @@ function sortActionsByOrder(actions: T[]): T[] { * @internal */ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefinition { + // [ADR-0112 · #19785] Shape guard, because `defineStack(config, { strict: + // false })` hands this merge the normalized input with no parse in between. + // A non-array `objects` is REFUSED with the strict parse's own envelope + // (`STACK_SCHEMA_INVALID`, 422, the zod issue at `['objects']`) — never a bare + // `TypeError` from `.map`, and never a pass-through: bound actions cannot be + // merged into objects that are not a list, and a stack handed on in that + // shape is one `composeStacks` refuses with this same code anyway. `strict: + // false` skips VALIDATION (cross-references, schema detail); it never + // promised to accept a shape this merge cannot read. `undefined` is the one + // non-array that is not malformed — the key is simply absent — the same line + // `mergeObjects` draws for the same key. + const declaredObjects: unknown = (config as { objects?: unknown }).objects; + if (declaredObjects !== undefined && !Array.isArray(declaredObjects)) { + const { kind, issues } = describeNonArrayCollection('objects', declaredObjects); + throw new StackSchemaInvalidError( + `defineStack validation failed: 'objects' is ${kind}, not an array. Bound actions cannot be ` + + `merged into it, and \`strict: false\` skips validation, not this shape — \`composeStacks\` ` + + `refuses the same stack with the same code. Author 'objects' as an array or in the map form ` + + `(\`{ name: { … } }\`), or drop \`strict: false\` to have every schema check run.`, + issues, + ); + } + + // [#19785] A non-object ENTRY is refused with the same envelope, one zod + // issue per entry at `['objects', index]` (`expected: 'object'`) — the + // strict parse's own answer for it. Never handed on: the merge cannot read + // it, and a stack returned with it is a success whose objects are not all + // objects — the next consumer (plugin-object registration) then drops every + // object after it inside one warn-level catch, which is concealment, not + // leniency. + if (Array.isArray(declaredObjects) && declaredObjects.some((entry) => !isRecord(entry))) { + const parsed = z.array(z.looseObject({})).safeParse(declaredObjects); + const issues = parsed.success + ? [] + : parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] })); + const positions = declaredObjects + .map((entry, index) => (isRecord(entry) ? null : `#${index} (${entry === null ? 'null' : Array.isArray(entry) ? 'an array' : `a ${typeof entry}`})`)) + .filter((position): position is string => position !== null); + throw new StackSchemaInvalidError( + `defineStack validation failed: 'objects' holds ${positions.length === 1 ? 'an entry' : 'entries'} ` + + `that ${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}. Bound actions ` + + `cannot be merged into such an entry, and \`strict: false\` skips validation, not this shape. Author ` + + `every entry of 'objects' as an object definition, or drop \`strict: false\` to have every schema ` + + `check run.`, + issues as z.core.$ZodIssue[], + ); + } + // Honour `order` on the preserved top-level actions regardless of objects. const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions; const topChanged = sortedTop !== config.actions;