From eeddce00184b4c983e3cdd8e7e0de9c1b2d22822 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 04:25:46 +0000 Subject: [PATCH] fix(spec): reject unknown keys on nested action-param literals at compile time ObjectSchema.create() infers its generic from the argument, so tsc's excess-property (freshness) checking never fires at any depth; the existing NoExcessObjectKeys map compensated only at the top level. A typo'd key on an actions[].params[] literal passed tsc clean and was caught only by ActionParamSchema's strict parse at module load. Mirror the same Record map over each element of each action's params array so the typo becomes a located tsc error at the authoring site. Compile-layer signal only: the strict parse at module load stays the enforcement of record; zero changes to any Zod schema's accept/reject or to parse timing. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01JvjTCjJQn9zSTXEhUKgT7s --- .../action-param-excess-keys-compile.md | 22 ++++++ packages/spec/src/data/object.test.ts | 74 +++++++++++++++++++ packages/spec/src/data/object.zod.ts | 38 +++++++++- 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .changeset/action-param-excess-keys-compile.md diff --git a/.changeset/action-param-excess-keys-compile.md b/.changeset/action-param-excess-keys-compile.md new file mode 100644 index 0000000000..da8ec49f5c --- /dev/null +++ b/.changeset/action-param-excess-keys-compile.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): `ObjectSchema.create()` rejects unknown keys on nested action-param literals at compile time (#12615) + +`create()` infers its generic from the argument, so TypeScript's +excess-property (freshness) checking never fires at any depth; the existing +`NoExcessObjectKeys` map compensated only at the top level. A typo'd key on an +`actions[].params[]` literal (measured: `carryOverX` on +`sys-permission-set.object.ts`) therefore passed `tsc` clean and was caught +only by `ActionParamSchema`'s strict parse at module load. + +The same `Record` map is now mirrored over each element of +each action's `params` array, so the typo becomes a located `tsc` error at the +authoring site (`error TS2322 … 'true' is not assignable to 'never'` pointing +at the unknown key). + +Compile-layer signal only — shipped as `patch` because no working code +changes meaning: the strict parse at module load stays the enforcement of +record, nothing changes in what parses or when, and every literal the new +constraint refuses was already refused (later, at import) by that parse. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index d247cc2592..6a59171602 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1350,6 +1350,80 @@ describe('ObjectSchema.create()', () => { expect(obj.validations).toEqual([]); }); }); + + // [#12615] The #1535 compile-layer rejection, one level DOWN. `create()` + // infers `T` from its argument, so tsc's excess-property (freshness) check + // never fires at ANY depth; the top-level `Record<…, never>` map compensated + // only for top-level keys, and a typo'd key on a nested action-param literal + // (measured 2026-08-26: `carryOverX` on `sys-permission-set.object.ts`) + // passed the package typecheck CLEAN — caught only by `ActionParamSchema`'s + // strict parse at module load. `NoExcessNestedActionParams` mirrors the map + // over `actions[].params[]`; these pins hold both layers: + // - the `@ts-expect-error` line is the COMPILE pin — if the nested map is + // removed, the directive goes unused and `check:test-typecheck` fails; + // - the `.toThrow` is the RUNTIME pin — the strict parse stays the + // enforcement of record, unchanged, for non-literal (dynamic) configs. + describe('excess-key rejection on nested action-param literals (#12615)', () => { + it('a typo\'d param key is refused at compile time AND still refused by the load-time strict parse', () => { + expect(() => ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + actions: [ + { + name: 'clone_demo', + label: 'Clone', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'POST', + target: '/api/v1/data/demo', + params: [ + { + field: 'status', + defaultFromRow: true, + carryOver: true, + // @ts-expect-error — `carryOverX` is not an ActionParam key (the #12615 compile pin) + carryOverX: true, + }, + ], + }, + ], + })).toThrow(/carryOverX/); + }); + + it('positive control: every canonical param spelling still compiles and parses untouched', () => { + const obj = ObjectSchema.create({ + name: 'demo', + fields: { status: { type: 'text' } }, + actions: [ + { + // An action with NO params — the nested map must leave it alone. + name: 'activate_demo', + label: 'Activate', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'PATCH', + target: '/api/v1/data/demo/{id}', + }, + { + name: 'clone_demo', + label: 'Clone', + mode: 'custom', + locations: ['list_item'], + type: 'api', + method: 'POST', + target: '/api/v1/data/demo', + params: [ + { name: 'label', label: 'New Name', type: 'text', required: true, helpText: 'display name' }, + { field: 'status', defaultFromRow: true, carryOver: true }, + ], + }, + ], + }); + expect(obj.actions).toHaveLength(2); + }); + }); }); // ============================================================================ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index cb00c1b9f1..29ff73b851 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { FieldSchema } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; -import { ActionSchema } from '../ui/action.zod'; +import { ActionSchema, type ActionParam } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; /** @@ -2358,7 +2358,41 @@ function unknownKeyError(objectName: unknown, unknownKeys: string[], knownKeys: * silent strip into a `tsc` error at the authoring site as well as at build. */ type NoExcessObjectKeys = T & - Record>, never>; + Record>, never> & + NoExcessNestedActionParams; + +/** + * [#12615] Extends the compile-time excess-key rejection one level DOWN, to the + * action-param literals nested inside `actions[].params[]`. + * + * Why the top-level trick alone does not reach them: `create()` infers `T` + * from the argument itself, so the argument always matches `T` exactly and + * TypeScript's excess-property (freshness) checking never fires — at any + * depth. {@link NoExcessObjectKeys} compensates at the top level by mapping + * every non-schema key to `never`; nested literals had no such map, so a + * typo'd param key (measured 2026-08-26: `carryOverX` on + * `sys-permission-set.object.ts`) sailed through `tsc` and was caught only by + * `ActionParamSchema`'s strict parse at module load. This mirrors the same + * map over each element of each action's `params` array, turning the typo + * into a located compile error at the authoring site. + * + * Compile-layer signal only — the strict parse at module load remains the + * enforcement of record; nothing here changes what parses or when. + */ +type NoExcessNestedActionParams = + T extends { actions: infer As extends readonly unknown[] } + ? { actions: { [I in keyof As]: NoExcessActionParams } } + : unknown; + +/** Maps one action literal: constrain each of its `params` entries, if any. */ +type NoExcessActionParams = + A extends { params: infer Ps extends readonly unknown[] } + ? A & { params: { [I in keyof Ps]: NoExcessActionParamKeys } } + : A; + +/** One action-param literal: every key outside `ActionParam` becomes `never`. */ +type NoExcessActionParamKeys

= P & + Record, never>; /** Object names already warned about a generic `password` field (dedup per name). */ const warnedPasswordObjects = new Set();