From 22c2465f3b712d11e6cb53a72fd3c8a811f77a79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:03:05 +0000 Subject: [PATCH 1/3] test(spec): pin composeStacks refusal of a non-array objects (red before the guard) Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude --- ...mpose-stacks-objects-shape-refusal.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/spec/src/compose-stacks-objects-shape-refusal.test.ts diff --git a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts new file mode 100644 index 00000000000..c9ce0019ff8 --- /dev/null +++ b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `composeStacks` step 2 (`mergeObjects`) refuses a stack whose `objects` is + * not an array (#18239, ruling B). + * + * ## What was wrong + * + * `mergeObjects` iterated `stack.objects` with no shape guard. An input that + * bypassed the strict `defineStack` parse (a hand-built stack object, or + * `defineStack(config, { strict: false })`) reached it in one of three ways, + * none of them a refusal: + * + * - a truthy non-iterable (`{ … }`, a number) raised a bare `TypeError` + * (`stack.objects is not iterable`) — `code` and `status` both `undefined`, + * outside the ADR-0112 envelope every other refusal in the file carries; + * - a falsy non-array (`null`, `''`, `0`, `false`) hit `if (!stack.objects) + * continue` and was skipped IN SILENCE — the composed artifact simply lacked + * that stack's objects, and nothing said so; + * - a non-array iterable (a `Set` of objects) composed as if it were an array. + * + * ## What is pinned + * + * The ruling: 「A composed artifact is complete or it is refused」. So a + * non-array `objects` is REFUSED with the envelope the strict parse raises for + * the very same authored defect — `STACK_SCHEMA_INVALID`, `status: 422`, the + * zod issue on `issues` with its `path` naming `objects` — and never skipped. + * The code names the rule (the stack does not match its schema); the message + * header names the pass (`composeStacks`), the same split + * `STACK_CROSS_REFERENCE_INVALID` makes across its two raise sites. + * + * A non-object ENTRY inside a well-formed `objects` array follows step 3's + * shape instead: skipped, and reported through the shared malformed-collection + * warning — the entry carries no object for composition to merge. + * + * Every refusal has its CONTROL: the same composition with `objects` authored + * as an array is accepted, so no refusal can satisfy the assertions for the + * wrong reason. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod'; +import { ERROR_CODE_LEDGER } from './api/error-code-ledger.zod'; + +type Envelope = Error & { + code?: string; + status?: number; + issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>; +}; + +/** The thrown value, or `null` when the composition is accepted. */ +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const }); + +const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } }); + +/** A stack object nobody parsed — the shape the card's reproduction used. */ +const handBuilt = (overrides: Record) => overrides as unknown as ObjectStackDefinition; + +/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */ +const unparsed = (overrides: Record) => + defineStack(overrides as never, { strict: false }); + +const good = () => defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')] }); + +const B1 = "'com.example.b' (stack #1)"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Every refusal row: how the malformed `objects` reaches composition. */ +const rows: Array<{ label: string; build: () => ObjectStackDefinition }> = [ + { label: 'a hand-built stack whose `objects` is a map', build: () => handBuilt({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } }) }, + { label: 'a hand-built stack whose `objects` is a number', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 5 }) }, + { label: 'a hand-built stack whose `objects` is a Set of objects', build: () => handBuilt({ manifest: mf('com.example.b'), objects: new Set([obj('b_item')]) }) }, + { label: '`strict: false` with `objects: null`', build: () => unparsed({ manifest: mf('com.example.b'), objects: null }) }, + { label: "`strict: false` with `objects: ''`", build: () => unparsed({ manifest: mf('com.example.b'), objects: '' }) }, + { label: '`strict: false` with `objects: 0`', build: () => unparsed({ manifest: mf('com.example.b'), objects: 0 }) }, + { label: '`strict: false` with `objects: false`', build: () => unparsed({ manifest: mf('com.example.b'), objects: false }) }, +]; + +describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-0112 envelope', () => { + for (const row of rows) { + describe(row.label, () => { + it('is refused with code STACK_SCHEMA_INVALID and status 422 — never a bare TypeError, never a skip', () => { + const refused = refusal(() => composeStacks([good(), row.build()])); + expect(refused).toBeInstanceOf(Error); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + }); + + it('names the key and the stack — the zod issue rides `issues` with `path: [objects]`', () => { + const refused = refusal(() => composeStacks([good(), row.build()])); + expect(refused?.issues).toHaveLength(1); + expect(refused?.issues?.[0]?.path).toEqual(['objects']); + expect(refused?.issues?.[0]?.code).toBe('invalid_type'); + expect(refused?.issues?.[0]?.expected).toBe('array'); + expect(refused?.message.startsWith(`composeStacks validation failed: ${B1}`)).toBe(true); + expect(refused?.message).toContain("'objects'"); + }); + + it('is refused whichever position the malformed stack holds', () => { + const refused = refusal(() => composeStacks([row.build(), good()])); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + }); + }); + } + + it('the control — the same composition with `objects` authored as an array is ACCEPTED', () => { + const composed = composeStacks([good(), unparsed({ manifest: mf('com.example.b'), objects: [obj('b_item')] })]); + expect(composed.objects?.map((o) => o.name)).toEqual(['a_item', 'b_item']); + }); + + it('an ABSENT `objects` is not a malformed one — still composed, still silent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const composed = composeStacks([good(), unparsed({ manifest: mf('com.example.b') })]); + expect(composed.objects?.map((o) => o.name)).toEqual(['a_item']); + expect(warn.mock.calls.map((c) => String(c[0])).some((w) => w.includes("'objects'"))).toBe(false); + }); + + it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => { + const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } } as never)); + expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refused?.status).toBe(422); + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain('STACK_SCHEMA_INVALID'); + }); + + it('a non-object ENTRY inside an array `objects` is skipped and reported — step 3\'s shape', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const composed = composeStacks([ + good(), + handBuilt({ manifest: mf('com.example.b'), objects: [null, obj('b_item'), 7] }), + ]); + expect(composed.objects?.map((o) => o.name)).toEqual(['a_item', 'b_item']); + const warnings = warn.mock.calls.map((c) => String(c[0])); + expect( + warnings.some((w) => w.includes('composeStacks') && w.includes("'objects'") && w.includes('not an object')), + ).toBe(true); + }); +}); From 862bc7bba3ba09431c29be07fd022d4d02024898 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:06:27 +0000 Subject: [PATCH 2/3] fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope mergeObjects iterated stack.objects unguarded: a truthy non-iterable raised a bare TypeError, a falsy non-array was skipped in silence, and a non-array iterable composed as if it were an array. It now refuses every non-array objects with STACK_SCHEMA_INVALID (status 422, the zod issue on issues with path objects), the code the strict parse raises for the same defect. A non-object entry inside an array objects is skipped and reported through the shared malformed-collection warning, and the artifact pass's object-name collector skips it too instead of dereferencing it. Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude --- ...mpose-stacks-objects-shape-refusal.test.ts | 5 +- packages/spec/src/stack.zod.ts | 65 +++++++++++++++++-- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts index c9ce0019ff8..e3c4816f42c 100644 --- a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts +++ b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts @@ -128,7 +128,10 @@ describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-011 }); it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => { - const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } } as never)); + // A number, not a map: the map form (`objects: { name: {…} }`) is a legal + // AUTHORING spelling that `defineStack` normalizes to the array before any + // check runs, so only a hand-built stack can carry a map into composition. + const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), objects: 5 } as never)); expect(refused?.code).toBe('STACK_SCHEMA_INVALID'); expect(refused?.status).toBe(422); expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain('STACK_SCHEMA_INVALID'); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 24064eabbda..66d0db3fe4c 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1797,6 +1797,10 @@ function collectObjectNames(config: ObjectStackDefinition): Set { const names = new Set(); if (config.objects) { for (const obj of config.objects) { + // [#18239] `composeStacks`' artifact pass reads its unparsed inputs through + // here, and `mergeObjects` skips a non-object entry rather than refusing + // it — so skip it here too instead of dereferencing it. + if (!isRecord(obj)) continue; names.add(obj.name); } } @@ -3681,9 +3685,22 @@ const warnedMalformedCollectionKeys = new Set(); * `defineStack` path rejects the shape outright. * @internal */ -function warnMalformedCollectionKey(key: string): void { - if (warnedMalformedCollectionKeys.has(key)) return; - warnedMalformedCollectionKeys.add(key); +function warnMalformedCollectionKey(key: string, shape: 'value' | 'entry' = 'value'): void { + // [#18239] `'entry'`: the collection IS an array but one of its entries is not + // an object (`mergeObjects`, where a non-array `objects` is refused instead). + // Deduplicated apart from the `'value'` notice, so neither silences the other. + const dedupKey = shape === 'value' ? key : `${key}[]`; + if (warnedMalformedCollectionKeys.has(dedupKey)) return; + warnedMalformedCollectionKeys.add(dedupKey); + if (shape === 'entry') { + console.warn( + `composeStacks: top-level key '${key}' is a collection but at least one stack carries an ` + + `entry in it that is not an object — that entry cannot be composed and was skipped. Author ` + + `every entry as an object, or run the stack through strict \`defineStack\` to have the ` + + `shape rejected where it is written.`, + ); + return; + } console.warn( `composeStacks: top-level key '${key}' is a collection (concatenated across stacks) but at ` + `least one stack carries a non-array value for it — that value cannot be composed and was ` + @@ -3994,8 +4011,46 @@ function mergeObjects( const collectionOwner = new Map>(); for (const [i, stack] of stacks.entries()) { - if (!stack.objects) continue; - for (const obj of stack.objects) { + // [ADR-0112 · #18239] Shape guard, because composition accepts inputs the + // strict parse never saw (a hand-built stack, `strict: false`). A non-array + // `objects` is REFUSED, never skipped: skipping it composes an artifact that + // silently lacks this stack's objects — a composed artifact is complete or + // it is refused. `undefined` is the one non-array that is not malformed: the + // key is simply absent. The code is the strict parse's own + // (`STACK_SCHEMA_INVALID`) because the defect is the same authored mistake + // that door refuses; the header names this pass, the split + // {@link StackCrossReferenceError} makes across its two raise sites. + const declared: unknown = (stack as { objects?: unknown }).objects; + if (declared === undefined) continue; + if (!Array.isArray(declared)) { + const parsed = z.array(z.unknown()).safeParse(declared); + const issues = parsed.success + ? [] + : parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] })); + const kind = + declared === null + ? 'null' + : typeof declared !== 'object' + ? `a ${typeof declared}` + : Object.getPrototypeOf(declared) === Object.prototype + ? 'an object' + : `a ${(declared as object).constructor?.name ?? 'non-plain'} object`; + throw new StackSchemaInvalidError( + `composeStacks validation failed: ${stackLabel(stack, i)} declares 'objects' as ${kind}, ` + + `not an array. Its objects cannot be composed, and skipping them would compose an artifact ` + + `that silently lacks them. Author 'objects' as an array (\`defineStack\` normalizes the map ` + + `form into one), or run the stack through strict \`defineStack\` to have the shape rejected ` + + `where it is written.`, + issues as z.core.$ZodIssue[], + ); + } + for (const obj of declared as Obj[]) { + // A non-object ENTRY carries no object to merge — step 3's shape: skip it + // and say so once, never dereference it into a bare `TypeError`. + if (!isRecord(obj)) { + warnMalformedCollectionKey('objects', 'entry'); + continue; + } const existing = map.get(obj.name); if (!existing) { map.set(obj.name, obj); From 927ea9bfa6a7f9780acb2327bf7bcc638c50b8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 03:16:54 +0000 Subject: [PATCH 3/3] chore(changeset): composeStacks non-array objects refusal Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude --- .changeset/18239-merge-objects-refusal.md | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/18239-merge-objects-refusal.md diff --git a/.changeset/18239-merge-objects-refusal.md b/.changeset/18239-merge-objects-refusal.md new file mode 100644 index 00000000000..c3807ebcc62 --- /dev/null +++ b/.changeset/18239-merge-objects-refusal.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +--- + +fix(spec): `composeStacks` refuses a stack whose `objects` is not an array, with the ADR-0112 envelope + +**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to crash on, skip in silence, or compose by accident. + +Step 2 of `composeStacks` (`mergeObjects`) iterated each input's `objects` with no shape guard. The strict `defineStack` parse already rejects a non-array `objects`, so the reachable population is an input that bypassed it — a hand-built stack object, or `defineStack(config, { strict: false })`. Measured before this change, composing a well-formed stack with such an input: + +| the second stack's `objects` | before | after | +| :--- | :--- | :--- | +| a map (`{ b_item: {…} }`) or a number | bare `TypeError: … is not iterable`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` | +| `null`, `''`, `0`, `false` | composed, the stack's objects silently absent | refused, `STACK_SCHEMA_INVALID`, `status: 422` | +| a `Set` of objects | composed as if it were an array | refused, `STACK_SCHEMA_INVALID`, `status: 422` | + +A composed artifact is complete or it is refused: skipping a stack's objects composes an artifact that silently lacks them, so no non-array `objects` is skipped. An absent `objects` (`undefined`) is not malformed and composes as before. The refusal carries the code the strict parse raises for the same authored mistake — one code for one defect, whichever door catches it — with the zod issue on `issues` (`path: ['objects']`, `expected: 'array'`) and a message naming the stack by manifest id and position. The map form is an authoring spelling `defineStack` normalizes before any check runs; a stack that reaches composition without passing through `defineStack` never had it normalized, and is refused like any other non-array. + +A non-object entry inside an array `objects` (`null`, a number) is skipped and reported once through the composer's malformed-collection warning, the shape step 3 gives a non-array collection; before, it raised a bare `TypeError` reading `name` off it. The artifact cross-reference pass skips such an entry too. + +No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`, and the error class stays module-local. + + + +Clause-②: no (narrowing)