Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/19799-merge-actions-actions-shape.md
Original file line number Diff line number Diff line change
@@ -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`.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves: no spec key, Zod schema, export, config field or stored metadata shape is added, removed, renamed or re-spelled. The strict defineStack parse already refused every input this refuses, so an authored stack that passed its schema builds exactly as before; what narrows is the runtime behaviour of the strict: false door, and of composeStacks on hand-built inputs, on inputs that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->

Clause-②: no (narrowing)
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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<string, unknown>) =>
defineStack({ manifest, ...overrides } as never, { strict: false });

const strictParse = (overrides: Record<string, unknown>) => 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([]);
});
});
57 changes: 57 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[] | 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;
Expand Down
Loading