Skip to content

Commit 9282578

Browse files
fix(spec): composeStacks' action-key collision pass skips malformed actions, so step 7 refuses them with the envelope (#19903)
Fixes #19816 Clause-②: no ## What changed `composeStacks` step 6, the cross-stack action-key collision pass (`collectComposedActionKeyCollisions` in `packages/spec/src/stack.zod.ts`), now reads only what is shaped like an action: - a top-level `actions` entry that is not an object (`isRecord` fails) is skipped; - a composed object's own `actions` that is not an array is skipped, and so is a non-object entry inside one. It throws nothing new. Every skipped input is still in the composed artifact: the entry is in the `actions` that step 3 concatenated, and the object is the same composed object. So step 7's existing guard in `mergeActionsIntoObjects` refuses each one with the `STACK_SCHEMA_INVALID` / `status: 422` envelope, at the composed artifact's own zod path. The stale in-function comment ("the concat pass drops it (and warns)") now says what step 3 has done since it gained its guard: it refuses a non-array `actions`. Landing site: only that function's region, plus one new test file and a changeset. Step 7's guard and its message are read, not edited. No other region of `stack.zod.ts` changes. No other package changes. ## Route: (a), skip and let step 7 refuse. Not (b), throw here I measured before choosing: with the collision pass removed in-tree (a wrapped `ablation-replace` mutation, restored and proven byte-equal to HEAD), step 7 alone answered **every** malformed row below with the envelope. So route (a) leaves one refusal, with one wording, for one condition. Route (b) would add a second copy of step 7's message in this pass for the same shapes. The fixed tree's answers are byte-for-byte what the bypass probe printed for every malformed row. The only row that differs is the real-collision control, which the bypass could not see by construction. (a) also fixes a misdiagnosis that (b) would have to handle separately. Measured on `origin/main`: two stacks that each carried a non-object entry the pass *could* read (`actions: ['x']`) were keyed `global:undefined` and refused as `STACK_COMPOSE_ACTION_KEY_COLLISION`, which is the wrong code for a schema defect. They are now `STACK_SCHEMA_INVALID`. Trade-off, stated rather than hidden: the index in a top-level issue path is the entry's index in the **composed** `actions`, for example `['actions', 1]` for stack B's first entry after stack A's one action. It is not the input stack's local index. That is the coordinate step 7 has always used for this artifact. An object's own `actions` is named by the object (`object 'b_item''s 'actions'`). ## Readings (hand-built stacks through `composeStacks`, `packages/spec/src` via `tsx`) Stack A is valid (one object, one global action). Stack B carries the malformed shape. | B's malformed shape | `origin/main` `1f89ba0d70` | step 6 bypassed (same tree) | this PR | | :-- | :-- | :-- | :-- | | top-level `actions: [null]` | `TypeError` reading `objectName`, code/status `undefined` | `STACK_SCHEMA_INVALID` 422 at `['actions', 1]` | same as bypass | | top-level `actions: [undefined]` | `TypeError` reading `objectName` | `STACK_SCHEMA_INVALID` 422 at `['actions', 1]` | same | | top-level `[valid, null]` | `TypeError` reading `objectName` | `STACK_SCHEMA_INVALID` 422 at `['actions', 2]` | same | | top-level `['x']` / `[5]` / `[[]]` | `STACK_SCHEMA_INVALID` 422 (already reached step 7) | same | same | | `['x']` in **both** stacks | `STACK_COMPOSE_ACTION_KEY_COLLISION` on `global:undefined` | `STACK_SCHEMA_INVALID` 422 at `['actions', 0]`, `['actions', 1]` | same as bypass | | object `actions: 5` / `'abc'` / `{}` / `true` | `TypeError: (obj.actions ?? []).entries is not a function` | `STACK_SCHEMA_INVALID` 422 at `['objects', 1, 'actions']` | same | | object `actions: null` / a `Map` | `STACK_SCHEMA_INVALID` 422 (already reached step 7) | same | same | | object `actions: [null]` / `[undefined]` | `TypeError` reading `name` | `STACK_SCHEMA_INVALID` 422 at `['objects', 1, 'actions', 0]` | same | | object `actions: 5`, `objectConflict: 'override'` | `TypeError … entries is not a function` | `STACK_SCHEMA_INVALID` 422 | same | | object `actions: [null]`, `objectConflict: 'merge'` | `TypeError` reading `name` | `STACK_SCHEMA_INVALID` 422 | same | | CONTROL: valid pair | accepted | accepted | accepted | | CONTROL: real `global:a_home` collision | `STACK_COMPOSE_ACTION_KEY_COLLISION` | accepted (pass removed) | `STACK_COMPOSE_ACTION_KEY_COLLISION` | ## Tests New file `packages/spec/src/compose-stacks-action-collision-shape.test.ts`, 19 cases. Each malformed shape above goes through `composeStacks`. The assertions: not a `TypeError`; `code` `STACK_SCHEMA_INVALID`; `status` 422; the exact issue paths with `invalid_type` / `expected`; and the message names the location (`'actions'` plus `#index`, or `object 'b_item'`). The message wording itself is not pinned. The controls: a valid pair composes; a real cross-stack collision is still `STACK_COMPOSE_ACTION_KEY_COLLISION`; and the walk goes **on** past a skipped entry. A collision declared right after a `null` entry is still found at the declaring stack's own site (`stack.actions[1]`, `objects['b_item'].actions[1]`). **Firing control** (fix committed first, then `origin/main`'s `stack.zod.ts` restored into the working tree only, then restored from HEAD, with the blob hash proven equal to `HEAD:packages/spec/src/stack.zod.ts` and `git diff HEAD` empty). On today's file the new file goes **13 failed / 6 passed**. The 13 red are every row that crashed (the assertion reads `expected TypeError: Cannot read properties of null… to not be an instance of TypeError`), the `global:undefined` misdiagnosis, and both walk-continues controls. The 6 green are the rows that already reached step 7 on `main` (`'x'`, `5`, `[]` top-level, and `'x'` embedded) plus the valid-pair and real-collision controls. Spec's vitest imports `./stack.zod` from `src`, so no rebuild sits between the mutation and the run. ### Runs, all at this branch's head `91bc309849` (the final commit; every figure below is from a run after it) Every build and test ran through `scripts/pm/os-verify-lock.sh`, so its wall-clock figures come from a shared box. - `@objectstack/spec` build: exit 0. `check-dts-emitted` 34/34. `authorable-surface.base.json` was not touched and `git status` stayed clean. - `@objectstack/spec` suite, `--project local`: **527 files / 15520 passed** (1 todo). `--project repo`: **35 files / 602 passed**. `typecheck` (`tsc --noEmit`, the scripts program, `check:test-typecheck`): exit 0. The new test file is inside the test program (`tsc -p tsconfig.test.json --listFilesOnly` counts 1), and `check:test-typecheck` reports no unledgered file. - Downstream suites, meaning the **consumers** of `@objectstack/spec`. These are the packages whose tests call `composeStacks` (found with `git grep composeStacks` over `packages/**/*.test.ts`), not the whole `...@objectstack/spec` consumer closure. They ran after a full `turbo run build --filter='./packages/*' --filter='./packages/*/*'` (72/72 tasks): - `@objectstack/metadata`: 53 files / 792 passed - `@objectstack/plugin-security`: 120 files / 2289 passed - `@objectstack/plugin-dev`: 8 files / 76 passed - `@objectstack/runtime`: `--project local` 272 files / 3800 passed (1 skipped); `--project repo` 2 files / 69 passed - `@objectstack/cli`: `--project unit` 222 files / 3141 passed. The 11 per-PR files that call `composeStacks`, across both tiers: 11 files / 118 passed. **Declared narrowing:** the rest of the `integration` tier is left to CI's `pnpm test`. The two `*.e2e.test.ts` files that call `composeStacks` (`build-multi-package-artifact`, `build-package-docs-attachment`) are in the nightly `OS_TEST_TIERS` tier, outside the per-PR population, and were not run here. - `@objectstack/dogfood`: the one file that calls `composeStacks` (`multi-package-artifact.dogfood.test.ts`), 1 file / 9 passed. **Declared narrowing:** the rest of the dogfood suite belongs to CI's Dogfood Regression Gate. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 82 commands at `91bc309849`, and all 82 ran with each exit code captured before any pipe. Four exited 3 (PREREQUISITE NOT MET) on the first pass: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. All four were rerun after the full build and exited 0. `--ran` reports: `82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)`. - ESLint, a proven narrowing rather than the repo-wide run (that belongs to CI): `eslint --no-inline-config --format json` over the 2 changed `.ts` files gives 2 files, 0 errors, 0 warnings. Neither file is ignored, so both are in the population `eslint.config.mjs` lints. That config never enables type-aware linting (no `parserOptions.project`, as its own comment states), so this diff cannot move a verdict on any untouched file. ## Changeset `.changeset/19816-compose-action-collision-shape.md`: `'@objectstack/spec': patch`. Derivation: `stack.zod.ts` ships in `@objectstack/spec`'s `files[]` twice, as `dist` and as `src/**/*.zod.ts`, so the fix publishes runtime bytes. It is a bug fix in a released package, so it takes `patch`. `Clause-②: no` without an arm: every input the pass now skips was a refusal before (a `TypeError`, or the wrong-coded collision) and is a refusal now (the envelope). An input that passed before contained no non-object entry in any array this pass walks, because step 7 refuses those, so it behaves identically. Skipping can only remove keys the pass would count, never add one, so no acceptance is newly refused and no refusal is newly accepted. ## Acceptance notes - **Step 7's wording for an `undefined` entry** reads `#1 (a undefined)` (`a ${typeof entry}`). It is cosmetic and lives in `mergeActionsIntoObjects`, which this card fences off, so it is untouched here and not filed. - **A record entry without a string `name`** (`{}`) still keys `global:undefined` in this pass: two stacks each carrying `actions: [{}]` are refused as `STACK_COMPOSE_ACTION_KEY_COLLISION`, and a single one composes. `composeStacks` does not schema-validate action records (step 7 guards shape only). Skipping name-less records here would turn that refusal into an acceptance, a widening, so the line stays where the card drew it: non-object entries and non-array `actions`. Noted, not filed. - **A `Symbol` `name`** still throws a bare `TypeError` (`Cannot convert a Symbol value to a string`) from this pass's key template. JSON or YAML cannot produce it, and type-checked TS cannot either. Noted, not filed. - **Ordering**: a real collision that sits beside a malformed entry is refused as the collision first, and step 7 refuses the malformed entry once the collision is fixed. Both refusals are envelopes. The pin is the walk-continues control above. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7a3495 commit 9282578

3 files changed

Lines changed: 232 additions & 3 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `composeStacks` refuses a malformed `actions` with the ADR-0112 envelope instead of crashing in its action-key collision pass
6+
7+
`composeStacks` checks the composed stacks for cross-stack action-key collisions before it binds each standalone action to its object. That check read every input's top-level `actions` entries, and every composed object's own `actions`, with no shape guard. On a hand-built input stack it therefore crashed before the bound-action merge could refuse the same input. `defineStack` already refuses these shapes at its own door, with or without `strict: false`, so a stack it built never reached this crash. Measured before this change:
8+
9+
| malformed input stack | before | after |
10+
| :--- | :--- | :--- |
11+
| top-level `actions: [null]` or `[undefined]` | bare `TypeError` reading `objectName`, `code` and `status` both `undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['actions', index]` |
12+
| an object's `actions: 5` (or `'abc'`, `{}`, `true`) | bare `TypeError: (obj.actions ?? []).entries is not a function` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions']` |
13+
| an object's `actions: [null]` or `[undefined]` | bare `TypeError` reading `name` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, issue at `['objects', i, 'actions', index]` |
14+
| two stacks that each carry a non-object top-level entry (`['x']`) | refused as `STACK_COMPOSE_ACTION_KEY_COLLISION` on the key `global:undefined` | refused, `STACK_SCHEMA_INVALID`, `status: 422`, one issue per entry |
15+
16+
The collision check now skips anything that declares no action key: a non-object entry, and an object's `actions` that is not an array. It does not word a refusal of its own. Every such input still reaches the bound-action merge, whose existing guard gives the one refusal for this condition. The index in each issue path is the entry's index in the composed artifact. Nothing that used to be accepted is refused now, and nothing that used to be refused is accepted. Well-formed stacks compose exactly as before, and a real cross-stack collision is still refused with `STACK_COMPOSE_ACTION_KEY_COLLISION`, including one that sits beside a skipped entry.
17+
18+
Fix: author every `actions` as an array of action definitions, or run each stack through strict `defineStack` to have the shape refused where it is written.
19+
20+
No code is added to the ADR-0112 ledger and no export changes: `STACK_SCHEMA_INVALID` is already registered under `@objectstack/spec`.
21+
22+
Clause-②: no
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `composeStacks` refuses a malformed `actions` with the ADR-0112 envelope,
5+
* never a bare `TypeError` out of its action-key collision pass (#19816).
6+
*
7+
* ## What was wrong
8+
*
9+
* Step 6 of `composeStacks` (`collectComposedActionKeyCollisions`) walked
10+
* every input's top-level `actions` and every composed object's own `actions`
11+
* with no element-shape guard, and it runs BEFORE step 7, the bound-action
12+
* merge whose guard (#19799) refuses exactly these shapes. So on a hand-built
13+
* input the collision pass crashed first, `code` and `status` both
14+
* `undefined`:
15+
*
16+
* - top-level `actions: [null]` / `[undefined]`: reading `objectName`;
17+
* - an object's `actions: 5` / `'abc'` / `{}` / `true`:
18+
* `(obj.actions ?? []).entries is not a function`;
19+
* - an object's `actions: [null]` / `[undefined]`: reading `name`.
20+
*
21+
* A non-object entry it could read (`'x'`) was keyed `global:undefined`, so
22+
* two stacks each carrying one were refused as a cross-stack ACTION KEY
23+
* COLLISION — the wrong code for a schema defect.
24+
*
25+
* ## What is pinned
26+
*
27+
* The pass skips what declares no key, and every such input reaches step 7,
28+
* which answers it with its one refusal: `STACK_SCHEMA_INVALID`, `status:
29+
* 422`, the zod issue at the composed artifact's own path. The last block is
30+
* the control: well-formed stacks still compose, and a real cross-stack
31+
* collision — including one sitting beside a skipped entry — is still
32+
* refused with the collision code at the declaring stack's own index.
33+
*/
34+
import { describe, it, expect } from 'vitest';
35+
import { composeStacks } from './stack.zod';
36+
37+
type Envelope = Error & {
38+
code?: string;
39+
status?: number;
40+
issues?: ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string } | string>;
41+
};
42+
43+
/** The thrown value, or `null` when the composition is accepted. */
44+
function refusal(fn: () => unknown): Envelope | null {
45+
try {
46+
fn();
47+
return null;
48+
} catch (e) {
49+
return e as Envelope;
50+
}
51+
}
52+
53+
const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const });
54+
55+
const act = (name: string, extra: Record<string, unknown> = {}) =>
56+
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });
57+
58+
const obj = (name: string, extra: Record<string, unknown> = {}) => ({
59+
name,
60+
label: name,
61+
fields: { title: { type: 'text' as const } },
62+
...extra,
63+
});
64+
65+
/** Stack A — well-formed, one object and one global action. */
66+
const valid = () => ({ manifest: mf('com.example.a'), objects: [obj('a_item')], actions: [act('a_home')] });
67+
/** Stack B, hand-built, with the given top-level `actions`. */
68+
const withTop = (actions: unknown) => ({ manifest: mf('com.example.b'), objects: [obj('b_item')], actions });
69+
/** Stack B, hand-built, whose object `b_item` carries the given `actions`. */
70+
const withOwn = (actions: unknown) => ({ manifest: mf('com.example.b'), objects: [obj('b_item', { actions })] });
71+
72+
const compose = (stacks: unknown[], options?: Record<string, unknown>) =>
73+
composeStacks(stacks as never, options as never);
74+
75+
function expectSchemaEnvelope(refused: Envelope | null, paths: PropertyKey[][], expected: 'array' | 'object'): void {
76+
expect(refused).toBeInstanceOf(Error);
77+
expect(refused).not.toBeInstanceOf(TypeError);
78+
expect(refused?.code).toBe('STACK_SCHEMA_INVALID');
79+
expect(refused?.status).toBe(422);
80+
const issues = (refused?.issues ?? []) as ReadonlyArray<{ code?: string; path?: readonly PropertyKey[]; expected?: string }>;
81+
expect(issues.map((issue) => issue.path)).toEqual(paths);
82+
expect(issues.every((issue) => issue.code === 'invalid_type' && issue.expected === expected)).toBe(true);
83+
}
84+
85+
// Stack A contributes one top-level action, so stack B's first entry sits at
86+
// composed index 1 — the index the artifact carries and the refusal names.
87+
const topEntries: Array<{ label: string; value: unknown }> = [
88+
{ label: 'null', value: null },
89+
{ label: 'undefined', value: undefined },
90+
{ label: 'a string', value: 'x' },
91+
{ label: 'a number', value: 5 },
92+
{ label: 'an array', value: [] },
93+
];
94+
95+
describe('#19816 — a non-object top-level `actions` entry is refused with the envelope, not a TypeError', () => {
96+
for (const row of topEntries) {
97+
it(`entry ${row.label} is refused at ['actions', 1]`, () => {
98+
const refused = refusal(() => compose([valid(), withTop([row.value])]));
99+
expectSchemaEnvelope(refused, [['actions', 1]], 'object');
100+
expect(refused?.message).toContain("'actions'");
101+
expect(refused?.message).toContain('#1 (');
102+
});
103+
}
104+
105+
it('a malformed entry beside a well-formed one is refused at its own composed index', () => {
106+
const refused = refusal(() => compose([valid(), withTop([act('b_home'), null])]));
107+
expectSchemaEnvelope(refused, [['actions', 2]], 'object');
108+
});
109+
110+
it("two stacks each carrying a non-object entry are a schema defect, NOT a 'global:undefined' key collision", () => {
111+
const refused = refusal(() => compose([{ ...valid(), actions: ['x'] }, withTop(['x'])]));
112+
expectSchemaEnvelope(refused, [['actions', 0], ['actions', 1]], 'object');
113+
expect(refused?.message).not.toContain('global:undefined');
114+
});
115+
});
116+
117+
describe("#19816 — a malformed object's own `actions` is refused with the envelope, not a TypeError", () => {
118+
const nonArrays: Array<{ label: string; value: unknown }> = [
119+
{ label: 'a number', value: 5 },
120+
{ label: 'a string', value: 'abc' },
121+
{ label: 'a plain object', value: {} },
122+
{ label: 'a boolean', value: true },
123+
];
124+
for (const row of nonArrays) {
125+
it(`\`actions\` as ${row.label} is refused at ['objects', 1, 'actions']`, () => {
126+
const refused = refusal(() => compose([valid(), withOwn(row.value)]));
127+
expectSchemaEnvelope(refused, [['objects', 1, 'actions']], 'array');
128+
expect(refused?.message).toContain("object 'b_item'");
129+
});
130+
}
131+
132+
const entries: Array<{ label: string; value: unknown }> = [
133+
{ label: 'null', value: null },
134+
{ label: 'undefined', value: undefined },
135+
{ label: 'a string', value: 'x' },
136+
];
137+
for (const row of entries) {
138+
it(`entry ${row.label} is refused at ['objects', 1, 'actions', 0]`, () => {
139+
const refused = refusal(() => compose([valid(), withOwn([row.value])]));
140+
expectSchemaEnvelope(refused, [['objects', 1, 'actions', 0]], 'object');
141+
expect(refused?.message).toContain("object 'b_item'");
142+
});
143+
}
144+
145+
it("under objectConflict 'override' and 'merge' the answer is the same envelope", () => {
146+
expectSchemaEnvelope(
147+
refusal(() => compose([valid(), withOwn(5)], { objectConflict: 'override' })),
148+
[['objects', 1, 'actions']],
149+
'array',
150+
);
151+
expectSchemaEnvelope(
152+
refusal(() => compose([valid(), withOwn([null])], { objectConflict: 'merge' })),
153+
[['objects', 1, 'actions', 0]],
154+
'object',
155+
);
156+
});
157+
});
158+
159+
describe('#19816 — the controls: well-formed stacks compose, and a real collision is still refused', () => {
160+
it('a valid pair composes, every action carried', () => {
161+
const composed = compose([valid(), withTop([act('b_home')])]);
162+
expect(composed.actions?.map((a) => a.name)).toEqual(['a_home', 'b_home']);
163+
});
164+
165+
it('a real cross-stack collision is still refused with the collision code', () => {
166+
const refused = refusal(() => compose([valid(), withTop([act('a_home')])]));
167+
expect(refused?.code).toBe('STACK_COMPOSE_ACTION_KEY_COLLISION');
168+
expect(refused?.status).toBe(422);
169+
expect(refused?.issues).toHaveLength(1);
170+
expect(String(refused?.issues?.[0])).toContain("'global:a_home'");
171+
});
172+
173+
it("the walk goes on past a skipped top-level entry — the collision names the declaring stack's own index", () => {
174+
const refused = refusal(() => compose([valid(), withTop([null, act('a_home')])]));
175+
expect(refused?.code).toBe('STACK_COMPOSE_ACTION_KEY_COLLISION');
176+
expect(String(refused?.issues?.[0])).toContain('stack.actions[1]');
177+
});
178+
179+
it("the walk goes on past a skipped embedded entry — an object's own collision is still found", () => {
180+
const refused = refusal(() =>
181+
compose([
182+
{ ...valid(), actions: [act('b_go', { objectName: 'b_item' })] },
183+
withOwn([null, act('b_go')]),
184+
]),
185+
);
186+
expect(refused?.code).toBe('STACK_COMPOSE_ACTION_KEY_COLLISION');
187+
expect(String(refused?.issues?.[0])).toContain("'b_item:b_go'");
188+
expect(String(refused?.issues?.[0])).toContain("objects['b_item'].actions[1]");
189+
});
190+
});

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4381,6 +4381,16 @@ function mergeObjects(
43814381
*
43824382
* Runs BEFORE `mergeActionsIntoObjects` so the composed output's own echo is
43834383
* not counted either. Returns one line per colliding key, in first-seen order.
4384+
*
4385+
* [ADR-0112 · #19816] Reads only what is shaped like an action. A non-object
4386+
* entry (`null`, `undefined`, a string, …) and an object's non-array `actions`
4387+
* declare no key, so they are SKIPPED here, never dereferenced into a bare
4388+
* `TypeError` and never keyed `global:undefined` (two such entries used to be
4389+
* reported as a cross-stack collision). Skipping loses nothing: the entry is
4390+
* still in the composed `actions` step 3 concatenated, and the object is the
4391+
* same composed object, so step 7's guard in `mergeActionsIntoObjects` refuses
4392+
* every one of them with its `STACK_SCHEMA_INVALID` / 422 envelope. That guard
4393+
* holds the one refusal for this condition; this pass does not word a second.
43844394
* @internal
43854395
*/
43864396
function collectComposedActionKeyCollisions(
@@ -4401,11 +4411,13 @@ function collectComposedActionKeyCollisions(
44014411
};
44024412

44034413
for (const [i, stack] of stacks.entries()) {
4404-
// A non-array `actions` never reaches the output: the concat pass drops it
4405-
// (and warns), so it declares nothing here either.
4414+
// An absent `actions` declares nothing. A present non-array one never
4415+
// reaches this pass: step 3 refuses it (`STACK_SCHEMA_INVALID`).
44064416
const declared = (stack as Record<string, unknown>).actions;
44074417
if (!Array.isArray(declared)) continue;
44084418
for (const [j, action] of (declared as Action[]).entries()) {
4419+
// A non-object entry declares no key; step 7 refuses it (see above).
4420+
if (!isRecord(action)) continue;
44094421
// Truthiness, not nullish: an empty-string `objectName` (type-legal;
44104422
// refused by ActionSchema's regex only under strict parse) keys as
44114423
// global here exactly as `collectDuplicateActionKeyErrors`,
@@ -4422,7 +4434,12 @@ function collectComposedActionKeyCollisions(
44224434
// and skipping would hide exactly the collisions this walk exists for.
44234435
throw new Error(`composeStacks internal error: no source stack recorded for composed object '${obj.name}'.`);
44244436
}
4425-
for (const [j, action] of (obj.actions ?? []).entries()) {
4437+
// An absent `actions` declares nothing; a non-array one and a non-object
4438+
// entry declare no key either, and step 7 refuses both (see above).
4439+
const embedded: unknown = obj.actions;
4440+
if (!Array.isArray(embedded)) continue;
4441+
for (const [j, action] of (embedded as Action[]).entries()) {
4442+
if (!isRecord(action)) continue;
44264443
note(obj.name, action.name, owner, `objects['${obj.name}'].actions[${j}]`);
44274444
}
44284445
}

0 commit comments

Comments
 (0)