Skip to content

Commit c5a865c

Browse files
committed
fix(spec): defineStack strict:false refuses a non-object objects entry too
A non-object entry in `objects` is now refused with the same envelope (STACK_SCHEMA_INVALID, 422, one zod issue per entry at ['objects', index], expected 'object') instead of being handed on untouched, which widened the accept set and let plugin-object registration drop every later object. Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88a0b0f commit c5a865c

3 files changed

Lines changed: 51 additions & 19 deletions

File tree

‎.changeset/19785-merge-actions-shape-refusal.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@
44

55
fix(spec): `defineStack(config, { strict: false })` refuses a non-array `objects` with the ADR-0112 envelope
66

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.
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 `objects`, or an `objects` array holding an entry that is not an object.
88

99
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:
1010

1111
| `objects` under `strict: false` | before | after |
1212
| :--- | :--- | :--- |
1313
| 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` |
1414
| `null`, `''`, `0`, `false` | returned untouched, refused one call later by `composeStacks` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
15-
| an array holding `null` (`[null, obj]`) | bare `TypeError` reading `actions` off `null` | accepted: the entry is handed on as written, the objects beside it get their bound actions |
15+
| 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]` |
16+
| 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]` |
1617

17-
`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'`), the same line `composeStacks` draws for the same key. An absent `objects` (`undefined`) is not malformed and behaves as before; the map form (`{ name: { … } }`) is still normalized to an array first and accepted.
18+
`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.
1819

19-
Fix: author `objects` as an array or in the map form, or drop `strict: false` to have every schema check run.
20+
Fix: author `objects` as an array of object definitions or in the map form, or drop `strict: false` to have every schema check run.
2021

2122
No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`.
2223

‎packages/spec/src/define-stack-non-strict-objects-shape-refusal.test.ts‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
* `status: 422`, the zod issue on `issues` at `path: ['objects']` — the same
2424
* line `composeStacks` step 2 draws for the same key.
2525
*
26-
* A non-object ENTRY is handed on exactly as written (nothing is lost at this
27-
* door; composition is where it would be, and it reports it there).
26+
* A non-object ENTRY is refused with the same envelope, one zod issue per
27+
* entry at `path: ['objects', index]` (`expected: 'object'`): handing it on
28+
* would return a success whose objects are not all objects, and the next
29+
* consumer (plugin-object registration) drops every object after it.
2830
*
2931
* Every refusal has its CONTROL: the same stack with `objects` authored as an
3032
* array, or as the map form, is accepted and its bound actions are merged.
@@ -97,13 +99,23 @@ describe('#19785 — defineStack strict: false refuses a non-array `objects` wit
9799
expect(stack.actions?.map((a) => a.name)).toEqual(['b_open']);
98100
});
99101

100-
it('a non-object ENTRY is handed on as written, the object beside it still merged — never a bare TypeError', () => {
101-
const stack = nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] });
102-
const objects = stack.objects as unknown[];
103-
expect(objects).toHaveLength(3);
104-
expect(objects[0]).toBeNull();
105-
expect(objects[2]).toBe(7);
106-
expect((objects[1] as { actions?: Array<{ name: string }> }).actions?.map((a) => a.name)).toEqual(['b_open']);
102+
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', () => {
103+
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item'), 7], actions: [bound] }));
104+
expect(refused).toBeInstanceOf(Error);
105+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
106+
expect(refused?.status).toBe(422);
107+
expect(refused?.issues?.map((issue) => issue.path)).toEqual([
108+
['objects', 0],
109+
['objects', 2],
110+
]);
111+
expect(refused?.issues?.every((issue) => issue.code === 'invalid_type' && issue.expected === 'object')).toBe(true);
112+
});
113+
114+
it('a single non-object entry beside a good one is refused too — the card\'s `[null, obj]` repro', () => {
115+
const refused = refusal(() => nonStrict({ objects: [null, obj('b_item')] }));
116+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
117+
expect(refused?.status).toBe(422);
118+
expect(refused?.issues?.map((issue) => issue.path)).toEqual([['objects', 0]]);
107119
});
108120

109121
it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => {

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

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2976,6 +2976,31 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi
29762976
);
29772977
}
29782978

2979+
// [#19785] A non-object ENTRY is refused with the same envelope, one zod
2980+
// issue per entry at `['objects', index]` (`expected: 'object'`) — the
2981+
// strict parse's own answer for it. Never handed on: the merge cannot read
2982+
// it, and a stack returned with it is a success whose objects are not all
2983+
// objects — the next consumer (plugin-object registration) then drops every
2984+
// object after it inside one warn-level catch, which is concealment, not
2985+
// leniency.
2986+
if (Array.isArray(declaredObjects) && declaredObjects.some((entry) => !isRecord(entry))) {
2987+
const parsed = z.array(z.looseObject({})).safeParse(declaredObjects);
2988+
const issues = parsed.success
2989+
? []
2990+
: parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] }));
2991+
const positions = declaredObjects
2992+
.map((entry, index) => (isRecord(entry) ? null : `#${index} (${entry === null ? 'null' : Array.isArray(entry) ? 'an array' : `a ${typeof entry}`})`))
2993+
.filter((position): position is string => position !== null);
2994+
throw new StackSchemaInvalidError(
2995+
`defineStack validation failed: 'objects' holds ${positions.length === 1 ? 'an entry' : 'entries'} ` +
2996+
`that ${positions.length === 1 ? 'is' : 'are'} not an object — ${positions.join(', ')}. Bound actions ` +
2997+
`cannot be merged into such an entry, and \`strict: false\` skips validation, not this shape. Author ` +
2998+
`every entry of 'objects' as an object definition, or drop \`strict: false\` to have every schema ` +
2999+
`check run.`,
3000+
issues as z.core.$ZodIssue[],
3001+
);
3002+
}
3003+
29793004
// Honour `order` on the preserved top-level actions regardless of objects.
29803005
const sortedTop = config.actions ? sortActionsByOrder(config.actions) : config.actions;
29813006
const topChanged = sortedTop !== config.actions;
@@ -2999,12 +3024,6 @@ function mergeActionsIntoObjects(config: ObjectStackDefinition): ObjectStackDefi
29993024
// references, consistent with mergeObjects() and Zod output).
30003025
let objectsChanged = false;
30013026
const newObjects = config.objects.map((obj) => {
3002-
// [#19785] A non-object ENTRY (only reachable unparsed) carries no actions
3003-
// to merge into: it is handed on exactly as written, never dereferenced.
3004-
// Not dropped and not warned about here — nothing is lost at this door,
3005-
// which is what a scalar entry already got; `composeStacks` is where such
3006-
// an entry would be lost, and it skips and reports it there.
3007-
if (!isRecord(obj as unknown)) return obj;
30083027
const base = obj.actions ?? [];
30093028
// Idempotent (#14847): append only the bound actions `base` does not carry
30103029
// ALREADY — judged by identity, never by equality. The one way an entry of

0 commit comments

Comments
 (0)