Skip to content

Commit c1dfa52

Browse files
fix(spec): refuse a malformed actions array in the non-strict bound-action merge (#19815)
Fixes #19799 Clause-②: no (narrowing) ## What changed `mergeActionsIntoObjects` (`packages/spec/src/stack.zod.ts`) — the merge that ends every `defineStack` call and `composeStacks` step 7 — now shape-guards every `actions` array it hands to `sortActionsByOrder`: the top-level one and each object's own. A non-array, or an array holding a non-object entry, is refused with `StackSchemaInvalidError` (`STACK_SCHEMA_INVALID`, `status: 422`), the zod issues at the strict parse's own paths, all findings in one refusal: | site | non-array | non-object entry | | :--- | :--- | :--- | | top-level `actions` | `['actions']` | `['actions', index]` | | an object's own `actions` | `['objects', i, 'actions']` | `['objects', i, 'actions', index]` | The non-array half reuses the shared `describeNonArrayCollection` helper, so the top-level refusal is the line `composeStacks` step 3 already draws for a non-array `actions`. `undefined` is the one non-array accepted (key absent). `sortActionsByOrder` is unchanged: its callers now only ever hand it an array of objects. Landing site: as expected — `mergeActionsIntoObjects`, plus a new test file and a changeset. No other package. ## Readings (at `origin/main` `e7a8bd43a4`, built dist) Before, `defineStack(config, { strict: false })`: - top-level `actions: 5` / `'abc'`: `TypeError: actions.some is not a function`, `code`/`status` undefined - top-level `actions: [null]`: `TypeError` reading `order` of null; `[5]`: accepted, entry handed on - object `actions: 5` / `{}`: `TypeError: actions.some is not a function`; `[null]`: `TypeError` reading `order` The strict door on the same inputs: `STACK_SCHEMA_INVALID` / 422 at `['actions']`, `['actions', 0]`, `['objects', 0, 'actions']`, `['objects', 0, 'actions', 0]` — so the object-level path is refused by the strict door, and this PR refuses it at the same path with the same envelope. After this PR every row above answers exactly that. ## Tests - New `packages/spec/src/define-stack-non-strict-actions-shape-refusal.test.ts`: 16 cases — each malformed shape at both sites asserts `code` + `status` + issue paths + `invalid_type`/`expected`; strict-door parity; controls (well-formed arrays merged and ordered by `order`, absent and empty `actions` accepted). - Ablation (fix removed by restoring `stack.zod.ts` from base, test imports `src` directly so no dist leg): 11 failed / 5 passed (5 bare-TypeError rows read `code` undefined, 6 falsy/entry rows were accepted); the 5 greens are the strict-door and control cases. Restore proven by `git hash-object` equal to the HEAD blob and an empty `git diff HEAD`. - `pnpm --filter @objectstack/spec test`: 519 files passed / 1 skipped, 15249 tests passed. `pnpm --filter @objectstack/spec typecheck`: exit 0 (test-layer debt held). - `dispatch-gates.mjs --commands`: 82 derived, 80 exit 0, 2 NOT MEASURED (exit 3 PREREQUISITE NOT MET — `check:dual-build-cjs-loads` and `check:type-check-debt` need the whole workspace built; CI's). - eslint (narrowed): the 2 touched TS files, `--format json` count 2, 0 errors / 0 warnings; `eslint.config.mjs` never enables type-aware linting, so this diff cannot move any untouched file's verdict. ## Acceptance notes - `composeStacks` also ends in this merge, so a hand-built input stack whose `actions` (top-level or on an object) carries a non-object entry such as `5` is now refused at step 7 with the same code instead of being carried into the artifact. The step-3 comment ("a non-object entry is concatenated as-is") still describes step 3 itself. - Out of scope, reported to the seat for filing: `composeStacks` over two stacks still raises a bare `TypeError` before step 7 for a top-level `actions: [null]` (reading `objectName`), an object's `actions: 5` (`.entries is not a function`) and an object's `actions: [null]` (reading `name`) — in `collectComposedActionKeyCollisions`, outside this card's file surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c118524 commit c1dfa52

3 files changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `defineStack(config, { strict: false })` refuses a malformed `actions` — top-level or an object's own — with the ADR-0112 envelope
6+
7+
**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.
8+
9+
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:
10+
11+
| `actions` under `strict: false` | before | after |
12+
| :--- | :--- | :--- |
13+
| 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']` |
14+
| 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]` |
15+
| 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]` |
16+
| 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']` |
17+
| 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]` |
18+
19+
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.
20+
21+
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.
22+
23+
No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`.
24+
25+
<!-- 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. -->
26+
27+
Clause-②: no (narrowing)
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)