From a67c46f4ea3364cb5c45a1349b97398a875caf58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 02:40:40 +0000 Subject: [PATCH] fix(plugin-list,types): a stray `groupBy` no longer overrides the kanban lane, and is refused by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ListView`'s kanban branch destructured `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the merged kanban config and spread the REST *after* its own `groupBy: laneField`, so a `groupBy` surviving in the bag OVERRODE the lane the branch had just resolved. Measured on the card's distinguishing fixture, not reasoned: `options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against `kanban = { groupByField: 'LANE_FROM_CANONICAL' }` produced `node.groupBy === 'LANE_FROM_STRAY_GROUPBY'`. It read as latent only because the one producer that fed it wrote both spellings with the same value by construction, so no fixture driven by that producer could tell them apart. Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim 「8365 同意」) — option B. Option A (strip and re-group in silence) was the fallback for a measured zero of stored views and is NOT taken. Two halves: 1. `groupBy` joins the destructure, so the CANONICAL lane wins. 2. The key is refused LOUDLY at the read door of the view. This repo's `.passthrough()` `KanbanConfig` mirror declares it as a named alias refusal (`aliasKeyRefusal`) pointing at `groupByField`, in the sentence shape the protocol already answers the sibling alias with. The refusal lands wherever a view's metadata is validated — `os check` / `os validate`, the VS Code extension — and on the type face, where the inferred authoring surface now carries `groupBy?: never` and `tsc` refuses the key at the authoring site. Both nestings are covered, and the second is load-bearing rather than thorough: `ListView` merges `{ ...options.kanban, ...kanban }`, and the producer objectui#8213 retired wrote into `options.kanban`, so that is where the stored views this ruling is about carry the key. `options` is `z.record(z.string(), z.any())` and can declare no member, so it takes the declared arm's guidance as a check (`custom` at `options.kanban.groupBy`) while the declared slot reports the arm itself (`invalid_type` at `kanban.groupBy`). Two codes, ONE message — read off the arm's own `.description`, and pinned equal. Honouring `groupBy` as a declared alias was never available: `@objectstack/spec`'s `KanbanConfigSchema` is a strict object of `columns` / `groupByField` / `summarizeField` and refuses it by name. Re-measured on the version this tree pins (17.4.0; the card measured 17.3.0) with both controls firing — a lit control (`zzzBogusKey`) is named, a dark control (`groupByField` alone) draws no `unrecognized_keys`. Legalising it is a spec change on its own objectstack card, never a renderer-side widening (AGENTS.md #0.1). `groupBy` on the generated `object-kanban` NODE is untouched: that is the live canonical lane key `ObjectKanban` reads. The `.passthrough()` is kept — an undeclared sibling key still rides through, pinned as a control. Two now-false prose claims in `app-shell` are corrected in the same commit: `ObjectView.tsx` and `ObjectView.kanbanGroupByRetired-8213.test.tsx` both stated that the override "is untouched" and "is a `plugin-list` change on its own card". Comment-only; no behaviour in that package changes. Part of objectui#8365 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Co-authored-by: Claude --- .../8365-stray-kanban-groupby-refused.md | 60 ++++ ...ectView.kanbanGroupByRetired-8213.test.tsx | 19 +- packages/app-shell/src/views/ObjectView.tsx | 22 +- packages/plugin-list/src/ListView.tsx | 20 +- ...ListView.strayGroupByRefused-8365.test.tsx | 298 ++++++++++++++++++ ...kanban-stray-group-by-refusal-8365.test.ts | 92 ++++++ .../__tests__/list-view-spec-parity.test.ts | 7 +- packages/types/src/zod/objectql.zod.ts | 123 +++++++- 8 files changed, 621 insertions(+), 20 deletions(-) create mode 100644 .changeset/8365-stray-kanban-groupby-refused.md create mode 100644 packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx create mode 100644 packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts diff --git a/.changeset/8365-stray-kanban-groupby-refused.md b/.changeset/8365-stray-kanban-groupby-refused.md new file mode 100644 index 0000000000..692cb11cca --- /dev/null +++ b/.changeset/8365-stray-kanban-groupby-refused.md @@ -0,0 +1,60 @@ +--- +"@object-ui/types": minor +"@object-ui/plugin-list": minor +--- + +**A stray `groupBy` in a view's kanban config no longer overrides the lane, and is now refused by name.** + +`ListView`'s kanban branch destructured +`columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the merged +kanban config and spread the **rest** *after* its own `groupBy: laneField`. A +`groupBy` surviving in that bag therefore **overrode the lane the branch had just +resolved**. Measured on a distinguishing fixture, not reasoned: with +`options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against +`kanban = { groupByField: 'LANE_FROM_CANONICAL' }`, the generated `object-kanban` +node carried `groupBy: 'LANE_FROM_STRAY_GROUPBY'`. It read as latent only because +the one producer that fed it wrote both spellings with the same value by +construction; that producer was retired separately, and this closes the override +itself. + +Two halves, per the maintainer's ruling (option B — a silent re-grouping was the +fallback and was **not** taken): + +1. **The canonical lane wins.** `groupBy` joins the destructure, so the stray key + can no longer reach the passthrough spread. A view that authored it now groups + by whatever `groupByField` / `groupField` / the declared lifecycle field + resolves. +2. **The stray key is refused loudly, at the read door of the view.** This repo's + `.passthrough()` `KanbanConfig` mirror (`@object-ui/types`) declares `groupBy` + as a named alias refusal pointing at `groupByField`, in the same sentence + shape `@objectstack/spec` already answers the sibling alias with — "Unrecognized + key(s) on this kanban configuration: `groupBy`. Did you mean `groupBy` → + `groupByField`?". The refusal lands wherever a view's metadata is validated: + the CLI's `os check` / `os validate`, the VS Code extension, and `tsc` at the + authoring site (the inferred authoring face now carries `groupBy?: never`). + The legacy `options.kanban` nesting — where the retired producer wrote, and so + where stored views carry the key — takes the identical message through a check + on that untyped bag. + +**Breaking, in the sense worth stating explicitly** (shipped `minor`: this repo +never declares `major`, and `.changeset/config.json` puts every package in one +`fixed` group, so levels cannot be split). Two behaviours change for **stored +data**, which is why this is not a patch: + +- a stored view authoring `kanban.groupBy` (either nesting) **re-points its lane** + — it used to group by the stray key and now groups by the canonical binding, so + its board may show different columns; +- the same document now **fails validation** where it used to pass: any pipeline + running `safeValidateSchema` over it (`os check`, `os validate`, the extension) + reports one issue naming the key and the replacement. + +Honouring `groupBy` as a declared alias was never an option here: +`@objectstack/spec`'s `KanbanConfigSchema` is a strict object of +`columns` / `groupByField` / `summarizeField` and refuses it by name — re-measured +on the pinned 17.4.0 with both controls firing — so legalising it would be a spec +change, not a renderer widening (AGENTS.md #0.1). + +`groupBy` on the generated `object-kanban` **node** is untouched: that is the live, +canonical lane key `ObjectKanban` reads. Only the **view-level** kanban config +spelling is refused. The `.passthrough()` itself is kept — an undeclared sibling +key still rides through, which is pinned as a control. diff --git a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx index 6fa5143673..ec2176290b 100644 --- a/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx +++ b/packages/app-shell/src/views/ObjectView.kanbanGroupByRetired-8213.test.tsx @@ -44,16 +44,19 @@ * `groupBy`. * * ⚠️ NO ARM HERE ASSERTS THE `...restKanban` OVERRIDE, deliberately. `ListView` - * spreads the rest of the merged kanban config AFTER its own `groupBy: - * laneField`, so a surviving `groupBy` wins over the lane it just resolved. - * That override is real, but it cannot be pinned FROM THIS PRODUCER: every bag - * this producer can build has `groupBy` and `groupByField` holding the same + * spread the rest of the merged kanban config AFTER its own `groupBy: + * laneField`, so a surviving `groupBy` won over the lane it had just resolved. + * That override was real, but it could not be pinned FROM THIS PRODUCER: every + * bag this producer can build has `groupBy` and `groupByField` holding the same * value by construction, so an override row driven by it passes with or without - * the fix — a test that cannot fail. Distinguishing the two spellings needs a + * the fix — a test that cannot fail. Distinguishing the two spellings needed a * hand-built `plugin-list` fixture (`options.kanban.groupBy: 'a'` against - * `kanban.groupByField: 'b'`), which is a `plugin-list` change on its own card. - * What this deletion does is remove the only producer in this repo that fed the - * override; the override itself is untouched. + * `kanban.groupByField: 'b'`), which was a `plugin-list` change on its own card. + * ⭐ THAT FIXTURE NOW EXISTS and the override is closed — objectui#8365, + * `plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx`. What + * THIS deletion did remains what it always was: it removed the only producer in + * this repo that fed the override. The arms below are unchanged by that card, + * because they measure the producer, not the render branch. * * REVERSE VERIFICATION — direction predicted before running, then observed: * restore `{ groupBy: lane, groupByField: lane }` in `kanbanViewOptions` and diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 5c7619010b..6e5d06f30d 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -412,14 +412,20 @@ export function galleryViewOptions(viewDef: any): Record { * nestings), and neither does the render branch (`groupByField || groupField || * detectStatusField(...)`). * - * ⚠️ WHAT THIS CLOSES AND WHAT IT DOES NOT. `ListView`'s kanban branch - * destructures `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` - * out of the merged config and spreads the REST *after* its own - * `groupBy: laneField`, so a surviving `groupBy` overrides the lane it just - * resolved. This deletion removes the only producer in this repo that fed that - * override — it does NOT remove the override, which stays reachable from - * author-written `kanban.groupBy` riding this repo's `.passthrough()` mirror - * and is a `plugin-list` change on its own card. + * ⚠️ WHAT THIS CLOSED AND WHAT IT DID NOT — and what has since closed the rest. + * `ListView`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the + * merged config and spread the REST *after* its own `groupBy: laneField`, so a + * surviving `groupBy` overrode the lane it had just resolved. This deletion + * removed the only producer in this repo that fed that override; the override + * itself stayed reachable from an author-written `kanban.groupBy` riding this + * repo's `.passthrough()` mirror, and was carried on its own card. + * ⭐ THAT CARD HAS LANDED (objectui#8365, maintainer ruling of 2026-09-12 — + * decision batch #117 item 5, option B): `groupBy` is now stripped in + * `ListView`'s destructure, so the canonical lane wins, AND the view-level + * `KanbanConfig` mirror (`@object-ui/types`, `zod/objectql.zod.ts`) declares it + * as an alias refusal naming `groupByField`, so the key is refused BY NAME at + * the read door instead of riding the passthrough. ⛔ Do not re-file it. * * ⚠️ `titleField` AND `cardFields` BELOW ARE ALSO OUTSIDE `KanbanConfigSchema`, * and are deliberately NOT swept up here. `cardFields` is a DECLARED deprecated diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 853fe7fbfd..dfbbd7a5ca 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -2566,7 +2566,25 @@ export const ListView = React.forwardRef(({ // own `columns` prop is its LANES, so passing this through verbatim built // lanes with undefined id/title. Map it to `cardFields` and strip the // vocabulary keys from the passthrough (mirrors plugin-view's adapter). - const { columns: kanbanCardColumns, groupByField, groupField, cardFields, titleField, ...restKanban } = kanbanCfg as Record; + // ⭐ `groupBy` IS STRIPPED HERE (objectui#8365, maintainer ruling of + // 2026-09-12 — decision batch #117 item 5, option B). It is a THIRD + // spelling of the lane, and because it was NOT in this destructure it + // survived into `restKanban`, which the return below spreads AFTER its + // own `groupBy: laneField` — so an authored `kanban.groupBy` OVERRODE + // the lane this branch had just resolved. Measured on the card's + // distinguishing fixture (`options.kanban = { groupBy: + // 'LANE_FROM_STRAY_GROUPBY' }` against `kanban = { groupByField: + // 'LANE_FROM_CANONICAL' }`): the generated node carried + // `groupBy: 'LANE_FROM_STRAY_GROUPBY'`. Stripping it is the half that + // makes the CANONICAL lane win; the loud half is the read door, where + // the view-level `KanbanConfig` mirror (`@object-ui/types`, + // `zod/objectql.zod.ts`) now declares `groupBy` as an alias refusal and + // names `groupByField`, so the key is no longer silently accepted by + // that object's `.passthrough()`. + // ⛔ Deliberately NOT folded onto `laneField`: this branch's own read is + // already canonical-first (`groupByField || groupField || detect…`), so + // a fold would re-create the override it just closed. + const { columns: kanbanCardColumns, groupByField, groupField, cardFields, titleField, groupBy: _strayGroupBy, ...restKanban } = kanbanCfg as Record; const laneField = groupByField || groupField || detectStatusField(objectDef) || undefined; // `groupBy` is the lane key and the ONLY one written here. This node // used to carry `groupField: laneField` alongside it — a duplicate the diff --git a/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx new file mode 100644 index 0000000000..3144025415 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListView.strayGroupByRefused-8365.test.tsx @@ -0,0 +1,298 @@ +/** + * 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#8365 — a surviving `groupBy` in the kanban config OVERRODE the lane + * `ListView` had just resolved. + * + * `ListView`'s kanban branch destructured + * `columns`/`groupByField`/`groupField`/`cardFields`/`titleField` out of the + * merged config and spread the REST *after* its own `groupBy: laneField`. So an + * authored `kanban.groupBy` rode the `.passthrough()` `KanbanConfig` mirror into + * `restKanban` and won over the canonical `groupByField` the branch had already + * resolved — the board grouped by the stray key, and nothing said so. + * + * ## THE DISTINGUISHING FIXTURE, and why it had to be hand-built + * + * The override was measured as ACTIVE, not latent. It read as latent only + * because the one producer that fed it (`app-shell`'s `kanbanViewOptions`, + * retired by objectui#8213) wrote both spellings with the SAME value by + * construction — so no fixture driven by that producer could tell them apart, + * and an override row driven by it was a test that cannot fail. The two + * spellings therefore carry two DIFFERENT lane names here, which is the whole + * reason this file exists: + * + * options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' } + * kanban = { groupByField: 'LANE_FROM_CANONICAL' } + * + * ## THE RULING, and the two halves it asks for + * + * Maintainer ruling of 2026-09-12 (decision batch #117 item 5, verbatim + * 「8365 同意」) — option B. Option A (strip the key and re-group in silence) + * was the fallback for a measured zero of stored views and was NOT taken. + * Honouring `groupBy` as a declared alias was never on the table: the protocol + * refuses it BY NAME (see the control below), and legalising it would be a spec + * change on its own `objectstack` card, never a renderer-side widening + * (AGENTS.md #0.1). + * + * 1. THE CANONICAL LANE WINS — `ListView` adds `groupBy` to the destructure, + * so the stray key can no longer reach `restKanban`. Asserted on the + * GENERATED `object-kanban` node, through a registry spy, because that node + * is the only place the override was ever observable. + * 2. THE STRAY KEY IS REFUSED LOUDLY, at the READ DOOR of the view and in the + * sentence shape the platform contract already uses for the sibling alias. + * That door is the view-level `KanbanConfig` mirror in `@object-ui/types` + * (`zod/objectql.zod.ts`), reached here through the published + * `safeValidateSchema` — the same entry point the CLI's `os check` / + * `os validate` and the VS Code extension run, i.e. where the AUTHOR of the + * view is standing. ⛔ Deliberately NOT a `console.warn`: the ruling names + * that outcome and refuses it. + * + * ⚠️ TWO NESTINGS, and the second one is load-bearing rather than thorough. + * `ListView` merges `{ ...options.kanban, ...kanban }`, and the producer + * objectui#8213 retired wrote into `options.kanban` — so that is where the + * stored views this ruling is ABOUT carry the key. `options` is + * `z.record(z.string(), z.any())` and can declare no member, so it takes the + * declared arm's guidance as a CHECK (`custom`) while the declared `kanban` + * slot reports the arm itself (`invalid_type`). Two codes, ONE message — + * asserted below, because a refusal that only reached the declared nesting + * would leave exactly the affected population silently re-grouped, i.e. + * option A wearing option B's name. + * + * ## THE CONTROLS, and what each one would catch + * + * - DARK CONTROL (`groupByField` alone) — parses GREEN through the same door. + * Without it, "the door refuses the fixture" is satisfied by a door that + * refuses everything. + * - PASSTHROUGH CONTROL (an undeclared `zzzBogusKey`) — still parses GREEN. + * `KanbanConfig` stays `.passthrough()` for renderer-ahead knobs + * (`swimlaneField` is the live one); this card declared exactly ONE named + * refusal arm and did not close the object. Without this arm a later + * `.strict()` would satisfy every other assertion here. + * - PROTOCOL CONTROL — `@objectstack/spec`'s own `KanbanConfigSchema` refuses + * `groupBy` by name, with a lit control (`zzzBogusKey`) firing and a dark + * control (`groupByField` alone) drawing no `unrecognized_keys`. This is what + * makes "the mirror stopped being more permissive than the spec" a reading + * rather than a claim, and it reddens first if a later spec bump ever DECLARES + * `groupBy`. + * - LANE CONTROL (`groupBy` alone, no canonical key) — the board falls back to + * the detector / no lane, and does NOT silently keep grouping by the stray + * key. This is the arm that tells option B apart from "strip it and hope". + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * remove `groupBy` from the destructure in `ListView.tsx` and the lane arms go + * RED naming `LANE_FROM_STRAY_GROUPBY`, while every refusal arm stays GREEN; + * remove the `groupBy` arm from `KanbanConfig` and the refusal arms go RED while + * the lane arms stay GREEN. Two independent halves, two independent ablations — + * the asymmetry is what proves neither arm is carrying the other. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { KanbanConfigSchema } from '@objectstack/spec/ui'; +import { ListView } from '../ListView'; + +const OBJECT = 'deal'; + +/** The two lane names the fixture holds apart. */ +const STRAY = 'LANE_FROM_STRAY_GROUPBY'; +const CANONICAL = 'LANE_FROM_CANONICAL'; + +const objectDef = { + name: OBJECT, + label: 'Deal', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + [CANONICAL]: { name: CANONICAL, type: 'text', label: 'Canonical lane' }, + [STRAY]: { name: STRAY, type: 'text', label: 'Stray lane' }, + }, +}; + +/** Every `object-kanban` node the renderer generated, in order. */ +let kanbanNodes: Array> = []; + +ComponentRegistry.register( + 'object-kanban', + (props: Record) => { + kanbanNodes.push(props.schema); + return
; + }, + { namespace: 'test', label: 'Kanban spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => []), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => 0), + getObjectSchema: vi.fn(async () => objectDef), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }) as any; + +/** Mount `ListView` on a view and return the last generated `object-kanban` node. */ +async function generatedKanbanNode(view: Record) { + const dataSource = makeDataSource(); + render( + + + , + ); + await waitFor(() => expect(kanbanNodes.length).toBeGreaterThan(0)); + return kanbanNodes[kanbanNodes.length - 1]; +} + +/** + * THE FIXTURE, built once and used by BOTH halves — the lane assertions read it + * through the renderer, the refusal assertions read it through the read door, so + * neither half can drift onto a different document than the other. + */ +const DISTINGUISHING_VIEW = { + type: 'list-view', + objectName: OBJECT, + viewType: 'kanban', + options: { kanban: { groupBy: STRAY } }, + kanban: { groupByField: CANONICAL }, +} as const; + +/** Issues the read door raises at `kanban.groupBy`, if any. */ +const refusalIssuesFor = (doc: unknown) => { + const result = safeValidateSchema(doc); + if (result.success) return []; + return result.error.issues.filter((i) => i.path.join('.').endsWith('kanban.groupBy')); +}; + +beforeEach(() => { + kanbanNodes = []; +}); + +describe('objectui#8365 · half 1 — the CANONICAL lane wins on the generated node', () => { + it('the distinguishing fixture resolves the lane from `groupByField`, not the stray `groupBy`', async () => { + const node = await generatedKanbanNode(DISTINGUISHING_VIEW); + // Before the fix this read `LANE_FROM_STRAY_GROUPBY`: the `...restKanban` + // spread landed after the branch's own `groupBy: laneField`. + expect(node.groupBy).toBe(CANONICAL); + expect(node.groupBy).not.toBe(STRAY); + }); + + it('the stray key does not reach the generated node under ANY spelling', async () => { + const node = await generatedKanbanNode(DISTINGUISHING_VIEW); + // The node's `groupBy` IS the live lane key `ObjectKanban` reads, so the + // assertion is about its VALUE above. This one is about the stray value: + // it must appear nowhere on the node, including under a key the merge might + // have carried it through. + expect(Object.values(node)).not.toContain(STRAY); + }); + + it('CONTROL: the declared `kanban.groupByField` still resolves the lane on its own', async () => { + const node = await generatedKanbanNode({ kanban: { groupByField: CANONICAL } }); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('CONTROL: the legacy `kanban.groupField` alias still resolves the lane', async () => { + // The VIEW-LEVEL legacy alias is LIVE and untouched by this card — only the + // third spelling is refused. Without this arm the fix could have narrowed + // the alias read as well and nothing here would notice. + const node = await generatedKanbanNode({ kanban: { groupField: CANONICAL } }); + expect(node.groupBy).toBe(CANONICAL); + }); + + it('LANE CONTROL: `groupBy` alone no longer sets the lane — option B, not a silent re-grouping', async () => { + const node = await generatedKanbanNode({ options: { kanban: { groupBy: STRAY } } }); + expect(node.groupBy).not.toBe(STRAY); + }); +}); + +describe('objectui#8365 · half 2 — the stray key is REFUSED at the read door', () => { + it('the distinguishing fixture is refused BY NAME, pointing at `groupByField`', () => { + const issues = refusalIssuesFor(DISTINGUISHING_VIEW); + expect(issues).toHaveLength(1); + // ⭐ THE LEGACY NESTING. `options` is `z.record(z.string(), z.any())` and can + // declare no MEMBER, so the refusal there is a CHECK — `custom`, not + // `invalid_type`. Two codes, ONE message (the check reads the declared arm's + // own `.description`), which is why the message assertions below are shared + // between the two nestings while the code assertions are not. + expect(issues[0].code).toBe('custom'); + expect(issues[0].path.join('.')).toBe('options.kanban.groupBy'); + // The lead sentence is the one the protocol's own `strictObject({ aliases })` + // answers with, so an author meets ONE remedy on both faces. + expect(issues[0].message).toContain('Unrecognized key(s) on this kanban configuration: `groupBy`.'); + expect(issues[0].message).toContain('Did you mean `groupBy` → `groupByField`?'); + }); + + it('the DECLARED nesting is refused with the SAME message, as a declared member', () => { + const declared = refusalIssuesFor({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL, groupBy: STRAY }, + }); + expect(declared).toHaveLength(1); + // `aliasKeyRefusal` is a `z.never()` ARM here, so the envelope is + // `invalid_type` at the key's own path. + expect(declared[0].code).toBe('invalid_type'); + expect(declared[0].path.join('.')).toBe('kanban.groupBy'); + // ⭐ ONE STRING, BOTH NESTINGS — the assertion that keeps the check and the + // arm from drifting into two dialects of one remedy. + const legacy = refusalIssuesFor(DISTINGUISHING_VIEW); + expect(declared[0].message).toBe(legacy[0].message); + }); + + it('DARK CONTROL: the canonical config alone parses GREEN through the same door', () => { + const result = safeValidateSchema({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL }, + }); + expect(result.success).toBe(true); + }); + + it('PASSTHROUGH CONTROL: an undeclared sibling key still rides through GREEN', () => { + // `KanbanConfig` keeps `.passthrough()` for renderer-ahead knobs. This card + // declared ONE named refusal arm; it did not close the object. + const result = safeValidateSchema({ + type: 'list-view', + objectName: OBJECT, + kanban: { groupByField: CANONICAL, zzzBogusKey: 'still accepted' }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('objectui#8365 · the protocol is what this mirror is aligning to', () => { + /** Keys `@objectstack/spec`'s strict `KanbanConfigSchema` names as refused. */ + const specRefusedKeys = (cfg: Record) => { + const r = KanbanConfigSchema.safeParse(cfg); + if (r.success) return []; + return r.error.issues.flatMap((i) => ((i as { keys?: string[] }).keys ?? [])); + }; + + it('PROTOCOL CONTROL: the spec refuses `groupBy` BY NAME, with both controls firing', () => { + // LIT: a bogus key is named. DARK: the canonical config draws no + // `unrecognized_keys` at all. Between them, the middle row is a reading. + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'], zzzBogusKey: 1 })).toContain('zzzBogusKey'); + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'] })).toEqual([]); + expect(specRefusedKeys({ groupByField: 'stage', columns: ['name'], groupBy: 'stage' })).toContain('groupBy'); + }); + + it('PROTOCOL CONTROL: `groupBy` is still not a key the spec DECLARES', () => { + // If a later spec bump ever declares it, this reddens first and the refusal + // arm above becomes the thing to re-decide — deliberately, on a new card. + expect(Object.keys((KanbanConfigSchema as any).shape)).not.toContain('groupBy'); + }); +}); diff --git a/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts b/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts new file mode 100644 index 0000000000..bc585b17ce --- /dev/null +++ b/packages/types/src/__tests__/kanban-stray-group-by-refusal-8365.test.ts @@ -0,0 +1,92 @@ +/** + * 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#8365 — `kanban.groupBy` is a DECLARED REFUSAL on this mirror, on both + * faces. + * + * The runtime half and the whole reading live in `plugin-list`'s + * `ListView.strayGroupByRefused-8365.test.tsx`, beside the render branch the + * refusal protects. THIS file exists for the half that file cannot carry: the + * TYPE face. + * + * ⭐ WHY IT IS A SEPARATE FILE, and why that is not duplication. `z.never()` at a + * member position makes `z.input` of that member `undefined`, so the inferred + * authoring face carries `groupBy?: never` and `tsc` refuses the key at the + * AUTHORING SITE — before anything runs. A pin on that face is a compile-time + * assertion: vitest erases it, and a green vitest run says nothing whatsoever + * about it. The gate that enforces it is `tsc`, and in this package that is + * `pnpm --filter @object-ui/types type-check`, whose `tsconfig.test.json` + * compiles exactly this directory. ⛔ Do not "verify" the arms below by running + * vitest over them. + * + * ⚠️ `@ts-expect-error` is the assertion, not a suppression: if a later edit + * makes `kanban: { groupBy }` compile again, tsc reports the directive itself as + * unused and this file goes RED. That is the failure mode the pin is for. + * + * REVERSE VERIFICATION — direction predicted before running, then observed: + * remove the `groupBy` arm from `KanbanConfig` and `tsc -p tsconfig.test.json` + * goes RED here with TS2578 ("Unused '@ts-expect-error' directive"), while the + * two positive controls below stay silent in both worlds. + */ + +import { describe, it, expect } from 'vitest'; +import { ListViewSchema } from '../zod/objectql.zod'; +import type { ListViewInferred } from '../zod/objectql.zod'; + +/** COMPILE-TIME PIN. Erased before vitest runs — `tsc` is the only gate here. */ +const strayGroupByIsRefusedByTsc: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { + groupByField: 'stage', + // @ts-expect-error objectui#8365 — `groupBy` is a declared refusal on + // `KanbanConfig`; write `groupByField` (above) or the deprecated + // `groupField`. Removing the arm makes this directive unused → TS2578. + groupBy: 'stage', + }, +}; + +/** POSITIVE CONTROL: the canonical key compiles, so the pin is not "kanban is unwritable". */ +const canonicalCompiles: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { groupByField: 'stage' }, +}; + +/** POSITIVE CONTROL: the live legacy alias still compiles — this card narrowed ONE key. */ +const legacyAliasCompiles: ListViewInferred = { + type: 'list-view', + objectName: 'deal', + kanban: { groupField: 'stage' }, +}; + +describe('objectui#8365 · the type face refuses `kanban.groupBy`', () => { + it('the compile-time fixtures above are real program inputs', () => { + // The arms that matter are the `@ts-expect-error` and the two controls, and + // they are checked by `tsc`, not here. This runtime arm exists so the file + // is not an empty suite and so the fixtures cannot be dropped as unused. + expect(canonicalCompiles.kanban).toEqual({ groupByField: 'stage' }); + expect(legacyAliasCompiles.kanban).toEqual({ groupField: 'stage' }); + expect((strayGroupByIsRefusedByTsc.kanban as Record).groupBy).toBe('stage'); + }); + + it('and the runtime door refuses the same key, so the two faces agree', () => { + const result = ListViewSchema.safeParse({ + type: 'list-view', + objectName: 'deal', + kanban: { groupByField: 'stage', groupBy: 'stage' }, + }); + expect(result.success).toBe(false); + const issue = result.success + ? undefined + : result.error.issues.find((i) => i.path.join('.') === 'kanban.groupBy'); + expect(issue?.code).toBe('invalid_type'); + expect(issue?.message).toContain('Did you mean `groupBy` → `groupByField`?'); + }); +}); diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index 6f48fcbeef..21df5fbb67 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -177,7 +177,12 @@ describe('ListView spec parity (#2231 drift guard)', () => { */ describe('per-view-type configs derive from the spec', () => { const CONFIGS = { - kanban: { spec: SpecKanbanConfigSchema, local: ['groupField', 'cardFields'] }, + // `groupBy` is local and DECLARED, but it is not a writable member: it is + // the objectui#8365 alias-refusal arm (`aliasKeyRefusal`), declared exactly so + // the key is refused BY NAME instead of riding this mirror's `.passthrough()`. + // It belongs on this list because the list asks which keys the mirror + // declares beyond the spec — declaring a refusal is still declaring. + kanban: { spec: SpecKanbanConfigSchema, local: ['groupField', 'cardFields', 'groupBy'] }, calendar: { spec: SpecCalendarConfigSchema, local: ['defaultView'] }, gantt: { spec: SpecGanttConfigSchema, local: [] }, gallery: { spec: SpecGalleryConfigSchema, local: ['imageField'] }, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index e92e66eb84..54fedd4183 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -40,7 +40,7 @@ import { DashboardWidgetSchema as SpecDashboardWidgetSchema, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { DrillDownConfigSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). @@ -569,11 +569,99 @@ const LIST_VIEW_LOCAL_OVERRIDES = [ // absent from the spec's `KanbanConfigSchema` / `TimelineConfigSchema`, so these // `.passthrough()`s still carry real authored values — // `core/src/utils/__tests__/normalize-list-view.test.ts` pins exactly those two. +// ALIAS REFUSAL — THE READ DOOR FOR A STORED VIEW'S KANBAN CONFIG +// (objectui#8365, maintainer ruling of 2026-09-12, decision batch #117 item 5: +// option B, 「8365 同意」). +// +// `groupBy` is a THIRD spelling of the lane the spec names `groupByField` and +// this mirror's own `groupField` aliases. It was never declared here — and +// because this object ends `.passthrough()`, an undeclared key is not +// dropped, it is KEPT. That is the whole defect: the surviving key rode the +// bag into `ListView`'s kanban branch, whose `...restKanban` spread lands +// AFTER its own `groupBy: laneField`, so an authored `kanban.groupBy` +// OVERRODE the lane the branch had just resolved from `groupByField`. +// Measured on the card's distinguishing fixture, not reasoned: +// `options.kanban = { groupBy: 'LANE_FROM_STRAY_GROUPBY' }` against +// `kanban = { groupByField: 'LANE_FROM_CANONICAL' }` produced +// `node.groupBy === 'LANE_FROM_STRAY_GROUPBY'`. +// +// ⛔ HONOURING IT AS A DECLARED ALIAS IS NOT AVAILABLE. `@objectstack/spec`'s +// `KanbanConfigSchema` is a `strictObject` of exactly +// `columns` / `groupByField` / `summarizeField` and refuses `groupBy` BY NAME. +// Re-measured at implementation time on the version this tree pins +// (17.4.0 — the card measured 17.3.0), with BOTH controls firing: a lit +// control (`zzzBogusKey` alongside a valid `groupByField`) draws +// `unrecognized_keys` naming the bogus key, and a dark control +// (`groupByField` alone) draws none. Upstream knows the SIBLING alias by name +// — probing `groupField` answers "Did you mean `groupField` → `groupByField`?" +// — and knows nothing at all about `groupBy`, which it refuses as a plain +// unrecognized key. Legalising it is a spec change on its own objectstack +// card, ⛔ never a renderer-side widening (AGENTS.md #0.1). +// +// ⇒ the key is DECLARED and unwritable, so it is refused BY NAME instead of +// riding the passthrough in silence, and this mirror stops being more +// permissive than the protocol it mirrors. The lead sentence is the one the +// spec's own `strictObject({ aliases })` answers with (surface noun quoted +// verbatim from the measurement above), so an author meets ONE remedy on both +// faces. `z.input` is `undefined`, so the inferred TypeScript face carries +// `groupBy?: never` and `tsc` refuses it at the authoring site too. +// +// ⛔ NOT A FOLD onto `groupByField`. A fold is only honest where the canonical +// key is the READER's first limb; here the reader's first limb IS canonical +// (`groupByField || groupField || detectStatusField(...)`), so folding the +// alias in would re-create the very override this refusal closes. +// ⛔ NOT the `groupField` / `cardFields` treatment above either: those two are +// deprecated aliases the SPEC also models under its canonical names and +// `normalize-list-view.ts` folds forward; `groupBy` is modelled nowhere and +// folds nowhere. +// +// ⚠️ NODE-LOCAL vs VIEW-LEVEL, the distinction this file has to keep straight: +// this arm is the VIEW-LEVEL `kanban` config. `groupBy` on the generated +// `object-kanban` NODE is the live, canonical lane key that `ObjectKanban` +// reads — untouched, and deliberately so. +const KanbanStrayGroupByRefusal = aliasKeyRefusal( + 'groupBy', + 'groupByField', + 'this kanban configuration', + '`groupBy` is the lane key of the generated `object-kanban` NODE, not of the view-level ' + + 'kanban configuration (objectui#8365). `@objectstack/spec`\'s `KanbanConfigSchema` is a ' + + 'strict object of `columns` / `groupByField` / `summarizeField` and refuses `groupBy` by ' + + 'name, so a view carrying it never came through the validated path. Write `groupByField` ' + + '(or the deprecated `groupField`, which folds onto it). Until this refusal the key rode ' + + 'this object\'s `.passthrough()` into `ListView`\'s kanban branch and OVERRODE the lane ' + + 'that branch had already resolved from `groupByField` — the board grouped by the stray ' + + 'key, and nothing said so.', +); + +/** + * WHERE THIS ARM IS INSTALLED — TWO NESTINGS, ONE STRING. + * + * `ListView` merges `{ ...schema.options?.kanban, ...schema.kanban }` before it + * reads anything, so a stored view can carry the stray key under EITHER. The + * declared `kanban` slot takes this arm as a DECLARED MEMBER (`invalid_type` at + * `kanban.groupBy`, and `groupBy?: never` on the inferred TypeScript face). + * The legacy `options` bag is `z.record(z.string(), z.any())` and can declare no + * member at all, so it takes the SAME guidance as a check (`custom` at + * `options.kanban.groupBy`) — see `ListViewSchema.options` below. + * + * ⚠️ Covering the legacy nesting is not optional politeness: the retired + * producer (`app-shell`'s `kanbanViewOptions`, objectui#8213) wrote into + * `options.kanban`, so that is where the stored views this ruling is ABOUT carry + * the key. Refusing only the declared nesting would leave exactly that + * population re-grouped in silence — option A, which the ruling did not take. + * + * ⛔ The two channels take ONE string, read off this arm's own `.description`, + * so the message an author meets cannot depend on which nesting they wrote. + */ + const KanbanConfig = stripImportedDefaults(SpecKanbanConfigSchema).partial().extend({ /** @deprecated legacy alias for the spec's `groupByField` */ groupField: z.string().optional().describe('Deprecated alias for groupByField'), /** @deprecated legacy alias for the spec's `columns` (fields shown on each card) */ cardFields: z.array(z.string()).optional().describe('Deprecated alias for columns'), + // ⭐ The named alias-refusal arm — objectui#8365. Declared above with the + // whole reading; ⛔ do not re-spell the message here, it has ONE source. + groupBy: KanbanStrayGroupByRefusal, }).passthrough(); const CalendarConfig = stripImportedDefaults(SpecCalendarConfigSchema).partial().extend({ @@ -708,7 +796,38 @@ export const ListViewSchema = BaseSchema addRecordViaForm: z.boolean().optional().describe('Add records via form dialog'), addDeleteRecordsInline: z.boolean().optional().describe('Enable inline add/delete'), collapseAllByDefault: z.boolean().optional().describe('Collapse all groups by default'), - options: z.record(z.string(), z.any()).optional().describe('Component overrides (legacy)'), + // THE LEGACY BAG, and the ONE named refusal that reaches into it + // (objectui#8365). Everything in here is `z.any()` and stays that way: this + // is the pre-#2231 "component overrides" escape hatch, not an authoring + // surface the protocol models, and typing it is a much larger question than + // this card. ⚠️ But `ListView` merges `{ ...options.kanban, ...kanban }` + // before it reads anything, and the retired producer objectui#8213 removed + // wrote the stray `groupBy` into THIS nesting — so the stored views the + // objectui#8365 ruling is about carry it here. A refusal that covered only + // the declared `kanban` slot would leave exactly that population silently + // re-grouped, which is option A; the ruling took option B. + // + // A record can declare no MEMBER, so this is a check rather than an arm: + // same guidance string, read off {@link KanbanStrayGroupByRefusal}'s own + // `.description` so the two channels cannot drift, reported as `custom` at + // `options.kanban.groupBy` (the declared slot reports `invalid_type` at + // `kanban.groupBy` — two codes, one message, and the pin asserts both). + // ⛔ Scoped to the ONE key: no other member of `options.kanban`, and nothing + // else under `options`, is judged here. + options: z.record(z.string(), z.any()) + .check((ctx) => { + const bag = ctx.value as Record | undefined; + const kanban = bag?.kanban; + if (!kanban || typeof kanban !== 'object' || Array.isArray(kanban)) return; + if ((kanban as Record).groupBy === undefined) return; + ctx.issues.push({ + code: 'custom', + message: KanbanStrayGroupByRefusal.description as string, + input: (kanban as Record).groupBy, + path: ['kanban', 'groupBy'], + }); + }) + .optional().describe('Component overrides (legacy)'), operations: z.object({ create: z.boolean().optional(), read: z.boolean().optional(),