Commit 55095cc
fix(spec): composeStacks refuses a non-array
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>objects with an ADR-0112 envelope (#19783)1 parent 01df025 commit 55095cc
3 files changed
Lines changed: 237 additions & 5 deletions
File tree
- .changeset
- packages/spec/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
Lines changed: 152 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1797 | 1797 | | |
1798 | 1798 | | |
1799 | 1799 | | |
| 1800 | + | |
| 1801 | + | |
| 1802 | + | |
| 1803 | + | |
1800 | 1804 | | |
1801 | 1805 | | |
1802 | 1806 | | |
| |||
3681 | 3685 | | |
3682 | 3686 | | |
3683 | 3687 | | |
3684 | | - | |
3685 | | - | |
3686 | | - | |
| 3688 | + | |
| 3689 | + | |
| 3690 | + | |
| 3691 | + | |
| 3692 | + | |
| 3693 | + | |
| 3694 | + | |
| 3695 | + | |
| 3696 | + | |
| 3697 | + | |
| 3698 | + | |
| 3699 | + | |
| 3700 | + | |
| 3701 | + | |
| 3702 | + | |
| 3703 | + | |
3687 | 3704 | | |
3688 | 3705 | | |
3689 | 3706 | | |
| |||
3994 | 4011 | | |
3995 | 4012 | | |
3996 | 4013 | | |
3997 | | - | |
3998 | | - | |
| 4014 | + | |
| 4015 | + | |
| 4016 | + | |
| 4017 | + | |
| 4018 | + | |
| 4019 | + | |
| 4020 | + | |
| 4021 | + | |
| 4022 | + | |
| 4023 | + | |
| 4024 | + | |
| 4025 | + | |
| 4026 | + | |
| 4027 | + | |
| 4028 | + | |
| 4029 | + | |
| 4030 | + | |
| 4031 | + | |
| 4032 | + | |
| 4033 | + | |
| 4034 | + | |
| 4035 | + | |
| 4036 | + | |
| 4037 | + | |
| 4038 | + | |
| 4039 | + | |
| 4040 | + | |
| 4041 | + | |
| 4042 | + | |
| 4043 | + | |
| 4044 | + | |
| 4045 | + | |
| 4046 | + | |
| 4047 | + | |
| 4048 | + | |
| 4049 | + | |
| 4050 | + | |
| 4051 | + | |
| 4052 | + | |
| 4053 | + | |
3999 | 4054 | | |
4000 | 4055 | | |
4001 | 4056 | | |
| |||
0 commit comments