Skip to content

Commit 55095cc

Browse files
fix(spec): composeStacks refuses a non-array objects with an ADR-0112 envelope (#19783)
Fixes #18239 Clause-②: no (narrowing) Implements ruling `5690859601` (batch #139 item 2, letter B): `composeStacks` step 2 (`mergeObjects`) **refuses** a stack whose `objects` is not an array, with an ADR-0112 envelope. It no longer raises a bare `TypeError`, and it no longer skips the stack. A non-object entry inside an array `objects` is skipped and reported through `warnMalformedCollectionKey`, the way step 3 handles a malformed collection. ## What changed `packages/spec/src/stack.zod.ts`, three hunks, none of them inside the regions open PR #19666 edits (`ObjectStackDefinitionSchema` at ~1408, `ComposeStacksOptionsSchema`, `preservePackageEntries`, the `composeStacks` docblock and body). I read that PR's hunk list before editing. 1. **`mergeObjects`, the ruled change.** If `objects` is `undefined`, the key is absent and the stack is skipped as before. Any other non-array value throws `StackSchemaInvalidError`: `code: 'STACK_SCHEMA_INVALID'`, `status: 422`. `issues` carries one real zod issue (`z.array(z.unknown()).safeParse` of the value, with `path` prefixed `['objects']`, so `code: 'invalid_type'` and `expected: 'array'`). The message starts `composeStacks validation failed:`, names the stack by manifest id and position, and names the key `'objects'`. A non-object entry is skipped and reported through `warnMalformedCollectionKey('objects', 'entry')`. 2. **`warnMalformedCollectionKey`** gains an optional `shape` argument. The default `'value'` path is unchanged byte for byte. The new `'entry'` path prints an accurate sentence ("an entry in it that is not an object"), because the existing sentence says "a non-array value", which is false for an entry. The two shapes are deduplicated separately. 3. **`collectObjectNames`** skips a non-object entry. Without this, step 3b (`collectArtifactCrossReferenceErrors`) reads the raw input's `objects` and still raises `TypeError: Cannot read properties of null (reading 'name')` on an entry that step 2 had just skipped. The ruling's entry half cannot hold end to end without it. This is one line outside the `mergeObjects` body that the claim fenced: same file, same defect class, not in #19666's hunks. **Why `STACK_SCHEMA_INVALID` and not a new code.** The ruling asks for a closed `error.code`. The strict `defineStack` parse already refuses this exact authored mistake with `STACK_SCHEMA_INVALID` (a `number` in `objects` raises it; the new test pins that). Reusing it gives one code for one defect, whichever door catches it. The header names the pass and the code names the rule, the same split `STACK_CROSS_REFERENCE_INVALID` already makes across its `defineStack` and `composeStacks` raise sites. It also adds no ledger row and no `ErrorCode` member, which keeps the claim's `Clause-②: no` true: the public face does not grow, only the accept set narrows. A `STACK_COMPOSE_*` spelling would also be wrong by that family's own docblock, which reserves it for disagreements between stacks. ## Red before the guard, green after The fixture is `packages/spec/src/compose-stacks-objects-shape-refusal.test.ts` (25 cases). The subject is imported from `./stack.zod` (source, not `dist`), so the ablation needs no rebuild. - **Red, on the base code** (test commit `22c2465f` before the fix existed): `Tests 23 failed | 2 passed (25)`. - **Green, after the fix** (`862bc7bb`): `Tests 25 passed (25)`. - **Ablation on the committed fix**, through `node scripts/ablation-replace.mjs`. It swapped the anchor `if (!Array.isArray(declared)) {` for the base's `if (!declared) continue;` behaviour (anchor 1 to 0, blob `66d0db3fe4cc` to `486f0a54245a`). Result: `Tests 21 failed | 4 passed (25)`. The 21 are exactly the per-row refusal cases. The tool then restored the file: blob equals HEAD `66d0db3fe4cc`, `git diff HEAD` empty. What the same composition (a well-formed stack plus a second stack whose `objects` is X) did at the ablated state compared with the fix, from a direct probe: | X | before | after | | :--- | :--- | :--- | | a map, or `5` | `TypeError` ("… is not iterable"), `code` and `status` undefined | `STACK_SCHEMA_INVALID`, 422 | | `null`, `''`, `0`, `false` | ACCEPTED, objects = `["a_item"]` (the stack's objects silently absent) | `STACK_SCHEMA_INVALID`, 422 | | a `Set` of objects | ACCEPTED, composed as if it were an array | `STACK_SCHEMA_INVALID`, 422 | The `strict: false` path is exercised: four rows go through `defineStack(config, { strict: false })`. ## The ruling's measurement: does skipping a malformed `permissions` / `data` change the composed artifact's content? **Yes, for a non-array value; no, for a non-object entry.** Probed at HEAD. Stack A has `permissions: [{ name: 'pa' }]`, and stack B has a hand-built non-array `permissions: { name: 'pb', … }`. The result is `composed.permissions = ["pa"]`: B's grant is absent from the artifact, and only the #5005 warning mentions it. `data` behaves the same way (`composed.data = ["a_item"]`, B's dataset absent). The loss happens in **step 3, the concat pass** (`CONCAT_ARRAY_FIELDS`), not in step 3b's collectors: #18212's skip there only affects validation. A non-object entry (`permissions: [null, {…}]`) is carried into the artifact as is (`[null, {"name":"pb",…}]`), so its content is unchanged. The ruling says this answer goes back to the card for the spec lane to file C. I have not widened anything here. ## Verification Run at HEAD `927ea9bfa6`. That commit only adds the changeset on top of `862bc7bb`, so no source changed after the test runs. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and each result below is its `VERDICT command-exit` line. - `pnpm --filter @objectstack/spec test` + `typecheck` (which includes `check:test-typecheck` over the test layer): exit 0. `Test Files 516 passed | 1 skipped (517)`, `Tests 15079 passed | 1 skipped | 1 todo`, and `check:test-typecheck: OK — 53 file(s) / 257 error(s) / 142 pinned signature(s) held`. The new test file compiles with no new debt. - `pnpm --filter @objectstack/spec build`, then `check:generated`: exit 0, `All 15 generated artifacts are up to date`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 82 commands. All were run and exit codes recorded before any pipe. `--ran` reconciliation: `82 derived famil(ies) accounted for — 80 run, 2 NOT-MEASURED`. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`. Both exit 3 (PREREQUISITE NOT MET) because they need every workspace package's `dist`, which means a full `pnpm build`. That is left to CI. - Three gates first exited 3 on a prerequisite and are green after I supplied it: `check-plugin-teardown-shape --self-test` (fetched its pinned control commit), `check:doc-formula-expressions` (built formula and lint), `check:lean-entry-closure` (built objectql). - `check-adr-0087-registration`: the changeset is detected as `[BREAKING+clause-②-narrowing]` with disposition `not-required (no-migration-prescription)`. - Lint: targeted, not a proven narrowing. `eslint --no-inline-config --format json` on the 2 changed `.ts` files gives files 2, errors 0, warnings 0. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot change any verdict on an untouched file. The repo-wide `pnpm lint` is CI's. ## Changeset `.changeset/18239-merge-objects-refusal.md`: `minor`, with the **BREAKING** banner (a public root export now refuses a class of input), a before/after table, the `Clause-②: no (narrowing)` line, and the ADR-0087 disposition `not-required (no-migration-prescription)`. No authorable key, export or stored shape moves, and the strict parse already refused every input this refuses. ## Acceptance notes - **Boundary, unchanged:** `composeStacks([oneStack])` returns `stacks[0]` untouched, so a single input is never refused here. This is the same declared boundary the artifact cross-reference pass states. - **Observation, not filed:** `defineStack(config, { strict: false })` with `objects` set to a `Set` returns without throwing. The map-form normalizer reads the Set as a map with no keys. Contrived, and no author writes it. - **Finding (reproducible, reported on the card for the seat to file):** the `strict: false` door crashes before composition is reached, in `mergeActionsIntoObjects`. `defineStack({ …, objects: 5 }, { strict: false })` gives `TypeError: config.objects.map is not a function`. `objects: [null, …]` gives `TypeError: Cannot read properties of null (reading 'actions')`. Both have `code` and `status` undefined. It is the same family, but a different function and a different door, so it is out of this card's scope. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01df025 commit 55095cc

3 files changed

Lines changed: 237 additions & 5 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec): `composeStacks` refuses a stack whose `objects` is not an array, with the ADR-0112 envelope
6+
7+
**BREAKING** — `composeStacks`, a public root export, now refuses a class of input it used to crash on, skip in silence, or compose by accident.
8+
9+
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:
10+
11+
| the second stack's `objects` | before | after |
12+
| :--- | :--- | :--- |
13+
| a map (`{ b_item: {…} }`) or a number | bare `TypeError: … is not iterable`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
14+
| `null`, `''`, `0`, `false` | composed, the stack's objects silently absent | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
15+
| a `Set` of objects | composed as if it were an array | refused, `STACK_SCHEMA_INVALID`, `status: 422` |
16+
17+
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.
18+
19+
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.
20+
21+
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.
22+
23+
<!-- 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. -->
24+
25+
Clause-②: no (narrowing)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `composeStacks` step 2 (`mergeObjects`) refuses a stack whose `objects` is
5+
* not an array (#18239, ruling B).
6+
*
7+
* ## What was wrong
8+
*
9+
* `mergeObjects` iterated `stack.objects` with no shape guard. An input that
10+
* bypassed the strict `defineStack` parse (a hand-built stack object, or
11+
* `defineStack(config, { strict: false })`) reached it in one of three ways,
12+
* none of them a refusal:
13+
*
14+
* - a truthy non-iterable (`{ … }`, a number) raised a bare `TypeError`
15+
* (`stack.objects is not iterable`) — `code` and `status` both `undefined`,
16+
* outside the ADR-0112 envelope every other refusal in the file carries;
17+
* - a falsy non-array (`null`, `''`, `0`, `false`) hit `if (!stack.objects)
18+
* continue` and was skipped IN SILENCE — the composed artifact simply lacked
19+
* that stack's objects, and nothing said so;
20+
* - a non-array iterable (a `Set` of objects) composed as if it were an array.
21+
*
22+
* ## What is pinned
23+
*
24+
* The ruling: 「A composed artifact is complete or it is refused」. So a
25+
* non-array `objects` is REFUSED with the envelope the strict parse raises for
26+
* the very same authored defect — `STACK_SCHEMA_INVALID`, `status: 422`, the
27+
* zod issue on `issues` with its `path` naming `objects` — and never skipped.
28+
* The code names the rule (the stack does not match its schema); the message
29+
* header names the pass (`composeStacks`), the same split
30+
* `STACK_CROSS_REFERENCE_INVALID` makes across its two raise sites.
31+
*
32+
* A non-object ENTRY inside a well-formed `objects` array follows step 3's
33+
* shape instead: skipped, and reported through the shared malformed-collection
34+
* warning — the entry carries no object for composition to merge.
35+
*
36+
* Every refusal has its CONTROL: the same composition with `objects` authored
37+
* as an array is accepted, so no refusal can satisfy the assertions for the
38+
* wrong reason.
39+
*/
40+
import { describe, it, expect, vi, afterEach } from 'vitest';
41+
import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod';
42+
import { ERROR_CODE_LEDGER } from './api/error-code-ledger.zod';
43+
44+
type Envelope = Error & {
45+
code?: string;
46+
status?: number;
47+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
48+
};
49+
50+
/** The thrown value, or `null` when the composition is accepted. */
51+
function refusal(fn: () => unknown): Envelope | null {
52+
try {
53+
fn();
54+
return null;
55+
} catch (e) {
56+
return e as Envelope;
57+
}
58+
}
59+
60+
const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const });
61+
62+
const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } });
63+
64+
/** A stack object nobody parsed — the shape the card's reproduction used. */
65+
const handBuilt = (overrides: Record<string, unknown>) => overrides as unknown as ObjectStackDefinition;
66+
67+
/** A stack through `defineStack`'s `strict: false` door, which skips the parse. */
68+
const unparsed = (overrides: Record<string, unknown>) =>
69+
defineStack(overrides as never, { strict: false });
70+
71+
const good = () => defineStack({ manifest: mf('com.example.a'), objects: [obj('a_item')] });
72+
73+
const B1 = "'com.example.b' (stack #1)";
74+
75+
afterEach(() => {
76+
vi.restoreAllMocks();
77+
});
78+
79+
/** Every refusal row: how the malformed `objects` reaches composition. */
80+
const rows: Array<{ label: string; build: () => ObjectStackDefinition }> = [
81+
{ label: 'a hand-built stack whose `objects` is a map', build: () => handBuilt({ manifest: mf('com.example.b'), objects: { b_item: obj('b_item') } }) },
82+
{ label: 'a hand-built stack whose `objects` is a number', build: () => handBuilt({ manifest: mf('com.example.b'), objects: 5 }) },
83+
{ 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')]) }) },
84+
{ label: '`strict: false` with `objects: null`', build: () => unparsed({ manifest: mf('com.example.b'), objects: null }) },
85+
{ label: "`strict: false` with `objects: ''`", build: () => unparsed({ manifest: mf('com.example.b'), objects: '' }) },
86+
{ label: '`strict: false` with `objects: 0`', build: () => unparsed({ manifest: mf('com.example.b'), objects: 0 }) },
87+
{ label: '`strict: false` with `objects: false`', build: () => unparsed({ manifest: mf('com.example.b'), objects: false }) },
88+
];
89+
90+
describe('#18239 — composeStacks refuses a non-array `objects` with an ADR-0112 envelope', () => {
91+
for (const row of rows) {
92+
describe(row.label, () => {
93+
it('is refused with code STACK_SCHEMA_INVALID and status 422 — never a bare TypeError, never a skip', () => {
94+
const refused = refusal(() => composeStacks([good(), row.build()]));
95+
expect(refused).toBeInstanceOf(Error);
96+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
97+
expect(refused?.status).toBe(422);
98+
});
99+
100+
it('names the key and the stack — the zod issue rides `issues` with `path: [objects]`', () => {
101+
const refused = refusal(() => composeStacks([good(), row.build()]));
102+
expect(refused?.issues).toHaveLength(1);
103+
expect(refused?.issues?.[0]?.path).toEqual(['objects']);
104+
expect(refused?.issues?.[0]?.code).toBe('invalid_type');
105+
expect(refused?.issues?.[0]?.expected).toBe('array');
106+
expect(refused?.message.startsWith(`composeStacks validation failed: ${B1}`)).toBe(true);
107+
expect(refused?.message).toContain("'objects'");
108+
});
109+
110+
it('is refused whichever position the malformed stack holds', () => {
111+
const refused = refusal(() => composeStacks([row.build(), good()]));
112+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
113+
expect(refused?.status).toBe(422);
114+
});
115+
});
116+
}
117+
118+
it('the control — the same composition with `objects` authored as an array is ACCEPTED', () => {
119+
const composed = composeStacks([good(), unparsed({ manifest: mf('com.example.b'), objects: [obj('b_item')] })]);
120+
expect(composed.objects?.map((o) => o.name)).toEqual(['a_item', 'b_item']);
121+
});
122+
123+
it('an ABSENT `objects` is not a malformed one — still composed, still silent', () => {
124+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
125+
const composed = composeStacks([good(), unparsed({ manifest: mf('com.example.b') })]);
126+
expect(composed.objects?.map((o) => o.name)).toEqual(['a_item']);
127+
expect(warn.mock.calls.map((c) => String(c[0])).some((w) => w.includes("'objects'"))).toBe(false);
128+
});
129+
130+
it('the strict door raises the SAME code for the same defect — one dialect for one authored mistake', () => {
131+
// A number, not a map: the map form (`objects: { name: {…} }`) is a legal
132+
// AUTHORING spelling that `defineStack` normalizes to the array before any
133+
// check runs, so only a hand-built stack can carry a map into composition.
134+
const refused = refusal(() => defineStack({ manifest: mf('com.example.b'), objects: 5 } as never));
135+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
136+
expect(refused?.status).toBe(422);
137+
expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain('STACK_SCHEMA_INVALID');
138+
});
139+
140+
it('a non-object ENTRY inside an array `objects` is skipped and reported — step 3\'s shape', () => {
141+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
142+
const composed = composeStacks([
143+
good(),
144+
handBuilt({ manifest: mf('com.example.b'), objects: [null, obj('b_item'), 7] }),
145+
]);
146+
expect(composed.objects?.map((o) => o.name)).toEqual(['a_item', 'b_item']);
147+
const warnings = warn.mock.calls.map((c) => String(c[0]));
148+
expect(
149+
warnings.some((w) => w.includes('composeStacks') && w.includes("'objects'") && w.includes('not an object')),
150+
).toBe(true);
151+
});
152+
});

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

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,10 @@ function collectObjectNames(config: ObjectStackDefinition): Set<string> {
17971797
const names = new Set<string>();
17981798
if (config.objects) {
17991799
for (const obj of config.objects) {
1800+
// [#18239] `composeStacks`' artifact pass reads its unparsed inputs through
1801+
// here, and `mergeObjects` skips a non-object entry rather than refusing
1802+
// it — so skip it here too instead of dereferencing it.
1803+
if (!isRecord(obj)) continue;
18001804
names.add(obj.name);
18011805
}
18021806
}
@@ -3681,9 +3685,22 @@ const warnedMalformedCollectionKeys = new Set<string>();
36813685
* `defineStack` path rejects the shape outright.
36823686
* @internal
36833687
*/
3684-
function warnMalformedCollectionKey(key: string): void {
3685-
if (warnedMalformedCollectionKeys.has(key)) return;
3686-
warnedMalformedCollectionKeys.add(key);
3688+
function warnMalformedCollectionKey(key: string, shape: 'value' | 'entry' = 'value'): void {
3689+
// [#18239] `'entry'`: the collection IS an array but one of its entries is not
3690+
// an object (`mergeObjects`, where a non-array `objects` is refused instead).
3691+
// Deduplicated apart from the `'value'` notice, so neither silences the other.
3692+
const dedupKey = shape === 'value' ? key : `${key}[]`;
3693+
if (warnedMalformedCollectionKeys.has(dedupKey)) return;
3694+
warnedMalformedCollectionKeys.add(dedupKey);
3695+
if (shape === 'entry') {
3696+
console.warn(
3697+
`composeStacks: top-level key '${key}' is a collection but at least one stack carries an ` +
3698+
`entry in it that is not an object — that entry cannot be composed and was skipped. Author ` +
3699+
`every entry as an object, or run the stack through strict \`defineStack\` to have the ` +
3700+
`shape rejected where it is written.`,
3701+
);
3702+
return;
3703+
}
36873704
console.warn(
36883705
`composeStacks: top-level key '${key}' is a collection (concatenated across stacks) but at ` +
36893706
`least one stack carries a non-array value for it — that value cannot be composed and was ` +
@@ -3994,8 +4011,46 @@ function mergeObjects(
39944011
const collectionOwner = new Map<string, Map<string, number>>();
39954012

39964013
for (const [i, stack] of stacks.entries()) {
3997-
if (!stack.objects) continue;
3998-
for (const obj of stack.objects) {
4014+
// [ADR-0112 · #18239] Shape guard, because composition accepts inputs the
4015+
// strict parse never saw (a hand-built stack, `strict: false`). A non-array
4016+
// `objects` is REFUSED, never skipped: skipping it composes an artifact that
4017+
// silently lacks this stack's objects — a composed artifact is complete or
4018+
// it is refused. `undefined` is the one non-array that is not malformed: the
4019+
// key is simply absent. The code is the strict parse's own
4020+
// (`STACK_SCHEMA_INVALID`) because the defect is the same authored mistake
4021+
// that door refuses; the header names this pass, the split
4022+
// {@link StackCrossReferenceError} makes across its two raise sites.
4023+
const declared: unknown = (stack as { objects?: unknown }).objects;
4024+
if (declared === undefined) continue;
4025+
if (!Array.isArray(declared)) {
4026+
const parsed = z.array(z.unknown()).safeParse(declared);
4027+
const issues = parsed.success
4028+
? []
4029+
: parsed.error.issues.map((issue) => ({ ...issue, path: ['objects', ...issue.path] }));
4030+
const kind =
4031+
declared === null
4032+
? 'null'
4033+
: typeof declared !== 'object'
4034+
? `a ${typeof declared}`
4035+
: Object.getPrototypeOf(declared) === Object.prototype
4036+
? 'an object'
4037+
: `a ${(declared as object).constructor?.name ?? 'non-plain'} object`;
4038+
throw new StackSchemaInvalidError(
4039+
`composeStacks validation failed: ${stackLabel(stack, i)} declares 'objects' as ${kind}, ` +
4040+
`not an array. Its objects cannot be composed, and skipping them would compose an artifact ` +
4041+
`that silently lacks them. Author 'objects' as an array (\`defineStack\` normalizes the map ` +
4042+
`form into one), or run the stack through strict \`defineStack\` to have the shape rejected ` +
4043+
`where it is written.`,
4044+
issues as z.core.$ZodIssue[],
4045+
);
4046+
}
4047+
for (const obj of declared as Obj[]) {
4048+
// A non-object ENTRY carries no object to merge — step 3's shape: skip it
4049+
// and say so once, never dereference it into a bare `TypeError`.
4050+
if (!isRecord(obj)) {
4051+
warnMalformedCollectionKey('objects', 'entry');
4052+
continue;
4053+
}
39994054
const existing = map.get(obj.name);
40004055
if (!existing) {
40014056
map.set(obj.name, obj);

0 commit comments

Comments
 (0)