Skip to content

Commit 8f9d578

Browse files
committed
fix(spec): refuse a malformed actions array in the non-strict bound-action merge
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7a8bd4 commit 8f9d578

2 files changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `defineStack(config, { strict: false })` refuses a malformed `actions` —
5+
* top-level or an object's own — with an ADR-0112 envelope (#19799).
6+
*
7+
* ## What was wrong
8+
*
9+
* The non-strict door skips the parse and hands the normalized input straight
10+
* to `mergeActionsIntoObjects`, which stable-sorts every `actions` array via
11+
* `sortActionsByOrder` — `actions.some(…)` with no shape guard. A non-array
12+
* `actions` (`5`, `'abc'`), top-level or on an object, raised a bare
13+
* `TypeError: actions.some is not a function`, and a `null` entry one reading
14+
* `order` off it — `code` and `status` both `undefined`. Any other non-object
15+
* entry (`[5]`) was handed on inside a success.
16+
*
17+
* ## What is pinned
18+
*
19+
* Every such shape is refused with the strict parse's own envelope —
20+
* `STACK_SCHEMA_INVALID`, `status: 422` — and the zod issue at the strict
21+
* parse's own path: `['actions']` / `['actions', index]` for the top-level
22+
* collection, `['objects', i, 'actions']` / `['objects', i, 'actions', index]`
23+
* for an object's own. The top-level line is the one `composeStacks` step 3
24+
* draws for the same key. Every refusal has its CONTROL: the same stack with
25+
* `actions` authored as an array of action objects is accepted, merged and
26+
* ordered.
27+
*/
28+
import { describe, it, expect } from 'vitest';
29+
import { defineStack } from './stack.zod';
30+
31+
type Envelope = Error & {
32+
code?: string;
33+
status?: number;
34+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
35+
};
36+
37+
/** The thrown value, or `null` when the call is accepted. */
38+
function refusal(fn: () => unknown): Envelope | null {
39+
try {
40+
fn();
41+
return null;
42+
} catch (e) {
43+
return e as Envelope;
44+
}
45+
}
46+
47+
const manifest = { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'app' as const };
48+
49+
const obj = (name: string, extra: Record<string, unknown> = {}) => ({
50+
name,
51+
label: name,
52+
fields: { title: { type: 'text' as const } },
53+
...extra,
54+
});
55+
56+
const url = { type: 'url' as const, target: 'https://example.com' };
57+
const bound = { name: 'b_open', label: 'Open', ...url, objectName: 'b_item', order: 2 };
58+
const embedded = { name: 'b_first', label: 'First', ...url, order: 1 };
59+
const global = { name: 'b_home', label: 'Home', ...url };
60+
61+
const nonStrict = (overrides: Record<string, unknown>) =>
62+
defineStack({ manifest, ...overrides } as never, { strict: false });
63+
64+
const strictParse = (overrides: Record<string, unknown>) => defineStack({ manifest, ...overrides } as never);
65+
66+
function expectEnvelope(refused: Envelope | null, paths: PropertyKey[][], expected: 'array' | 'object'): void {
67+
expect(refused).toBeInstanceOf(Error);
68+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
69+
expect(refused?.status).toBe(422);
70+
expect(refused?.issues?.map((issue) => issue.path)).toEqual(paths);
71+
expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === expected)).toBe(true);
72+
}
73+
74+
const nonArrays: Array<{ label: string; value: unknown }> = [
75+
{ label: 'a number', value: 5 },
76+
{ label: 'a string', value: 'abc' },
77+
{ label: 'null', value: null },
78+
{ label: 'false', value: false },
79+
];
80+
81+
describe('#19799 — defineStack strict: false refuses a malformed top-level `actions` with an ADR-0112 envelope', () => {
82+
for (const row of nonArrays) {
83+
it(`top-level \`actions\` as ${row.label} is refused at ['actions'] — never a bare TypeError`, () => {
84+
const refused = refusal(() => nonStrict({ objects: [obj('b_item')], actions: row.value }));
85+
expectEnvelope(refused, [['actions']], 'array');
86+
expect(refused?.message).toContain("'actions'");
87+
});
88+
}
89+
90+
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', () => {
91+
const refused = refusal(() => nonStrict({ objects: [obj('b_item')], actions: [null, global, 5] }));
92+
expectEnvelope(refused, [['actions', 0], ['actions', 2]], 'object');
93+
});
94+
95+
it('the strict door answers the same path with the same code — one dialect for one authored mistake', () => {
96+
expectEnvelope(refusal(() => strictParse({ actions: 5 })), [['actions']], 'array');
97+
expectEnvelope(refusal(() => strictParse({ actions: [null] })), [['actions', 0]], 'object');
98+
});
99+
});
100+
101+
describe("#19799 — defineStack strict: false refuses a malformed object's own `actions` with the same envelope", () => {
102+
for (const row of nonArrays) {
103+
it(`an object's \`actions\` as ${row.label} is refused at ['objects', i, 'actions']`, () => {
104+
const refused = refusal(() => nonStrict({ objects: [obj('b_other'), obj('b_item', { actions: row.value })] }));
105+
expectEnvelope(refused, [['objects', 1, 'actions']], 'array');
106+
expect(refused?.message).toContain("object 'b_item'");
107+
});
108+
}
109+
110+
it("a non-object entry in an object's `actions` is refused at ['objects', i, 'actions', index]", () => {
111+
const refused = refusal(() => nonStrict({ objects: [obj('b_item', { actions: [embedded, null, 7] })] }));
112+
expectEnvelope(refused, [['objects', 0, 'actions', 1], ['objects', 0, 'actions', 2]], 'object');
113+
});
114+
115+
it('findings at several sites are reported in ONE refusal, top-level first, as the strict parse reports them', () => {
116+
const refused = refusal(() =>
117+
nonStrict({ objects: [obj('b_item', { actions: 'x' }), obj('b_two', { actions: [null] })], actions: [5] }),
118+
);
119+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
120+
expect(refused?.status).toBe(422);
121+
expect(refused?.issues?.map((issue) => issue.path)).toEqual([
122+
['actions', 0],
123+
['objects', 0, 'actions'],
124+
['objects', 1, 'actions', 0],
125+
]);
126+
});
127+
128+
it('the strict door answers the same paths with the same code', () => {
129+
expectEnvelope(refusal(() => strictParse({ objects: [obj('b_item', { actions: 5 })] })), [['objects', 0, 'actions']], 'array');
130+
expectEnvelope(
131+
refusal(() => strictParse({ objects: [obj('b_item', { actions: [null] })] })),
132+
[['objects', 0, 'actions', 0]],
133+
'object',
134+
);
135+
});
136+
});
137+
138+
describe('#19799 — the controls: well-formed `actions` are accepted, merged and ordered', () => {
139+
it('arrays of action objects at both sites: the bound action is merged and every group is sorted by `order`', () => {
140+
const stack = nonStrict({
141+
objects: [obj('b_item', { actions: [embedded] })],
142+
actions: [bound, global],
143+
});
144+
expect(stack.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['b_first', 'b_open']);
145+
expect(stack.actions?.map((a) => a.name)).toEqual(['b_home', 'b_open']);
146+
});
147+
148+
it('an ABSENT `actions` at either site is not a malformed one — accepted', () => {
149+
const stack = nonStrict({ objects: [obj('b_item')] });
150+
expect(stack.actions).toBeUndefined();
151+
expect(stack.objects?.[0]?.actions).toBeUndefined();
152+
});
153+
154+
it('an empty `actions` array is accepted at either site', () => {
155+
const stack = nonStrict({ objects: [obj('b_item', { actions: [] })], actions: [] });
156+
expect(stack.actions).toEqual([]);
157+
expect(stack.objects?.[0]?.actions).toEqual([]);
158+
});
159+
});

‎packages/spec/src/stack.zod.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2996,6 +2996,63 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi
29962996
);
29972997
}
29982998

2999+
// [ADR-0112 · #19799] The same guard for every `actions` array this merge
3000+
// reads — the top-level one and each object's own — because
3001+
// `sortActionsByOrder` calls `.some` on it and reads `order` off each entry:
3002+
// a non-array raised a bare `TypeError` (`actions.some is not a function`),
3003+
// a `null` entry one reading `order`, and any other non-object entry was
3004+
// handed on inside a success. Each is refused with the strict parse's own
3005+
// envelope at the strict parse's own path — `['actions']` /
3006+
// `['actions', index]`, `['objects', i, 'actions']` /
3007+
// `['objects', i, 'actions', index]` — all findings in one refusal, as the
3008+
// parse reports them. `undefined` is the one non-array that is not
3009+
// malformed: the key is absent. This merge also ends `composeStacks`
3010+
// (step 7), which refuses a non-array top-level `actions` in its own step 3
3011+
// with this same code, so the message names both doors.
3012+
const actionsProblems: string[] = [];
3013+
const actionsIssues: z.core.$ZodIssue[] = [];
3014+
const guardActions = (prefix: readonly (string | number)[], label: string, declared: unknown): void => {
3015+
if (declared === undefined) return;
3016+
const reroot = (issue: z.core.$ZodIssue): z.core.$ZodIssue =>
3017+
({ ...issue, path: [...prefix, ...issue.path] }) as z.core.$ZodIssue;
3018+
if (!Array.isArray(declared)) {
3019+
const { kind, issues } = describeNonArrayCollection('actions', declared);
3020+
actionsProblems.push(`${label} is ${kind}, not an array`);
3021+
actionsIssues.push(...issues.map(reroot));
3022+
return;
3023+
}
3024+
const positions = declared
3025+
.map((entry, index) =>
3026+
isRecord(entry) ? null : `#${index} (${entry === null ? 'null' : Array.isArray(entry) ? 'an array' : `a ${typeof entry}`})`,
3027+
)
3028+
.filter((position): position is string => position !== null);
3029+
if (positions.length === 0) return;
3030+
const parsed = z.array(z.looseObject({})).safeParse(declared);
3031+
if (!parsed.success) {
3032+
actionsIssues.push(
3033+
...parsed.error.issues.map((issue) => reroot({ ...issue, path: ['actions', ...issue.path] } as z.core.$ZodIssue)),
3034+
);
3035+
}
3036+
actionsProblems.push(
3037+
`${label} holds ${positions.length === 1 ? 'an entry' : 'entries'} that ` +
3038+
`${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}`,
3039+
);
3040+
};
3041+
guardActions([], "'actions'", (config as { actions?: unknown }).actions);
3042+
for (const [index, obj] of ((declaredObjects as Record<string, unknown>[] | undefined) ?? []).entries()) {
3043+
const label = typeof obj.name === 'string' ? `object '${obj.name}'` : `object #${index}`;
3044+
guardActions(['objects', index], `${label}'s 'actions'`, obj.actions);
3045+
}
3046+
if (actionsProblems.length > 0) {
3047+
throw new StackSchemaInvalidError(
3048+
`Stack validation failed (the bound-action merge that ends \`defineStack\` and \`composeStacks\`): ` +
3049+
`${actionsProblems.join('; ')}. Actions cannot be merged or ordered by \`order\` in that shape, and ` +
3050+
`\`strict: false\` skips validation, not this shape. Author every 'actions' as an array of action ` +
3051+
`definitions, or drop \`strict: false\` to have every schema check run.`,
3052+
actionsIssues,
3053+
);
3054+
}
3055+
29993056
// Honour `order` on the preserved top-level actions regardless of objects.
30003057
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
30013058
const topChanged = sortedTop !== config.actions;

0 commit comments

Comments
 (0)