From 6bd44196275627174ef5e97c075526e367e30f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 17:39:50 +0000 Subject: [PATCH] feat(types): restate AppComponentSchema.hidden as the spec's app-catalogue boolean (objectui#7542) On the app node two keys share the name `hidden`: BaseSchema's renderer hide predicate (`boolean | ExpressionWire`) and the spec AppSchema's app-catalogue flag (`z.boolean().optional()`, "Hide from the App Switcher"). The zod mirror takes the spec's key by reference through SpecAppFields, which lands after the base's and overrides it, so the validator has always refused a predicate on an app document; the TS interface restated nothing and inherited the base's union, inviting the spelling the validator refused. Direction 1 of the card: restate `hidden?: boolean` on the TS face so the declaration says what the validator enforces. - packages/types/src/app.ts: `hidden?: boolean` with the docblock naming the collision, both refusals, the in-repo reader (`filterActiveApps`, `hidden !== true`) and direction 2 as the open alternative. - zod-mirror-parity.test.ts: the KnownDrift row objectui#7455 seeded for `app.zod.ts#AppComponentSchema` is removed because the drift is gone (both faces read boolean); header counts 42/63 -> 41/62 with the history sentence added, not rewritten. - new pin app-hidden-catalogue-flag-7542.test.ts: invariant Equal on the app node plus base/button controls, a ts-expect-error on a predicate string, both entry paths refusing string and CEL envelope at path `hidden`, the spec reading live through the installed pin with a control key, and the ledger row absent read off disk. - changeset: @object-ui/types minor. Measured on @objectstack/spec@17.2.0 through the installed pin: AppSchema.shape.hidden is optional(boolean); visible/disabled absent. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- .changeset/app-hidden-catalogue-flag-7542.md | 7 + .../app-hidden-catalogue-flag-7542.test.ts | 265 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 38 +-- packages/types/src/app.ts | 44 +++ 4 files changed, 324 insertions(+), 30 deletions(-) create mode 100644 .changeset/app-hidden-catalogue-flag-7542.md create mode 100644 packages/types/src/__tests__/app-hidden-catalogue-flag-7542.test.ts diff --git a/.changeset/app-hidden-catalogue-flag-7542.md b/.changeset/app-hidden-catalogue-flag-7542.md new file mode 100644 index 000000000..547d0f353 --- /dev/null +++ b/.changeset/app-hidden-catalogue-flag-7542.md @@ -0,0 +1,7 @@ +--- +'@object-ui/types': minor +--- + +**`AppComponentSchema.hidden` is now declared `boolean` — the spec's app-catalogue flag — instead of the `boolean | ExpressionWire` hide predicate it inherited from `BaseSchema`.** On the `app` node two keys collide in name and differ in meaning (objectui#7542): the zod mirror takes `@objectstack/spec/ui` `AppSchema.hidden` ("Hide from the App Switcher") by reference through `SpecAppFields`, where the spec's `z.boolean().optional()` lands after the base's key and overrides it, so the validator has always refused a predicate string or a CEL envelope object at path `hidden` on an `app` document — through `AppComponentSchema.safeParse` and through `safeValidateSchema` alike — while the published TypeScript interface, restating nothing, invited exactly that spelling. This is direction 1 of the card: the declaration is pulled back to what the validator enforces. Direction 2 — giving the catalogue flag its own name upstream in `@objectstack/spec` so the app node can inherit the renderer's predicate again — stays open as the alternative and is a protocol change, not taken here. + +Breaking on the TypeScript face only: a predicate string or envelope on an `app` node no longer type-checks, and an `app` node cannot use the predicate spelling every other node accepts. Authored JSON metadata is unaffected — the validator never admitted it — and the in-repo reader (`filterActiveApps` in `@object-ui/app-shell`) already treats the key as the boolean `hidden !== true`, never evaluating it. The renderer's `hidden` predicate on every other node is unchanged, `BaseSchema.hidden` is untouched, and the `KnownDrift` row objectui#7455 seeded for this pair is removed because the drift it recorded is gone. diff --git a/packages/types/src/__tests__/app-hidden-catalogue-flag-7542.test.ts b/packages/types/src/__tests__/app-hidden-catalogue-flag-7542.test.ts new file mode 100644 index 000000000..93b7b39b7 --- /dev/null +++ b/packages/types/src/__tests__/app-hidden-catalogue-flag-7542.test.ts @@ -0,0 +1,265 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `AppComponentSchema.hidden` is the spec's APP-CATALOGUE boolean on both + * faces, not the renderer's hide predicate (objectui#7542, direction 1). + * + * ## The collision + * + * Two keys share one name on the `app` node and mean different things: + * + * - `BaseSchema.hidden` is the renderer's hide predicate, + * `boolean | ExpressionWire`, evaluated by `SchemaRenderer`'s `shouldHide` + * chain (objectui#7455 widened it to the string, objectui#7530 to the CEL + * envelope object) — every other node inherits it; + * - `@objectstack/spec/ui` `AppSchema.hidden` is the app-catalogue flag + * ("Hide from the App Switcher"), `z.boolean().optional()`. + * + * The zod mirror is `BaseSchema.extend(SpecAppFields.shape).extend({…})` and + * `SpecAppFields` does not exclude `hidden`, so the spec's boolean lands AFTER + * the base's key and overrides it: the validator has always refused a predicate + * on an `app` document. The TS interface restated nothing and inherited the + * base's union, so the published declaration invited a spelling the published + * validator refused — `declared !== enforced`, seeded into `KnownDrift` by + * objectui#7455 as the one pair that widening moved on one face only. + * + * ## Measured before the change (red-first, on `origin/main` 669d71bf) + * + * • spec — `AppSchema.shape.hidden` is `optional(boolean)`; `visible` and + * `disabled` are not in the shape (`@objectstack/spec@17.2.0`, resolved + * through the installed pin). `AppSchema.safeParse({ …, hidden: 'user.role + * == "admin"' })` → `invalid_type` at `hidden`, expected boolean. + * • zod — `AppComponentSchema.safeParse({ type: 'app', …, hidden: STRING })` + * → `invalid_type` at path `hidden`; `safeValidateSchema` on the same + * document → root `invalid_union` whose `app` arm carries that same issue. + * The CEL envelope object was refused the same way. `hidden: true` / `false` + * parsed on both entry paths. + * • TS — `AppComponentSchema['hidden']` read `boolean | ExpressionWire | + * undefined`, inherited; a predicate string on a typed `app` document + * compiled. + * + * ## What this file pins, and why in this shape + * + * 1. Type level — `AppComponentSchema['hidden']` is EXACTLY + * `boolean | undefined`, and a predicate string on a typed `app` document + * is a compile error (`@ts-expect-error`, which fails the build with + * TS2578 the moment the restatement is deleted and the member widens back + * to the inherited union). `Equal`, not `extends`: `boolean` is assignable + * to the wide union, so a one-way check stays green on the very + * inheritance this card removes. + * 2. Runtime — the mirror accepts `true` / `false` and refuses the string and + * the envelope AT PATH `hidden`, on BOTH entry paths (`AppComponentSchema` + * directly and `safeValidateSchema`, whose union arm is where an author's + * document actually meets the validator). + * 3. Controls — `BaseSchema` and one ordinary node (`ButtonSchema`) still + * accept the string and the envelope on `hidden`, on both faces. If those + * go red the failure is about the predicate, not about the app node, and + * the refusals above would be passing for the wrong reason. + * 4. The spec reading, live through the installed pin, with a control key — + * so a spec release that renames or widens the catalogue flag (direction 2 + * of the card) turns this file red here instead of drifting silently. + * 5. The `KnownDrift` row objectui#7455 seeded is ABSENT from the ledger, read + * off disk, beside a control that the reader can see the ledger and that + * the pair is still registered. ⛔ The row may only be absent because the + * face moved: `zod-mirror-parity.test.ts` re-derives the drift and turns + * red naming the pair if the declaration ever widens again with no entry. + * + * The in-repo reader agrees with the boolean: `filterActiveApps` + * (`packages/app-shell/src/utils/appRoute.ts`) keeps an app out of the launcher + * on `hidden !== true` and never evaluates the value, so a predicate string on + * this node would have read as "not hidden" without a sound. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; + +import type { AppComponentSchema } from '../app'; +import type { BaseSchema } from '../base'; +import type { ButtonSchema } from '../form'; +import type { ExpressionWire } from '../expression'; +import { AppComponentSchema as AppMirror } from '../zod/app.zod'; +import { BaseSchema as BaseMirror } from '../zod/base.zod'; +import { ButtonSchema as ButtonMirror } from '../zod/form.zod'; +import { safeValidateSchema } from '../zod/index.zod'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/* ── The declared type is the spec's boolean, and only on this node ──────── */ + +/** The app node: the catalogue flag, exactly `boolean | undefined`. */ +export type assertionAppHiddenIsCatalogueBoolean = Expect< + Equal< AppComponentSchema['hidden'], boolean | undefined > +>; + +/** Control: the base still declares the renderer's predicate union. */ +export type assertionBaseHiddenStillPredicate = Expect< + Equal< BaseSchema['hidden'], boolean | ExpressionWire | undefined > +>; + +/** Control: an ordinary node still inherits that union unchanged. */ +export type assertionButtonHiddenStillPredicate = Expect< + Equal< ButtonSchema['hidden'], BaseSchema['hidden'] > +>; + +/* ── Authorable fixtures ─────────────────────────────────────────────────── */ + +/** The catalogue flag, authored on a typed app document. */ +export const appHiddenFromSwitcher: AppComponentSchema = { + type: 'app', + name: 'personal-settings', + hidden: true, +}; + +/** Control: the predicate spelling is still authorable on an ordinary node. */ +export const buttonHiddenByPredicate: ButtonSchema = { + type: 'button', + hidden: 'user.role == "admin"', +}; + +/* ── Runtime companions ──────────────────────────────────────────────────── */ + +const PREDICATE = 'user.role == "admin"'; +const ENVELOPE = { dialect: 'cel', source: 'user.role == "admin"' } as const; + +const appDoc = (hidden: unknown) => ({ type: 'app', name: 'crm', label: 'CRM', hidden }); + +/** zod 4 issue, with the per-arm `errors` an `invalid_union` carries. */ +interface Issue { + code?: string; + path?: readonly (string | number)[]; + message?: string; + errors?: readonly (readonly Issue[])[]; +} + +/** + * Every issue as `path` + `message`, with the nested arm errors of an + * `invalid_union` flattened in: `AnyComponentSchema` is a plain `z.union`, so a + * refusal inside the `app` arm surfaces as one root `invalid_union` issue whose + * `errors` carry the per-arm paths. + */ +function issueEntries(issues: readonly Issue[], prefix: readonly (string | number)[] = []): Array<{ path: string; message: string }> { + const out: Array<{ path: string; message: string }> = []; + for (const issue of issues) { + const path = [...prefix, ...(issue.path ?? [])]; + out.push({ path: path.join('.'), message: issue.message ?? '' }); + for (const nested of issue.errors ?? []) out.push(...issueEntries(nested, path)); + } + return out; +} + +/** + * `true` when some issue (any arm) refuses at path `hidden` expecting a boolean. + * Takes the structural shape of a `safeParse` result rather than zod's own + * `ZodSafeParseResult`, whose issue `path` admits `symbol`; the cast narrows to + * the two members read here and nothing else. + */ +function refusesHiddenExpectingBoolean(result: { success: boolean; error?: { issues: readonly unknown[] } }): boolean { + if (result.success || !result.error) return false; + return issueEntries(result.error.issues as readonly Issue[]).some( + (entry) => entry.path === 'hidden' && /expected boolean/.test(entry.message), + ); +} + +describe('AppComponentSchema.hidden is the app-catalogue boolean (objectui#7542)', () => { + it('type-level: hidden is boolean | undefined on the app node, pinned invariantly', () => { + // Erased at runtime; `tsc -p tsconfig.test.json` is the checker, chained + // from this package's `type-check` script. The runtime case exists so a + // green vitest run is not mistaken for the proof. + expect(appHiddenFromSwitcher.hidden).toBe(true); + expect(buttonHiddenByPredicate.hidden).toBe(PREDICATE); + }); + + it('type-level: a predicate string on a typed app document does not compile', () => { + // This directive fails the build (TS2578, "unused '@ts-expect-error'") the + // moment the restatement in `app.ts` is deleted, because the member then + // inherits `boolean | ExpressionWire` and the assignment starts succeeding. + // That failure is the signal this card exists to create. + + // @ts-expect-error — on the app node `hidden` is the spec's boolean, not a predicate. + const doc: AppComponentSchema = { type: 'app', name: 'crm', hidden: PREDICATE }; + + expect(doc.hidden).toBe(PREDICATE); + }); + + it('zod mirror: the boolean form parses in full on both entry paths', () => { + for (const value of [true, false]) { + expect(AppMirror.safeParse(appDoc(value)).success, `direct, hidden: ${value}`).toBe(true); + expect(safeValidateSchema(appDoc(value)).success, `union, hidden: ${value}`).toBe(true); + } + }); + + it('zod mirror: a predicate string is refused at path `hidden` on both entry paths', () => { + const direct = AppMirror.safeParse(appDoc(PREDICATE)); + expect(direct.success).toBe(false); + expect(refusesHiddenExpectingBoolean(direct)).toBe(true); + + const union = safeValidateSchema(appDoc(PREDICATE)); + expect(union.success).toBe(false); + expect(refusesHiddenExpectingBoolean(union)).toBe(true); + }); + + it('zod mirror: the CEL envelope object is refused at path `hidden` on both entry paths', () => { + const direct = AppMirror.safeParse(appDoc(ENVELOPE)); + expect(direct.success).toBe(false); + expect(refusesHiddenExpectingBoolean(direct)).toBe(true); + + const union = safeValidateSchema(appDoc(ENVELOPE)); + expect(union.success).toBe(false); + expect(refusesHiddenExpectingBoolean(union)).toBe(true); + }); + + it('control: BaseSchema and an ordinary node still accept the predicate on `hidden`, both faces', () => { + // If these go red, the failure is NOT about the app node, and the refusals + // above would have been passing for the wrong reason. + expect(BaseMirror.safeParse({ type: 'probe', hidden: PREDICATE }).success).toBe(true); + expect(BaseMirror.safeParse({ type: 'probe', hidden: ENVELOPE }).success).toBe(true); + expect(ButtonMirror.safeParse({ type: 'button', hidden: PREDICATE }).success).toBe(true); + expect(ButtonMirror.safeParse({ type: 'button', hidden: ENVELOPE }).success).toBe(true); + // The union entry path, same document, ordinary node: green — so the app + // refusal above is the app ARM's, not the union's. + expect(safeValidateSchema({ type: 'button', hidden: PREDICATE }).success).toBe(true); + expect(safeValidateSchema({ type: 'button', hidden: ENVELOPE }).success).toBe(true); + }); + + it('spec: AppSchema declares `hidden` as a boolean and neither `visible` nor `disabled` — live through the pin', () => { + const shape = (SpecAppSchema as unknown as { shape: Record }).shape; + // Control key first: an empty or mis-resolved shape must not pass the + // absence checks below vacuously. + expect(Object.keys(shape)).toContain('active'); + expect(Object.keys(shape)).toContain('hidden'); + expect(Object.keys(shape)).not.toContain('visible'); + expect(Object.keys(shape)).not.toContain('disabled'); + + const specDoc = (hidden: unknown) => ({ name: 'crm', label: 'CRM', hidden }); + expect(SpecAppSchema.safeParse(specDoc(true)).success).toBe(true); + expect(SpecAppSchema.safeParse(specDoc(false)).success).toBe(true); + expect(refusesHiddenExpectingBoolean(SpecAppSchema.safeParse(specDoc(PREDICATE)))).toBe(true); + expect(refusesHiddenExpectingBoolean(SpecAppSchema.safeParse(specDoc(ENVELOPE)))).toBe(true); + }); + + it('ledger: the `KnownDrift` row objectui#7455 seeded for this pair is gone, and only that row', () => { + const HERE = dirname(fileURLToPath(import.meta.url)); + const parity = readFileSync(join(HERE, 'zod-mirror-parity.test.ts'), 'utf8'); + + const start = parity.indexOf('interface KnownDrift {'); + expect(start, 'the reader cannot see the KnownDrift ledger').toBeGreaterThan(-1); + const end = parity.indexOf('\n}\n', start); + const ledger = parity.slice(start, end); + + // Control: the reader sees a populated ledger, not an empty slice. + expect(ledger).toContain("'complex.zod.ts#CalendarViewSchema'"); + // The row itself: absent from the KnownDrift block. + expect(ledger).not.toContain("'app.zod.ts#AppComponentSchema'"); + // Control: the pair is still registered — the drift census still covers it, + // so a future widening of the declared face reddens the parity file by name. + expect(parity).toContain("'app.zod.ts#AppComponentSchema': AppComponentSchema,"); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 6a35c73ed..9147eee6f 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -75,7 +75,13 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **42 entries** in `KnownDrift`, **63 keys** across them — 40 / 57 until + * - **41 entries** in `KnownDrift`, **62 keys** across them — 42 / 63 until + * objectui#7542 REPAIRED `app.zod.ts#AppComponentSchema`'s one key `hidden` by + * restating the DECLARATION (`app.ts` now says `boolean`, what the spec-derived + * mirror enforced all along), the entry's whole content, so the entry went too — + * the ledger's first shrink on a SPEC-DERIVED pair, and the first by moving the + * declared face toward the mirror rather than the mirror toward the declaration; + * 40 / 57 until * objectui#7655 SEEDED the `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` pairs * with three runtime-slot refusals each (pairs born ledgered in the #6124 shape, * not growth on an existing entry); 40 / 56 until @@ -86,7 +92,7 @@ * objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one * spec-derived key `hidden` (a pair born ledgered, not growth on an existing * entry: both faces read `boolean` until the base was widened, and only the - * DECLARED face moved — see that entry). It stood at 39 / 55 rather than + * DECLARED face moved — the entry objectui#7542 removed, above). It stood at 39 / 55 rather than * 39 / 56 because objectui#6940 * REPAIRED `DataTableSchema.rowActions` (the entry kept its other four keys, so * the entry count did not move). It was 12 / 17 until @@ -902,34 +908,6 @@ export type DeclaredTypeOf< K extends MirrorKey, P extends keyof Declared[K] > = * new drift on a listed mirror fails, and so does a listed key that has been fixed. */ interface KnownDrift { - /** - * SPEC-DERIVED, not a mirroring debt, and NOT closable by editing this entry. - * - * Measured on `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: the - * spec's `AppSchema` declares `hidden` (`z.boolean().optional()` -- accepts a - * boolean, refuses a string) and declares NEITHER `visible` NOR `disabled`. - * `AppComponentSchema` is `BaseSchema.extend(SpecAppFields.shape).extend(...)` - * and `SpecAppFields` excludes six keys -- `name`, `label`, `description`, - * `navigation`, `areas`, `contextSelectors` -- with `hidden` not among them, - * so on the MIRROR face the spec's boolean lands after the base's and - * overrides it. On the DECLARED face `interface AppComponentSchema extends - * BaseSchema` does not restate the key at all, so it inherits the base. - * - * That is why widening `BaseSchema.hidden` to `boolean | string` - * (objectui#7455, ruled 2026-09-03) moved only the TS side of THIS pair and - * seeded this entry, while the same widening on `visible` (objectui#4581) and - * `disabled` (objectui#4580 ruling Q3-A) moved both sides and seeded nothing. - * The asymmetry is the spec's, one layer under the one #7455 removed. - * - * The two keys collide in NAME and differ in MEANING -- the spec's is an - * app-catalogue flag (does the app show in the switcher), the base's is the - * renderer's hide predicate -- so this is a contract ruling, not a repair. - * objectui#7542 carries it, with the directions measured and none chosen. - * The one direction that reads easy and is probably wrong: dropping `hidden` - * from `SpecAppFields` would make a spec-DERIVED schema accept, by local - * divergence, a value the spec refuses. - */ - 'app.zod.ts#AppComponentSchema': 'hidden'; /** * RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads * `onViewChange` off the spread props (function values only) and hands it to diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 14d12b7ce..b4010c1e6 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -410,6 +410,50 @@ export interface AppComponentSchema extends BaseSchema { */ active?: boolean; + /** + * Whether the app is hidden from the App Switcher -- the spec's + * APP-CATALOGUE flag, NOT the renderer's hide predicate (objectui#7542). + * + * On every other node `hidden` is the key `BaseSchema` declares: + * `boolean | ExpressionWire`, a predicate `SchemaRenderer`'s `shouldHide` + * chain evaluates. On the `app` node a DIFFERENT key with the same name + * wins: `@objectstack/spec/ui` `AppSchema.hidden`, taken BY REFERENCE + * through `SpecAppFields` in `./zod/app.zod.ts`, where the spec's fields + * land after the base's and override them. Measured on + * `@objectstack/spec@17.2.0` by resolving `AppSchema.shape`: `hidden` is + * `z.boolean().optional()`, described "Hide from the App Switcher; the + * shell surfaces hidden apps via the avatar menu instead (navigation only + * -- never an access gate)"; the spec declares NEITHER `visible` NOR + * `disabled` on the app node, which is why the objectui#4581 / #4580 + * widenings of those two keys reached this node and the objectui#7455 + * widening of this one did not. + * + * So the predicate spelling every other node accepts -- a string, or the + * CEL envelope object -- is REFUSED on this node on both faces: measured + * before this restatement, `AppComponentSchema.safeParse({ type: 'app', + * hidden: 'user.role == "admin"' })` failed at path `hidden` + * (`invalid_type`, expected boolean, received string) and so did + * `safeValidateSchema` on the same document, while this interface still + * inherited the base's union and INVITED exactly that spelling. This member + * pulls the declaration back to what the validator has enforced all along + * (direction 1 of objectui#7542); the `KnownDrift` row objectui#7455 seeded + * for this pair left with it, because the drift did. The in-repo reader + * agrees with the boolean: `filterActiveApps` + * (`packages/app-shell/src/utils/appRoute.ts`) keeps an app out of the + * launcher on `hidden !== true` and never evaluates the value, so a + * predicate string here would have read as "not hidden" without a sound. + * + * The open alternative is direction 2 -- give the catalogue flag its own + * name upstream in `@objectstack/spec` so this node can inherit the + * renderer's predicate again. That is a protocol change with its own + * producers and is NOT taken here; until it is, an `app` node cannot use + * the predicate spelling. Pinned by + * `__tests__/app-hidden-catalogue-flag-7542.test.ts`. + * + * @example true + */ + hidden?: boolean; + /** * Global Layout Strategy * - sidebar: Standard admin layout with left sidebar