fix(spec): refuse a malformed actions array in the non-strict bound-action merge - #19815
Conversation
…ction merge Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d4161be8ca70cfc550c547d4b1d8ddae7beaec18 && git checkout d4161be8ca70cfc550c547d4b1d8ddae7beaec18
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eaa0f4a81a0146250f083df2b9d000b75dbec61 1a108852475fa4d5837b41b38abe77eaf567bb1d && git checkout -B drift-repro 6eaa0f4a81a0146250f083df2b9d000b75dbec61 && git merge --no-ff 1a108852475fa4d5837b41b38abe77eaf567bb1d
node scripts/docs-audit/affected-docs.mjs --json 6eaa0f4a81a0146250f083df2b9d000b75dbec61 |
Contract reviewServed-tier: ① Derived judgments
② Semver levelChangeset ③ Boundary flags
Implemented-by: VERDICT: PASS Rendered by an isolated at-tier review subagent, adopted by Generated by Claude Code |
Fixes #19799
Clause-②: no (narrowing)
What changed
mergeActionsIntoObjects(packages/spec/src/stack.zod.ts) — the merge that ends everydefineStackcall andcomposeStacksstep 7 — now shape-guards everyactionsarray it hands tosortActionsByOrder: the top-level one and each object's own. A non-array, or an array holding a non-object entry, is refused withStackSchemaInvalidError(STACK_SCHEMA_INVALID,status: 422), the zod issues at the strict parse's own paths, all findings in one refusal:actions['actions']['actions', index]actions['objects', i, 'actions']['objects', i, 'actions', index]The non-array half reuses the shared
describeNonArrayCollectionhelper, so the top-level refusal is the linecomposeStacksstep 3 already draws for a non-arrayactions.undefinedis the one non-array accepted (key absent).sortActionsByOrderis unchanged: its callers now only ever hand it an array of objects.Landing site: as expected —
mergeActionsIntoObjects, plus a new test file and a changeset. No other package.Readings (at
origin/maine7a8bd43a4, built dist)Before,
defineStack(config, { strict: false }):actions: 5/'abc':TypeError: actions.some is not a function,code/statusundefinedactions: [null]:TypeErrorreadingorderof null;[5]: accepted, entry handed onactions: 5/{}:TypeError: actions.some is not a function;[null]:TypeErrorreadingorderThe strict door on the same inputs:
STACK_SCHEMA_INVALID/ 422 at['actions'],['actions', 0],['objects', 0, 'actions'],['objects', 0, 'actions', 0]— so the object-level path is refused by the strict door, and this PR refuses it at the same path with the same envelope. After this PR every row above answers exactly that.Tests
packages/spec/src/define-stack-non-strict-actions-shape-refusal.test.ts: 16 cases — each malformed shape at both sites assertscode+status+ issue paths +invalid_type/expected; strict-door parity; controls (well-formed arrays merged and ordered byorder, absent and emptyactionsaccepted).stack.zod.tsfrom base, test importssrcdirectly so no dist leg): 11 failed / 5 passed (5 bare-TypeError rows readcodeundefined, 6 falsy/entry rows were accepted); the 5 greens are the strict-door and control cases. Restore proven bygit hash-objectequal to the HEAD blob and an emptygit diff HEAD.pnpm --filter @objectstack/spec test: 519 files passed / 1 skipped, 15249 tests passed.pnpm --filter @objectstack/spec typecheck: exit 0 (test-layer debt held).dispatch-gates.mjs --commands: 82 derived, 80 exit 0, 2 NOT MEASURED (exit 3 PREREQUISITE NOT MET —check:dual-build-cjs-loadsandcheck:type-check-debtneed the whole workspace built; CI's).--format jsoncount 2, 0 errors / 0 warnings;eslint.config.mjsnever enables type-aware linting, so this diff cannot move any untouched file's verdict.Acceptance notes
composeStacksalso ends in this merge, so a hand-built input stack whoseactions(top-level or on an object) carries a non-object entry such as5is now refused at step 7 with the same code instead of being carried into the artifact. The step-3 comment ("a non-object entry is concatenated as-is") still describes step 3 itself.composeStacksover two stacks still raises a bareTypeErrorbefore step 7 for a top-levelactions: [null](readingobjectName), an object'sactions: 5(.entries is not a function) and an object'sactions: [null](readingname) — incollectComposedActionKeyCollisions, outside this card's file surface.Generated by Claude Code