From 04d04f2541249a7bc31432c807b3b2044bd95d9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:58:18 +0000 Subject: [PATCH 1/3] feat(types): mirror ten of ObjectGridSchema's unmirrored keys (objectui#6152 round 6) `aggregations`, `bulkActionDefs`, `conditionalFormatting`, `grouping`, `navigation`, `operations`, `reorderableColumns`, `rowColor`, `rowHeight` and `singleClickEdit` gain their zod members on the flat `ObjectGridSchema` mirror, each measured read by `ObjectGrid` and each a member of the spec's `object-grid` row. `grouping`, `navigation`, `rowColor` and `rowHeight` take the spec schemas the TypeScript twin names, by reference; `reorderableColumns` and `singleClickEdit` take the row's own members; the other four restate the twin's local shapes, the conditional-formatting rule now shared with `ListViewSchema`'s mirror. `UnmirroredDeclared` keeps `resizableColumns` on the entry: its ruled retirement is open again since the authored node moved to the spec's `properties` bag (objectui#11276). The TypeScript `singleClickEdit` comment said `@default false`; the renderer and the spec row both default it to true. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/__tests__/zod-mirror-parity.test.ts | 60 +++-- packages/types/src/objectql.ts | 7 +- packages/types/src/zod/objectql.zod.ts | 218 ++++++++++++++---- 3 files changed, 228 insertions(+), 57 deletions(-) diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index fdf9620030..58a9d6b443 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -372,7 +372,14 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **5 entries** in `UnmirroredDeclared`, **16 keys** across them — 6 / 18 until + * - **5 entries** in `UnmirroredDeclared`, **6 keys** across them — 5 / 16 until + * objectui#6152 round 6 MIRRORED ten of `ObjectGridSchema`'s eleven keys, each + * measured READ by `ObjectGrid` (a type-checker census and a runtime probe through the + * real registry) and each a member of the spec's `object-grid` row — four as the spec + * schemas the twin names, two as the row's own members, four as restatements of the + * twin's local shapes. A shrink by REPAIR off an entry that kept ONE key + * (`resizableColumns`, its route open on that round's report), so only the key figure + * moved; 6 / 18 until * objectui#11434's reader change gave `ProcessDesignerSchema`'s `lanes` and `version` * their reader (the toolbar draws the version; each lane is a band around its nodes) * and MIRRORED both, with a `BPMNLaneSchema` record mirror — a shrink by REPAIR, the @@ -2675,7 +2682,7 @@ interface KnownDrift { * * objectui#6058 seeded this ledger at **121 keys**, and — on ONE line, because the * pin below reads this sentence off disk — - * **16 keys** is what this ledger records today. + * **6 keys** is what this ledger records today. * The movements between the two are different facts. objectui#6152 measured the 23 * callback-shaped (`on*`) keys and ruled that mirroring is the wrong remedy for every * one of them; @@ -2712,7 +2719,10 @@ interface KnownDrift { * repair. Then objectui#6152 round 5 moved four keys by two routes: two RETIRED on both * faces (`DataTableSchema.selectionStyle`, `ChatbotSchema.floatingConfig`) and two * RECLASSIFIED into `RuntimeOnlyDeclared` by NAME (`DataTableSchema`'s `editable` and - * `singleClickEdit`) — ⛔ none of the four a repair, and none mirrored. + * `singleClickEdit`) — ⛔ none of the four a repair, and none mirrored. Then objectui#6152 + * round 6 MIRRORED ten of `ObjectGridSchema`'s eleven keys, each measured READ — all of it + * by REPAIR — and stopped at the eleventh, `resizableColumns`, whose ruled retirement + * objectui#11276's move of the authored node into the spec's `properties` bag left open. * * ## The decomposition of "121" — a reading at NAMED REVISIONS, so it cannot rot * @@ -2798,7 +2808,10 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (3 entries, 14 keys)** — it was 4 / 16 until objectui#6152 round 3 + * - **SPEC-DERIVED (3 entries, 4 keys)** — it was 3 / 14 until objectui#6152 round 6 + * MIRRORED ten of `ObjectGridSchema`'s eleven keys: the entry kept `resizableColumns`, + * so it stayed in this half and only the keys moved, all ten by REPAIR. It was 4 / 16 + * until objectui#6152 round 3 * moved `ObjectFormSchema`'s last two keys (`open`, `submitHandler`) to * `RuntimeOnlyDeclared` by NAME: the entry emptied, so the half lost an entry and two * keys, and ⛔ neither key was mirrored. It was 4 / 35 until objectui#6152 round 1 @@ -2985,9 +2998,12 @@ interface KnownDrift { * `previewMode` on both faces, each a tombstone and so a mirrored member — two entries * and two keys gone, `ProcessDesignerSchema`'s left standing for its reader. Its reader * change then moved only the LOCAL half again, by the REPAIR route: `lanes` and - * `version` got their reader and were MIRRORED — one entry and two keys gone. The ledger + * `version` got their reader and were MIRRORED — one entry and two keys gone. objectui#6152 + * round 6 then moved only the SPEC-DERIVED half, by the REPAIR route: ten of + * `ObjectGridSchema`'s eleven keys MIRRORED, the entry kept `resizableColumns`, so no entry + * left and the LOCAL half did not move. The ledger * now totals — on ONE line, because the pin below reads this sentence off disk — - * **5 entries / 16 keys** — 3 / 14 spec-derived, 2 / 2 local. + * **5 entries / 6 keys** — 3 / 4 spec-derived, 2 / 2 local. * * ⛔ The four split figures above and this totals line are PINNED: 'objectui#7279' * at the bottom of this file derives every one of them from the `UnmirroredDeclared` @@ -3188,13 +3204,33 @@ interface UnmirroredDeclared { * mechanism that moved `ObjectViewSchema` at objectui#7779, and the second pair to reach * this half by a single mirrored member. * - * ⚠️ Read the half as MEMBERSHIP, not as a remedy: the eleven keys below are still - * plain hand-written omissions on a `BaseSchema.extend({…})` mirror — the ordinary local + * ⚠️ Read the half as MEMBERSHIP, not as a remedy: the key below is a plain + * hand-written omission on a `BaseSchema.extend({…})` mirror — the ordinary local * route (objectui#6152's worklist), NOT objectui#2231's unification question, which is * what the SPEC-DERIVED half means for a mirror that IS the spec schema by reference. * This is the per-key reading objectui#7279 recorded on `ObjectViewSchema` for the same * reason, kept here so whoever works these off does not re-derive it. * + * ⚠️ `resizableColumns` is NOT mirroring debt in waiting, and ⛔ it is not mirrored: the + * seat's answer A on objectui#6152 retires it (`resizable` is canonical; the spec half + * forks to objectstack). objectui#6152 round 6 STOPPED at it rather than execute that + * answer, because objectui#11276 moved the AUTHORED `object-grid` node to the spec's + * `properties` bag after the answer was given: the bag is `ComponentPropsMap['object-grid']` + * by reference, which still declares `resizableColumns` ("Alternate spelling of + * `resizable`"), so retiring the key here and dropping `ObjectGrid`'s read would leave + * the authored bag accepting a key nothing reads. Its route is the open question on that + * round's report; the key stays here until it is answered. + * + * It was 11 until objectui#6152 round 6 MIRRORED ten keys, each measured READ by + * `ObjectGrid` (a type-checker census, and a runtime probe through the real registry + * that varied one key at a time) and each a member of the spec's `object-grid` row: + * `grouping`, `navigation`, `rowColor` and `rowHeight` as the spec schemas the twin + * names, BY REFERENCE; `reorderableColumns` and `singleClickEdit` as the row's own + * members, BY REFERENCE; `aggregations`, `bulkActionDefs`, `conditionalFormatting` and + * `operations` as restatements of the twin's local shapes (the conditional-formatting + * rule shared with `ListViewSchema`'s mirror). A shrink by REPAIR, the objectui#6639 + * route; the entry kept one key, so only the key total moved. + * * It was 14 until objectui#11068 MIRRORED `emptyState` (read by `ObjectGrid` from that * card on) and TOMBSTONED `rowSpecActions` / `bulkSpecActions` (second spellings of * `rowActions` / `bulkActions` that nothing read) — a tombstone is a mirrored member. @@ -3206,11 +3242,7 @@ interface UnmirroredDeclared { * 2026-08-29, declare branch) — the ledger's first shrink by REPAIR rather than * reclassification. */ - 'objectql.zod.ts#ObjectGridSchema': - | 'aggregations' | 'bulkActionDefs' | 'conditionalFormatting' - | 'grouping' | 'navigation' | 'operations' - | 'reorderableColumns' | 'resizableColumns' | 'rowColor' | 'rowHeight' - | 'singleClickEdit'; + 'objectql.zod.ts#ObjectGridSchema': 'resizableColumns'; // `reports.zod.ts#ReportComponentSchema` recorded three keys here (LOCAL). objectui#6152 // round 3 MIRRORED `conditionalFormatting`, which `ReportViewer` reads per cell, and // round 4 RETIRED the other two on both faces under ADR-0049 enforce-or-remove: @@ -3246,7 +3278,7 @@ interface UnmirroredDeclared { * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058, and — on * ONE line, because the pin below reads this sentence off disk — - * `UnmirroredDeclared` records **16 keys** today. + * `UnmirroredDeclared` records **6 keys** today. * These 23 moved here whole. Keys have since left that ledger by MIRRORING and by * RETIREMENT, but the move recorded HERE is neither and repaired nothing. ⛔ Nothing * was mirrored by it, no declaration was removed, no defect was repaired and nothing was diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 889b638eed..c234996590 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1225,7 +1225,12 @@ export interface ObjectGridSchema extends BaseSchema { /** * Enable single-click editing mode * When true with editable, clicking a cell enters edit mode (instead of double-click) - * @default false + * + * `ObjectGrid` reads it as `schema.singleClickEdit ?? true`, so an absent key + * means single-click editing, as the spec's `object-grid` row says ("default true + * when editable"). This comment read `@default false` until objectui#6152 round 6, + * which mirrored the key and measured the default through the real registry. + * @default true */ singleClickEdit?: boolean; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index b3fb5a6522..32fe6b273c 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -60,6 +60,14 @@ import { // objectui#11276 — the `object-grid` row, read as the authored arm's // `properties` bag, by reference (`ObjectGridBlockSchema` below). ObjectGridPropsSchema as SpecObjectGridPropsSchema, + // objectui#6152 round 6 — the spec schemas `ObjectGridSchema`'s TypeScript twin + // declares itself aligned with, read by reference for the members it mirrors + // (`rowColor`, `rowHeight`, and the `operation` / `visible` members of one + // `bulkActionDefs` entry). + RowColorConfigSchema as SpecRowColorConfigSchema, + RowHeightSchema as SpecRowHeightSchema, + BulkActionDefSchema as SpecBulkActionDefSchema, + BulkActionOperationSchema as SpecBulkActionOperationSchema, // objectui#11070 — the per-element data binding (`PageComponentSchema.dataSource`) // the object-bound arms below declare as `dataSource`, by reference. ElementDataSourceSchema as SpecElementDataSourceSchema, @@ -73,6 +81,7 @@ import { DataTableSchema, DrillDownConfigSchema } from './data-display.zod.js'; import { KanbanCardSchema } from './complex.zod.js'; import { ViewSwitcherSchema } from './views.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; +import { ExpressionWireSchema } from './expression.zod.js'; import { dataSourceSuppliesObject, flatPropRefusals, NODE_ENVELOPE, propsBag } from './public-blocks.zod.js'; /** @@ -312,6 +321,148 @@ const OBJECT_NAME_BINDING_WAIVER_DESCRIPTION = + '`ElementDataSourceGate` lands here before the renderer reads the node. A node with neither is refused ' + 'here, keyed `RECORD_SOURCE_REQUIRED`.'; +/** + * The `condition` of a spec-shape conditional-formatting rule, `{ condition, style }`: + * the zod twin of `SpecConditionalFormattingRule.condition` (`../objectql.ts`), read by + * both rule unions in this module (`ConditionalFormattingRuleSchema` right below, which + * the grid's and the list view's `conditionalFormatting` share, and + * `KanbanConditionalFormattingRuleSchema`), objectui#10946. It sits above the grid's + * mirror because that mirror reads it at module load (objectui#6152 round 6). + * + * Two arms, and their ORDER is the point: + * + * - `z.string()` FIRST, the member's pre-existing declaration. A string condition + * parses exactly as it always did. It is not canonicalized into an envelope, and + * `''` is still accepted. The spec's own slot does both of those things to a string + * (its string arm is a `min(1)` pipe into `{ dialect: 'cel', source }`), so reading + * the slot alone would have narrowed this validator and rewritten its parse output. + * - The protocol's own slot schema, `ListViewSchema.conditionalFormatting[].condition`, + * BY REFERENCE, for everything that is not a string: the `{ dialect, source }` + * envelope `objectstack build` emits, judged by the spec's rule for it. The installed + * spec takes `source` or `ast`; spec `main` requires a non-blank `source`. Whichever + * line is installed is what this arm enforces, with no local copy to drift. + * + * The union's `z.input` is the TS member's type, `string` plus the spec slot's input, + * so the two faces admit the same values by construction. + */ +const SpecRuleConditionSchema = z.union([ + z.string(), + stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element.shape.condition, +]); + +/** + * One `conditionalFormatting` rule in either dialect `ConditionalFormattingRule` + * (`../objectql.ts`) declares: the objectui-native `{ field, operator, value, … }` + * comparison, or the spec-shape `{ condition, style }` rule. Module-private and + * shared, so the list view's member and the grid's member are one declaration: + * objectui#6152 round 6 hoisted it, unchanged, out of `ListViewSchema` when the + * grid's mirror took the same member. + */ +const ConditionalFormattingRuleSchema = z.union([ + z.object({ + field: z.string(), + operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']), + value: z.any(), + backgroundColor: z.string().optional(), + textColor: z.string().optional(), + borderColor: z.string().optional(), + expression: z.string().optional(), + }), + z.object({ + condition: SpecRuleConditionSchema, + style: z.record(z.string(), z.string()), + }), +]); + +/** + * objectui#6152 round 6 — one entry of `ObjectGridSchema.aggregations`, restating + * the interface's inline entry member for member: the field and the function the + * grouped grid's header query compiles (`summaryColumnsOf` in + * `@object-ui/plugin-grid`'s `useServerGrouping.ts`). ⚠️ Not the spec's + * `ChartAggregateFunctionSchema`, which has no `count_distinct`. Strict, so a + * misspelt member is named instead of the header number silently not drawing. + */ +const ObjectGridAggregationSchema = z.strictObject({ + field: z.string().describe('Field the group header aggregates'), + type: z.enum(['sum', 'count', 'avg', 'min', 'max', 'count_distinct']).describe('Aggregate function'), +}); + +/** + * objectui#6152 round 6 — `ObjectGridSchema.operations`, restating the interface's + * six toggles member for member. Strict, as `emptyState` is: an unknown member is + * named rather than dropped, so a misspelt `creat: false` cannot leave the add-record + * row on in silence. + */ +const ObjectGridOperationsSchema = z.strictObject({ + create: z.boolean().optional().describe('Offer the add-record row (it also needs the create permission)'), + read: z.boolean().optional(), + update: z.boolean().optional().describe('Ceiling over `rowActions`: `false` withholds the generic Edit entry'), + delete: z.boolean().optional().describe('Ceiling over `rowActions`: `false` withholds the generic Delete entry'), + export: z.boolean().optional().describe('`false` withholds the export button even when `exportOptions` is set'), + import: z.boolean().optional(), +}); + +/** + * objectui#6152 round 6 — one entry of a `bulkActionDefs` entry's `params`, + * restating `BulkActionParam` (`../objectql.ts`) member for member. LOOSE, because + * the interface is: its catch-all forwards widget configuration to the field + * renderer as-is, and each `options` entry is open for the same reason. + * + * ⚠️ Wider than the protocol's `BulkActionParamSchema`, which is strict and takes + * `type` from a closed vocabulary. The mirror follows its TypeScript twin, which + * is the parity this file's ledgers hold; a narrowing of the twin is its own + * question. + */ +const ObjectGridBulkActionParamSchema = z.looseObject({ + name: z.string(), + label: z.string().optional(), + help: z.string().optional(), + type: z.string(), + required: z.boolean().optional(), + default: z.unknown().optional(), + options: z.array(z.looseObject({ + label: z.string(), + value: z.union([z.string(), z.number(), z.boolean()]), + })).optional(), + object: z.string().optional(), + multiple: z.boolean().optional(), + labelField: z.string().optional(), + placeholder: z.string().optional(), +}); + +/** + * objectui#6152 round 6 — one entry of `ObjectGridSchema.bulkActionDefs`, + * restating `BulkActionDef` (`../objectql.ts`) member for member. Strict: the + * interface declares no catch-all. + * + * Two members are the protocol's own, BY REFERENCE, as the interface takes them: + * `operation` is the spec's `BulkActionOperationSchema`, and `visible`'s second arm + * is the spec's `bulkActionDefs[].visible` slot beside objectui's + * `ExpressionWireSchema`. ⚠️ The entry is NOT the spec's `BulkActionDefSchema` by + * reference: measured against the twin, that schema is narrower on `params` (a + * strict entry with a closed `type`) and on `visible` (no `ExpressionWire` arm), so + * binding it would refuse values the twin declares — a `KnownDrift` row this ledger + * does not take. `actionDef` is the member `resolveBulkActions` fills when a def is + * promoted from an object action; the twin declares it, so the mirror does. + */ +const ObjectGridBulkActionDefSchema = z.strictObject({ + name: z.string(), + label: z.string().optional(), + icon: z.string().optional(), + variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'outline']).optional(), + operation: stripImportedDefaults(SpecBulkActionOperationSchema), + patch: z.record(z.string(), z.unknown()).optional(), + params: z.array(ObjectGridBulkActionParamSchema).optional(), + confirmText: z.string().optional(), + confirmLabel: z.string().optional(), + visible: z.union([ExpressionWireSchema, stripImportedDefaults(SpecBulkActionDefSchema).shape.visible]).optional(), + requiredPermissions: z.array(z.string()).optional(), + maxRecords: z.number().optional(), + batchSize: z.number().optional(), + execution: z.enum(['perRecord', 'aggregate']).optional(), + actionDef: z.record(z.string(), z.unknown()).optional(), +}); + /** * ObjectGrid Schema * @@ -484,6 +635,29 @@ export const ObjectGridSchema = BaseSchema.extend({ editable: z.boolean().optional(), keyboardNavigation: z.boolean().optional(), frozenColumns: z.number().optional(), + // ⭐ objectui#6152 round 6 — ten members the TypeScript twin declared and this + // mirror never did, each measured READ by `ObjectGrid` (a type-checker census of + // the reads, and a runtime probe through the real registry that varied one key at + // a time) and each a member of the spec's `ComponentPropsMap['object-grid']` row. + // That row now judges the AUTHORED node's `properties` bag + // (`ObjectGridBlockSchema` below, objectui#11276), where it types seven of the ten + // as `unknown`; this mirror is the node `ObjectGrid` reads after the hoist and + // the source of the `object-view` `table` slot, so each member takes the type the + // twin declares. Where the twin takes a spec schema by name, the member is that + // schema BY REFERENCE (`grouping`, `navigation`, `rowColor`, `rowHeight`) or the + // row's own member (`reorderableColumns`, `singleClickEdit`); the rest restate the + // twin's local shapes. ⛔ `resizableColumns`, the eleventh, is NOT here: its route + // is open on objectui#6152 (the entry in `zod-mirror-parity.test.ts` says why). + aggregations: z.array(ObjectGridAggregationSchema).optional().describe('Per-group aggregations drawn in each group header, e.g. [{ field: "amount", type: "sum" }]'), + bulkActionDefs: z.array(ObjectGridBulkActionDefSchema).optional().describe('Rich bulk action definitions; each opens the bulk action dialog (params, confirm, progress) for the selected rows'), + conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules for row styling: { field, operator, value, … } or { condition, style }'), + grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Row grouping: the spec GroupingConfig, by reference'), + navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Row-click navigation: the spec NavigationConfig, by reference'), + operations: ObjectGridOperationsSchema.optional().describe('Built-in operation toggles { create, read, update, delete, export, import }; a declared block replaces the default'), + reorderableColumns: stripImportedDefaults(SpecObjectGridPropsSchema.shape.reorderableColumns), + rowColor: stripImportedDefaults(SpecRowColorConfigSchema).optional().describe('Row colour rules: the spec RowColorConfig, by reference'), + rowHeight: stripImportedDefaults(SpecRowHeightSchema).optional().describe('Row height preset: the spec RowHeight, by reference'), + singleClickEdit: stripImportedDefaults(SpecObjectGridPropsSchema.shape.singleClickEdit), // ⭐ `8d50bc2bf` — one key the REGISTERED `object-grid` renderer reads off // the authored document while this arm declared none. `BaseSchema` is // `.passthrough()`, so an undeclared key is NOT refused: it stops being @@ -1752,33 +1926,6 @@ function checkListViewDatasetChartFilter( } } -/** - * The `condition` of a spec-shape conditional-formatting rule, `{ condition, style }`: - * the zod twin of `SpecConditionalFormattingRule.condition` (`../objectql.ts`), read by - * both rule unions below (the list view's `conditionalFormatting` and - * `KanbanConditionalFormattingRuleSchema`), objectui#10946. - * - * Two arms, and their ORDER is the point: - * - * - `z.string()` FIRST, the member's pre-existing declaration. A string condition - * parses exactly as it always did. It is not canonicalized into an envelope, and - * `''` is still accepted. The spec's own slot does both of those things to a string - * (its string arm is a `min(1)` pipe into `{ dialect: 'cel', source }`), so reading - * the slot alone would have narrowed this validator and rewritten its parse output. - * - The protocol's own slot schema, `ListViewSchema.conditionalFormatting[].condition`, - * BY REFERENCE, for everything that is not a string: the `{ dialect, source }` - * envelope `objectstack build` emits, judged by the spec's rule for it. The installed - * spec takes `source` or `ast`; spec `main` requires a non-blank `source`. Whichever - * line is installed is what this arm enforces, with no local copy to drift. - * - * The union's `z.input` is the TS member's type, `string` plus the spec slot's input, - * so the two faces admit the same values by construction. - */ -const SpecRuleConditionSchema = z.union([ - z.string(), - stripImportedDefaults(SpecListViewSchema).shape.conditionalFormatting.unwrap().element.shape.condition, -]); - /** objectui#9256 (family-D re-measure): ONE refusal string for both content channels of `ListViewSchema`. */ const LIST_VIEW_NEITHER_CHANNEL = neitherContentChannelGuidance( 'list-view', @@ -1951,21 +2098,8 @@ export const ListViewSchema = BaseSchema live: z.enum(['polite', 'assertive', 'off']).optional() .describe('aria-live politeness for the list region (objectui-only — promote rather than grow this extension)'), }).optional().describe('ARIA attributes'), - conditionalFormatting: z.array(z.union([ - z.object({ - field: z.string(), - operator: z.enum(['equals', 'not_equals', 'contains', 'greater_than', 'less_than', 'in']), - value: z.any(), - backgroundColor: z.string().optional(), - textColor: z.string().optional(), - borderColor: z.string().optional(), - expression: z.string().optional(), - }), - z.object({ - condition: SpecRuleConditionSchema, - style: z.record(z.string(), z.string()), - }), - ])).optional().describe('Conditional formatting rules'), + // The two-dialect rule, shared with `ObjectGridSchema` (objectui#6152 round 6). + conditionalFormatting: z.array(ConditionalFormattingRuleSchema).optional().describe('Conditional formatting rules'), // `exportOptions` — the spec's own field, BY REFERENCE (objectui#6956). // // `ListViewExportOptionsSchema` is internal to the spec bundle (not a public From e290ca60b326ab65c20c33c55b21a2eb7f5877b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:08:58 +0000 Subject: [PATCH 2/3] test(types): pin the round-6 grid mirrors; the grouping census covers the table slot; changeset - `unmirrored-declared-round6-6152.test.ts` drives each of the ten mirrored keys through the mirror, the `object-view` `table` slot (tolerant and strict faces) and the authored `object-grid` node, which does not move. - `grouping-field-padded-refused-7347.test.ts`: the census now finds `table.grouping` on `object-view`, so the table slot gets its own row and carries the padded-field refusal on every face. - `imported-defaults-8317.test.ts` registers the four new spec crossings, and the mirror reads the row's boolean members through the boundary. - One changeset (`@object-ui/types` minor), and a dated, append-only correction on the pending objectui#11276 changeset, whose list of keys "the flat mirror does not" declare this change makes false for ten. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../11276-object-grid-properties-bag.md | 2 + .changeset/6152-objectgrid-round6-mirrored.md | 20 +++ ...grouping-field-padded-refused-7347.test.ts | 23 ++- .../__tests__/imported-defaults-8317.test.ts | 12 ++ .../unmirrored-declared-round6-6152.test.ts | 170 ++++++++++++++++++ packages/types/src/zod/objectql.zod.ts | 4 +- 6 files changed, 225 insertions(+), 6 deletions(-) create mode 100644 .changeset/6152-objectgrid-round6-mirrored.md create mode 100644 packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts diff --git a/.changeset/11276-object-grid-properties-bag.md b/.changeset/11276-object-grid-properties-bag.md index 672daf7c4c..defd40bcce 100644 --- a/.changeset/11276-object-grid-properties-bag.md +++ b/.changeset/11276-object-grid-properties-bag.md @@ -27,3 +27,5 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o **The same release's changesets** that describe what the validators say about an `object-grid` written flat (`.changeset/7762-object-grid-export-options-bare-array-refusal.md`, `.changeset/9739-object-grid-operators-tombstone.md`, `.changeset/11068-grid-declared-keys.md`, `.changeset/11068-object-grid-show-filters.md`, `.changeset/11117-datasource-objectname-waiver.md`, `.changeset/10872-flat-arm-responsive-styles.md` and others) describe the flat mirror, which still behaves as they say. On an authored node the props now live in the bag, where the spec row judges them as listed above; the retirements, the record-source rule and `responsiveStyles` hold there as they say. **What did not move.** The TypeScript `ObjectGridSchema` and its zod mirror `ObjectGridSchema` stay published and unchanged in shape. They are the node as `ObjectGrid` reads it after the hoist, and as code composes it, and the `object-view` `table` slot is still built from the mirror. + +**Correction, 2026-10-02 (objectui#6152, round 6).** The list above of keys "the row declares and the flat mirror does not" is no longer true for ten of them: the flat `ObjectGridSchema` mirror now declares `rowHeight`, `grouping`, `aggregations`, `conditionalFormatting`, `rowColor`, `bulkActionDefs`, `navigation`, `singleClickEdit`, `reorderableColumns` and `operations`, each typed as its TypeScript twin types it (`.changeset/6152-objectgrid-round6-mirrored.md`). `resizableColumns` is still the row's alone. The bag is unchanged: the spec row still judges these keys inside `properties`. diff --git a/.changeset/6152-objectgrid-round6-mirrored.md b/.changeset/6152-objectgrid-round6-mirrored.md new file mode 100644 index 0000000000..adb782aa1a --- /dev/null +++ b/.changeset/6152-objectgrid-round6-mirrored.md @@ -0,0 +1,20 @@ +--- +'@object-ui/types': minor +--- + +feat(types): `ObjectGridSchema`'s zod mirror declares ten members its TypeScript twin declares (objectui#6152, round 6) + +**Breaking for an invalid document, hence `minor`:** a wrongly typed value for one of these keys inside an `object-view`'s `table` is now refused by `safeValidateSchema` / `validateSchema`, where it used to be kept unexamined. + +**What changed.** The flat `ObjectGridSchema` mirror in `@object-ui/types/zod` now declares `aggregations`, `bulkActionDefs`, `conditionalFormatting`, `grouping`, `navigation`, `operations`, `reorderableColumns`, `rowColor`, `rowHeight` and `singleClickEdit`, each typed as the TypeScript `ObjectGridSchema` types it. `ObjectGrid` reads every one of them, and the spec's `object-grid` row declares every one. `grouping`, `navigation`, `rowColor` and `rowHeight` are the spec's `GroupingConfigSchema`, `NavigationConfigSchema`, `RowColorConfigSchema` and `RowHeightSchema` by reference; `reorderableColumns` and `singleClickEdit` are the row's own members. `operations`, `aggregations` and a `bulkActionDefs` entry name an unknown member instead of dropping it. + +**Where you see it.** The `object-view` `table` slot is built from this mirror, and `ObjectView` hands nine of these keys to the grid it draws (`navigation` stays refused there, as before): + +- the strict authoring face, which `objectui validate` runs, now accepts `table.grouping`, `table.rowHeight` and the other seven. It refused them as unknown keys although the TypeScript slot typed them; +- the tolerant face refuses a wrong value at the key: `table: { rowHeight: 'comfortable' }`, `table: { operations: { creat: false } }`, or a `grouping` field name with surrounding spaces, which the spec's own rule refuses. + +`ObjectGridSchema.safeParse` called directly judges the same ten keys. + +**What did not move.** An authored `object-grid` node takes its props in the spec's `properties` bag (objectui#11276), and that bag is still the spec row by reference, so a document's `object-grid` node parses exactly as before. `resizableColumns` stays declared on the TypeScript face and is not mirrored. No TypeScript declaration changed shape. The `singleClickEdit` doc comment now says `@default true`, which is what `ObjectGrid` and the spec row do; it said `false`. + +**Clause-②: yes** — the strict face widens on the `table` slot, and the tolerant face narrows there for wrongly typed values. diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index 853684fc76..b2fe0ae25c 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -86,6 +86,11 @@ const PADDED = [' business_unit ', ' business_unit', 'business_unit '] as cons type Entry = { field: string }; interface DeclaringArm { + /** + * The component `type` of the arm, when the row's key is not that type: one arm + * can declare `grouping` at more than one path, and each path is its own row. + */ + readonly type?: string; /** Where the census finds `grouping` on this arm, `*` standing for a record key. */ readonly declaredAt: string; /** The exported mirror, parsed directly as well as through the union. */ @@ -119,6 +124,17 @@ const DECLARING: Readonly> = { }), fieldPath: (i) => ['listViews', 'all', 'grouping', 'fields', i, 'field'], }, + // objectui#6152 round 6: the `object-view` `table` slot is built from the flat + // `ObjectGridSchema` mirror, which now declares `grouping` as the spec's + // `GroupingConfigSchema` by reference, so the slot judges it — the second + // path on that arm, and `ObjectView` hands it to the grid it draws. + 'object-view table': { + type: 'object-view', + declaredAt: 'table.grouping', + arm: ObjectViewSchema, + doc: (fields) => ({ type: 'object-view', objectName: 'account', table: { grouping: { fields } } }), + fieldPath: (i) => ['table', 'grouping', 'fields', i, 'field'], + }, }; /** @@ -255,10 +271,9 @@ describe('objectui#7347 — census: the table above is every arm that declares ` // skipping it would be a silent hole in the census. if (at.length) found[componentTypeOf(arm) ?? '?'] = at; } - const expected = { - ...Object.fromEntries(Object.entries(DECLARING).map(([type, spec]) => [type, [spec.declaredAt]])), - ...NOT_COVERED, - }; + const expected: Record = { ...NOT_COVERED }; + for (const [key, spec] of Object.entries(DECLARING)) (expected[spec.type ?? key] ??= []).push(spec.declaredAt); + for (const type of Object.keys(expected)) expected[type] = [...expected[type]].sort(); expect(found).toEqual(expected); }); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 3c7d79a7cd..e1a0fb86b3 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -90,6 +90,11 @@ import { ObjectMapPropsSchema as SpecObjectMapPropsSchema, ObjectGanttPropsSchema as SpecObjectGanttPropsSchema, ObjectGridPropsSchema as SpecObjectGridPropsSchema, + // objectui#6152 round 6 — the spec schemas `ObjectGridSchema`'s mirror reads. + RowColorConfigSchema as SpecRowColorConfigSchema, + RowHeightSchema as SpecRowHeightSchema, + BulkActionDefSchema as SpecBulkActionDefSchema, + BulkActionOperationSchema as SpecBulkActionOperationSchema, // objectui#10872 — the `ComponentPropsMap` rows the public-block arms read. PageHeaderProps as SpecPageHeaderProps, PageTabsProps as SpecPageTabsProps, @@ -286,6 +291,13 @@ const IMPORTED: Array = [ // the row's key set off the crossed copy. This row carries defaults too // (`data`'s `api` provider), so the boundary returns a rebuilt copy. ['ObjectGridPropsSchema', SpecObjectGridPropsSchema], + // objectui#6152 round 6: the flat `ObjectGridSchema` mirror reads `rowColor` / + // `rowHeight` as the spec schemas its twin names, and one `bulkActionDefs` + // entry's `operation` and `visible` slot, each crossed through this boundary. + ['RowColorConfigSchema', SpecRowColorConfigSchema], + ['RowHeightSchema', SpecRowHeightSchema], + ['BulkActionDefSchema', SpecBulkActionDefSchema], + ['BulkActionOperationSchema', SpecBulkActionOperationSchema], // objectui#10872: each ADR-0080 public-block arm's `properties` is the // block's `ComponentPropsMap` row, crossed through this boundary, so every // row is measured here like every other crossing (`page:section`, diff --git a/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts b/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts new file mode 100644 index 0000000000..2b734ac072 --- /dev/null +++ b/packages/types/src/__tests__/unmirrored-declared-round6-6152.test.ts @@ -0,0 +1,170 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#6152 round 6 — the ten `ObjectGridSchema` keys this round MIRRORED. + * + * Each key is read by `ObjectGrid` and is a member of the spec's + * `ComponentPropsMap['object-grid']` row. The flat mirror is the node `ObjectGrid` + * reads after `SchemaRenderer` hoists the authored `properties` bag (objectui#11276), + * and it is the source of the `object-view` `table` slot. So the move is visible on + * two doors: + * + * - the mirror itself judges each value instead of keeping it unexamined + * (`BaseSchema` is `.passthrough()`); + * - an `object-view`'s `table` slot, which `ObjectView` relays to the grid it draws, + * now accepts each of the nine slot keys on the strict face (it refused them as + * unknown keys while the TypeScript slot typed them) and refuses a wrongly typed + * value on the tolerant face (it kept one before). + * + * The authored `object-grid` node does not move: its bag is the spec row by + * reference, and a prop written flat on the node stays refused by name. + * + * `resizableColumns`, the eleventh key, is NOT mirrored. Its route is open on the + * card, and `zod-mirror-parity.test.ts` keeps it in `UnmirroredDeclared`. + */ +import { describe, it, expect } from 'vitest'; + +import { + GroupingConfigSchema as SpecGroupingConfigSchema, + ObjectGridPropsSchema as SpecObjectGridPropsSchema, +} from '@objectstack/spec/ui'; +import { ListViewSchema, ObjectGridSchema } from '../zod/objectql.zod.js'; +import { AnyComponentSchema, StrictAnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[] }; +type Parsed = { success: boolean; error?: { issues: readonly Issue[] } }; +type Parse = (doc: unknown) => Parsed; + +const codeAndPath = (r: Parsed) => (r.success ? [] : r.error!.issues.map((i) => ({ code: i.code, path: i.path }))); + +/** Each mirrored key, a value the twin declares, and a value it does not. */ +const MIRRORED: ReadonlyArray<{ key: string; valid: unknown; invalid: unknown; slot: boolean }> = [ + { key: 'aggregations', valid: [{ field: 'amount', type: 'count_distinct' }], invalid: [{ field: 'amount', type: 'median' }], slot: true }, + { + key: 'bulkActionDefs', + valid: [{ name: 'close', operation: 'update', patch: { status: 'closed' }, params: [{ name: 'note', type: 'textarea' }] }], + invalid: [{ name: 'close', operation: 'archive' }], + slot: true, + }, + { + key: 'conditionalFormatting', + valid: [{ field: 'status', operator: 'equals', value: 'open', backgroundColor: '#fee2e2' }, { condition: "record.status == 'late'", style: { color: 'red' } }], + invalid: [{ field: 'status', operator: 'matches', value: 'open' }], + slot: true, + }, + { key: 'grouping', valid: { fields: [{ field: 'region' }] }, invalid: { fields: [] }, slot: true }, + { key: 'navigation', valid: { mode: 'drawer' }, invalid: { mode: 'sideways' }, slot: false }, + { key: 'operations', valid: { create: false, export: false }, invalid: { creat: false }, slot: true }, + { key: 'reorderableColumns', valid: true, invalid: 'yes', slot: true }, + { key: 'rowColor', valid: { field: 'status', colors: { open: 'bg-green-200' } }, invalid: 42, slot: true }, + { key: 'rowHeight', valid: 'extra_tall', invalid: 'comfortable', slot: true }, + { key: 'singleClickEdit', valid: false, invalid: 'no', slot: true }, +]; + +const GRID = { type: 'object-grid', objectName: 'task' }; +const VIEW = { type: 'object-view', objectName: 'task' }; + +const FACES: ReadonlyArray = [ + ['the tolerant face', (d) => AnyComponentSchema.safeParse(d)], + ['the strict face', (d) => StrictAnyComponentSchema.safeParse(d)], +]; + +describe('objectui#6152 round 6 — the flat `ObjectGridSchema` mirror judges ten members it never declared', () => { + it('CONTROL: the minimal node parses, so a refusal below is the key\'s', () => { + expect(ObjectGridSchema.safeParse(GRID).success).toBe(true); + }); + + it.each(MIRRORED)('`$key` is a member: a declared value parses, a wrong one is refused at the key', ({ key, valid, invalid }) => { + expect(key in ObjectGridSchema.shape).toBe(true); + const ok = ObjectGridSchema.safeParse({ ...GRID, [key]: valid }); + expect(ok.success, JSON.stringify(codeAndPath(ok))).toBe(true); + const refused = codeAndPath(ObjectGridSchema.safeParse({ ...GRID, [key]: invalid })); + expect(refused.length).toBeGreaterThan(0); + expect(refused.every((i) => i.path[0] === key), JSON.stringify(refused)).toBe(true); + }); + + it('`resizableColumns` is NOT mirrored: its route is open, and the parity ledger keeps it', () => { + expect('resizableColumns' in ObjectGridSchema.shape).toBe(false); + }); + + it('the members the twin takes from the spec by name are the spec\'s, by reference', () => { + // The row's own boolean members carry no default, so the import boundary hands + // them back unchanged. + expect(ObjectGridSchema.shape.reorderableColumns).toBe(SpecObjectGridPropsSchema.shape.reorderableColumns); + expect(ObjectGridSchema.shape.singleClickEdit).toBe(SpecObjectGridPropsSchema.shape.singleClickEdit); + // `GroupingConfigSchema` carries a default (`order`), so the boundary rebuilds + // it; what travels is the spec's own rule — a padded field name is refused. + expect(codeAndPath(SpecGroupingConfigSchema.safeParse({ fields: [{ field: ' region ' }] }))).toEqual([ + { code: 'custom', path: ['fields', 0, 'field'] }, + ]); + expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, grouping: { fields: [{ field: ' region ' }] } }))).toEqual([ + { code: 'custom', path: ['grouping', 'fields', 0, 'field'] }, + ]); + }); + + it('`conditionalFormatting` is one rule declaration, shared with the list view\'s mirror', () => { + expect(ObjectGridSchema.shape.conditionalFormatting.unwrap().element) + .toBe(ListViewSchema.shape.conditionalFormatting.unwrap().element); + }); + + it('`operations`, `aggregations` and a `bulkActionDefs` entry name an unknown member', () => { + expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, operations: { creat: false } }))) + .toEqual([{ code: 'unrecognized_keys', path: ['operations'] }]); + expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, aggregations: [{ field: 'amount', type: 'sum', label: 'Total' }] }))) + .toEqual([{ code: 'unrecognized_keys', path: ['aggregations', 0] }]); + expect(codeAndPath(ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'update', colour: 'red' }] }))) + .toEqual([{ code: 'unrecognized_keys', path: ['bulkActionDefs', 0] }]); + }); + + it('a `bulkActionDefs` param keeps widget configuration, as its twin\'s catch-all declares', () => { + // `BulkActionParam` forwards extra keys to the field widget as-is, so the mirror + // entry is loose. (The strict face closes every undeclared key at every depth by + // its own construction; that is its rule, not this member's.) + const r = ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'update', params: [{ name: 'note', type: 'textarea', rows: 3 }] }] }); + expect(r.success, JSON.stringify(codeAndPath(r))).toBe(true); + }); + + it('a `bulkActionDefs` entry takes both predicate spellings its twin declares for `visible`', () => { + for (const visible of ["record.status == 'open'", { source: "record.status == 'open'" }, { dialect: 'cel', source: 'true' }]) { + const r = ObjectGridSchema.safeParse({ ...GRID, bulkActionDefs: [{ name: 'close', operation: 'custom', visible }] }); + expect(r.success, JSON.stringify(visible)).toBe(true); + } + }); +}); + +describe('objectui#6152 round 6 — the `object-view` `table` slot follows the mirror', () => { + const SLOT = MIRRORED.filter((m) => m.slot); + + describe.each(FACES)('%s', (_face, parse) => { + it('CONTROL: a view with a plain table parses', () => { + expect(parse({ ...VIEW, table: { columns: ['subject'] } }).success).toBe(true); + }); + + it.each(SLOT)('`table.$key`: a declared value parses, a wrong one is refused inside the slot', ({ key, valid, invalid }) => { + const ok = parse({ ...VIEW, table: { [key]: valid } }); + expect(ok.success, JSON.stringify(codeAndPath(ok))).toBe(true); + const refused = codeAndPath(parse({ ...VIEW, table: { [key]: invalid } })); + expect(refused.length).toBeGreaterThan(0); + expect(refused.every((i) => i.path[0] === 'table' && i.path[1] === key), JSON.stringify(refused)).toBe(true); + }); + + it('`table.navigation` and `table.resizableColumns` stay withheld, refused by name', () => { + for (const [key, value] of [['navigation', { mode: 'drawer' }], ['resizableColumns', false]] as const) { + expect(codeAndPath(parse({ ...VIEW, table: { [key]: value } }))).toEqual([{ code: 'invalid_type', path: ['table', key] }]); + } + }); + }); +}); + +describe('objectui#6152 round 6 — the authored `object-grid` node does not move', () => { + it.each(MIRRORED)('`$key` written flat on the node is still refused by name, and parses in the bag', ({ key, valid }) => { + const flat = codeAndPath(safeValidateSchema({ type: 'object-grid', properties: { objectName: 'task' }, [key]: valid })); + expect(flat).toEqual([{ code: 'invalid_type', path: [key] }]); + const bag = safeValidateSchema({ type: 'object-grid', properties: { objectName: 'task', [key]: valid } }); + expect(bag.success, JSON.stringify(codeAndPath(bag))).toBe(true); + }); +}); diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 32fe6b273c..36a2f81888 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -654,10 +654,10 @@ export const ObjectGridSchema = BaseSchema.extend({ grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Row grouping: the spec GroupingConfig, by reference'), navigation: stripImportedDefaults(SpecNavigationConfigSchema).optional().describe('Row-click navigation: the spec NavigationConfig, by reference'), operations: ObjectGridOperationsSchema.optional().describe('Built-in operation toggles { create, read, update, delete, export, import }; a declared block replaces the default'), - reorderableColumns: stripImportedDefaults(SpecObjectGridPropsSchema.shape.reorderableColumns), + reorderableColumns: stripImportedDefaults(SpecObjectGridPropsSchema).shape.reorderableColumns, rowColor: stripImportedDefaults(SpecRowColorConfigSchema).optional().describe('Row colour rules: the spec RowColorConfig, by reference'), rowHeight: stripImportedDefaults(SpecRowHeightSchema).optional().describe('Row height preset: the spec RowHeight, by reference'), - singleClickEdit: stripImportedDefaults(SpecObjectGridPropsSchema.shape.singleClickEdit), + singleClickEdit: stripImportedDefaults(SpecObjectGridPropsSchema).shape.singleClickEdit, // ⭐ `8d50bc2bf` — one key the REGISTERED `object-grid` renderer reads off // the authored document while this arm declared none. `BaseSchema` is // `.passthrough()`, so an undeclared key is NOT refused: it stops being From 437408cebc0782b0994eedf8274ad3eb0a15521d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:11:24 +0000 Subject: [PATCH 3/3] test(types): type the grouping census's expected map as mutable arrays `tsc -p tsconfig.test.json` refused spreading the readonly `NOT_COVERED` lists into a `Record`; copy each list instead. Claude-Session: https://claude.ai/code/session_01XvhGmGAP79ZB8swnkapxPC Co-authored-by: Claude --- .../src/__tests__/grouping-field-padded-refused-7347.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts index b2fe0ae25c..7498d201a5 100644 --- a/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts +++ b/packages/types/src/__tests__/grouping-field-padded-refused-7347.test.ts @@ -271,7 +271,7 @@ describe('objectui#7347 — census: the table above is every arm that declares ` // skipping it would be a silent hole in the census. if (at.length) found[componentTypeOf(arm) ?? '?'] = at; } - const expected: Record = { ...NOT_COVERED }; + const expected: Record = Object.fromEntries(Object.entries(NOT_COVERED).map(([type, paths]) => [type, [...paths]])); for (const [key, spec] of Object.entries(DECLARING)) (expected[spec.type ?? key] ??= []).push(spec.declaredAt); for (const type of Object.keys(expected)) expected[type] = [...expected[type]].sort(); expect(found).toEqual(expected);