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
25 changes: 25 additions & 0 deletions .changeset/18239-merge-objects-refusal.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- 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 composes exactly as before; what narrows is the runtime behaviour of composeStacks on inputs that bypassed that parse, and objectstack migrate meta has no document to rewrite for it. -->

Clause-②: no (narrowing)
152 changes: 152 additions & 0 deletions packages/spec/src/compose-stacks-objects-shape-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// 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<string, unknown>) => overrides as unknown as ObjectStackDefinition;

/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */
const unparsed = (overrides: Record<string, unknown>) =>
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', () => {
// 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');
});

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);
});
});
65 changes: 60 additions & 5 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1797,6 +1797,10 @@ function collectObjectNames(config: ObjectStackDefinition): Set<string> {
const names = new Set<string>();
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);
}
}
Expand Down Expand Up @@ -3681,9 +3685,22 @@ const warnedMalformedCollectionKeys = new Set<string>();
* `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 ` +
Expand Down Expand Up @@ -3994,8 +4011,46 @@ function mergeObjects(
const collectionOwner = new Map<string, Map<string, number>>();

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);
Expand Down
Loading