diff --git a/.changeset/19799-merge-actions-actions-shape.md b/.changeset/19799-merge-actions-actions-shape.md new file mode 100644 index 00000000000..05e7ae9f938 --- /dev/null +++ b/.changeset/19799-merge-actions-actions-shape.md @@ -0,0 +1,27 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `defineStack(config, { strict: false })` refuses a malformed `actions` — top-level or an object's own — 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 `actions`, or an `actions` array holding an entry that is not an object, at the top level or on 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 stable-sorts every `actions` array by `order` and read each one with no shape guard. Measured before this change: + +| `actions` under `strict: false` | before | after | +| :--- | :--- | :--- | +| top-level, a number or a string (`5`, `'abc'`) | bare `TypeError: actions.some is not a function`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions']` | +| top-level, an array holding `null` (`[null]`) | bare `TypeError` reading `order` of `null` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | +| top-level, an array holding another non-object (`[5]`) | returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['actions', index]` | +| an object's own, a non-array (`5`, `'abc'`, `{}`) | bare `TypeError: actions.some is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` | +| an object's own, an array holding a non-object (`[null]`, `[7]`) | bare `TypeError` reading `order` of `null`, or returned with the entry in place | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry at `['objects', i, 'actions', index]` | + +A falsy non-array (`null`, `false`) at either site, which the merge used to hand on untouched, is refused the same way. `strict: false` skips validation — cross-references and schema detail — and never promised to accept a shape the merge cannot read. Each refusal carries the code the strict parse raises for the same authored mistake, with the zod issues on `issues` at the strict parse's own paths, all findings in one refusal; the top-level line is the one `composeStacks` already draws for a non-array `actions`. The same merge ends `composeStacks`, so a hand-built input stack whose `actions` carries a non-object entry is now refused there with the same code instead of being carried into the artifact. Every row narrows: nothing that used to be refused is accepted now. An absent `actions` (`undefined`) is not malformed and behaves as before, and the top-level map form is still normalized to an array first. + +Fix: author every `actions` as an array of action definitions (the top-level one may also use 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/define-stack-non-strict-actions-shape-refusal.test.ts b/packages/spec/src/define-stack-non-strict-actions-shape-refusal.test.ts new file mode 100644 index 00000000000..48f7d24593d --- /dev/null +++ b/packages/spec/src/define-stack-non-strict-actions-shape-refusal.test.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `defineStack(config, { strict: false })` refuses a malformed `actions` — + * top-level or an object's own — with an ADR-0112 envelope (#19799). + * + * ## What was wrong + * + * The non-strict door skips the parse and hands the normalized input straight + * to `mergeActionsIntoObjects`, which stable-sorts every `actions` array via + * `sortActionsByOrder` — `actions.some(…)` with no shape guard. A non-array + * `actions` (`5`, `'abc'`), top-level or on an object, raised a bare + * `TypeError: actions.some is not a function`, and a `null` entry one reading + * `order` off it — `code` and `status` both `undefined`. Any other non-object + * entry (`[5]`) was handed on inside a success. + * + * ## What is pinned + * + * Every such shape is refused with the strict parse's own envelope — + * `STACK_SCHEMA_INVALID`, `status: 422` — and the zod issue at the strict + * parse's own path: `['actions']` / `['actions', index]` for the top-level + * collection, `['objects', i, 'actions']` / `['objects', i, 'actions', index]` + * for an object's own. The top-level line is the one `composeStacks` step 3 + * draws for the same key. Every refusal has its CONTROL: the same stack with + * `actions` authored as an array of action objects is accepted, merged and + * ordered. + */ +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, extra: Record = {}) => ({ + name, + label: name, + fields: { title: { type: 'text' as const } }, + ...extra, +}); + +const url = { type: 'url' as const, target: 'https://example.com' }; +const bound = { name: 'b_open', label: 'Open', ...url, objectName: 'b_item', order: 2 }; +const embedded = { name: 'b_first', label: 'First', ...url, order: 1 }; +const global = { name: 'b_home', label: 'Home', ...url }; + +const nonStrict = (overrides: Record) => + defineStack({ manifest, ...overrides } as never, { strict: false }); + +const strictParse = (overrides: Record) => defineStack({ manifest, ...overrides } as never); + +function expectEnvelope(refused: Envelope | null, paths: PropertyKey[][], expected: 'array' | 'object'): void { + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues?.map((issue) => issue.path)).toEqual(paths); + expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === expected)).toBe(true); +} + +const nonArrays: Array<{ label: string; value: unknown }> = [ + { label: 'a number', value: 5 }, + { label: 'a string', value: 'abc' }, + { label: 'null', value: null }, + { label: 'false', value: false }, +]; + +describe('#19799 — defineStack strict: false refuses a malformed top-level `actions` with an ADR-0112 envelope', () => { + for (const row of nonArrays) { + it(`top-level \`actions\` as ${row.label} is refused at ['actions'] — never a bare TypeError`, () => { + const refused = refusal(() => nonStrict({ objects: [obj('b_item')], actions: row.value })); + expectEnvelope(refused, [['actions']], 'array'); + expect(refused?.message).toContain("'actions'"); + }); + } + + it('a non-object ENTRY is refused, one issue per entry at [actions, index] — the card\'s `[null]` repro and a `[5]` that used to pass', () => { + const refused = refusal(() => nonStrict({ objects: [obj('b_item')], actions: [null, global, 5] })); + expectEnvelope(refused, [['actions', 0], ['actions', 2]], 'object'); + }); + + it('the strict door answers the same path with the same code — one dialect for one authored mistake', () => { + expectEnvelope(refusal(() => strictParse({ actions: 5 })), [['actions']], 'array'); + expectEnvelope(refusal(() => strictParse({ actions: [null] })), [['actions', 0]], 'object'); + }); +}); + +describe("#19799 — defineStack strict: false refuses a malformed object's own `actions` with the same envelope", () => { + for (const row of nonArrays) { + it(`an object's \`actions\` as ${row.label} is refused at ['objects', i, 'actions']`, () => { + const refused = refusal(() => nonStrict({ objects: [obj('b_other'), obj('b_item', { actions: row.value })] })); + expectEnvelope(refused, [['objects', 1, 'actions']], 'array'); + expect(refused?.message).toContain("object 'b_item'"); + }); + } + + it("a non-object entry in an object's `actions` is refused at ['objects', i, 'actions', index]", () => { + const refused = refusal(() => nonStrict({ objects: [obj('b_item', { actions: [embedded, null, 7] })] })); + expectEnvelope(refused, [['objects', 0, 'actions', 1], ['objects', 0, 'actions', 2]], 'object'); + }); + + it('findings at several sites are reported in ONE refusal, top-level first, as the strict parse reports them', () => { + const refused = refusal(() => + nonStrict({ objects: [obj('b_item', { actions: 'x' }), obj('b_two', { actions: [null] })], actions: [5] }), + ); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(refused?.issues?.map((issue) => issue.path)).toEqual([ + ['actions', 0], + ['objects', 0, 'actions'], + ['objects', 1, 'actions', 0], + ]); + }); + + it('the strict door answers the same paths with the same code', () => { + expectEnvelope(refusal(() => strictParse({ objects: [obj('b_item', { actions: 5 })] })), [['objects', 0, 'actions']], 'array'); + expectEnvelope( + refusal(() => strictParse({ objects: [obj('b_item', { actions: [null] })] })), + [['objects', 0, 'actions', 0]], + 'object', + ); + }); +}); + +describe('#19799 — the controls: well-formed `actions` are accepted, merged and ordered', () => { + it('arrays of action objects at both sites: the bound action is merged and every group is sorted by `order`', () => { + const stack = nonStrict({ + objects: [obj('b_item', { actions: [embedded] })], + actions: [bound, global], + }); + expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_first', 'b_open']); + expect(stack.actions?.map((a) => a.name)).toEqual(['b_home', 'b_open']); + }); + + it('an ABSENT `actions` at either site is not a malformed one — accepted', () => { + const stack = nonStrict({ objects: [obj('b_item')] }); + expect(stack.actions).toBeUndefined(); + expect(stack.objects?.[0]?.actions).toBeUndefined(); + }); + + it('an empty `actions` array is accepted at either site', () => { + const stack = nonStrict({ objects: [obj('b_item', { actions: [] })], actions: [] }); + expect(stack.actions).toEqual([]); + expect(stack.objects?.[0]?.actions).toEqual([]); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 52e2be92bce..deaf6a024c7 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2996,6 +2996,63 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi ); } + // [ADR-0112 · #19799] The same guard for every `actions` array this merge + // reads — the top-level one and each object's own — because + // `sortActionsByOrder` calls `.some` on it and reads `order` off each entry: + // a non-array raised a bare `TypeError` (`actions.some is not a function`), + // a `null` entry one reading `order`, and any other non-object entry was + // handed on inside a success. Each is refused with the strict parse's own + // envelope at the strict parse's own path — `['actions']` / + // `['actions', index]`, `['objects', i, 'actions']` / + // `['objects', i, 'actions', index]` — all findings in one refusal, as the + // parse reports them. `undefined` is the one non-array that is not + // malformed: the key is absent. This merge also ends `composeStacks` + // (step 7), which refuses a non-array top-level `actions` in its own step 3 + // with this same code, so the message names both doors. + const actionsProblems: string[] = []; + const actionsIssues: z.core.$ZodIssue[] = []; + const guardActions = (prefix: readonly (string | number)[], label: string, declared: unknown): void => { + if (declared === undefined) return; + const reroot = (issue: z.core.$ZodIssue): z.core.$ZodIssue => + ({ ...issue, path: [...prefix, ...issue.path] }) as z.core.$ZodIssue; + if (!Array.isArray(declared)) { + const { kind, issues } = describeNonArrayCollection('actions', declared); + actionsProblems.push(`${label} is ${kind}, not an array`); + actionsIssues.push(...issues.map(reroot)); + return; + } + const positions = declared + .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); + if (positions.length === 0) return; + const parsed = z.array(z.looseObject({})).safeParse(declared); + if (!parsed.success) { + actionsIssues.push( + ...parsed.error.issues.map((issue) => reroot({ ...issue, path: ['actions', ...issue.path] } as z.core.$ZodIssue)), + ); + } + actionsProblems.push( + `${label} holds ${positions.length === 1 ? 'an entry' : 'entries'} that ` + + `${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}`, + ); + }; + guardActions([], "'actions'", (config as { actions?: unknown }).actions); + for (const [index, obj] of ((declaredObjects as Record[] | undefined) ?? []).entries()) { + const label = typeof obj.name === 'string' ? `object '${obj.name}'` : `object #${index}`; + guardActions(['objects', index], `${label}'s 'actions'`, obj.actions); + } + if (actionsProblems.length > 0) { + throw new StackSchemaInvalidError( + `Stack validation failed (the bound-action merge that ends \`defineStack\` and \`composeStacks\`): ` + + `${actionsProblems.join('; ')}. Actions cannot be merged or ordered by \`order\` in that shape, and ` + + `\`strict: false\` skips validation, not this shape. Author every 'actions' as an array of action ` + + `definitions, or drop \`strict: false\` to have every schema check run.`, + actionsIssues, + ); + } + // 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;