From 0305ce849606177eeda4d7a8e4c3c11095656e6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:21:15 +0000 Subject: [PATCH 1/4] feat(spec/ui): export navItemVisibleDataNeedsDataSource so a mirror attaches it by identifier Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- packages/spec/src/ui/app.zod.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 90cf1bbc6c5..8755bfe0b8e 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1037,8 +1037,25 @@ function celReadsRootData(source: string): boolean { * schemas do not chain it, the same posture as * {@link objectNavTargetExclusivity}. Its migration record is the ADR-0087 D3 * semantic entry `nav-item-visible-data-needs-data-source` (protocol 18). + * + * EXPORTED, one function per refinement, the same posture as + * {@link objectNavTargetExclusivity} and the `check*` exports of #16489 + * (`checkPageRegionPlacement`, `checkPageMainRegionWidth`): a hand-written + * mirror of the navigation item attaches this very function instead of + * restating the rule from prose, because a restatement is what drifts. + * Exporting it moves no accept set, and its issue (`custom` at `visible`, the + * message above) is the same at every door that runs it. + * + * ⚠️ What it reads is the entry AFTER the spec's parse: `visible` as the + * `{ dialect, source }` envelope {@link EvaluatedExpressionInputSchema} turns + * either authored spelling into. A bare-string `visible` (the authored + * shorthand, before that normalisation) is not read and raises nothing, so a + * mirror whose own `visible` stays the authored string refuses nothing by + * attaching this as it is. The mount, the export, the barrel identity and that + * input contract are pinned by + * `app-nav-visible-data-needs-data-source-export.test.ts`. */ -function navItemVisibleDataNeedsDataSource(item: unknown, ctx: z.RefinementCtx): void { +export function navItemVisibleDataNeedsDataSource(item: unknown, ctx: z.RefinementCtx): void { if (!item || typeof item !== 'object') return; const entry = item as { visible?: unknown; dataSource?: unknown }; if (entry.dataSource !== undefined) return; From 42945c2f43c454c50c2cc43d27d2d4ffa40f5d83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:23:24 +0000 Subject: [PATCH 2/4] test(spec/ui): pin navItemVisibleDataNeedsDataSource as the check NavigationItemSchema attaches Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- ...ible-data-needs-data-source-export.test.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts diff --git a/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts b/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts new file mode 100644 index 00000000000..53ba98595a8 --- /dev/null +++ b/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24534] `navItemVisibleDataNeedsDataSource` is EXPORTED, and the export IS + * the check `NavigationItemSchema` runs, so a hand-written mirror of the + * navigation item attaches the spec's own rule by identifier instead of + * restating it. The rule itself (what is refused, where, and with which + * words) is pinned in `app-nav-visible-data-needs-data-source.test.ts`; this + * file pins only the export, on the same four legs as + * `app-nav-target-exclusivity-export.test.ts`, plus the export's input + * contract: + * + * 1. PARITY over the check's matrix (every refusing branch and every + * accepting path) between the export called directly with a collecting + * ctx and `NavigationItemSchema`'s own parse. The matrix authors `visible` + * as the `{ dialect, source }` envelope, the one spelling whose parsed + * value equals what was written, so the two runners read the same input. + * 2. MOUNT: the union `NavigationItemSchema` lazily resolves to carries + * exactly one `custom` check, and that check's issue vector over the + * matrix equals the export's. + * 3. ATTACHMENT BY IDENTIFIER: the module declares the export exactly once + * and chains it by name exactly once, read from the source, so the door + * cannot be running an inline copy that merely agrees on this matrix. + * 4. BARREL identity: `./index` (what `@objectstack/spec/ui` ships) exports + * the very same function object, with the `(value, ctx)` arity. + * 5. INPUT CONTRACT: the card's own entry, written with the bare-string + * `visible`, is refused by the door, and the export raises the identical + * issue on that entry's PARSED `visible`, but nothing on the bare string + * itself. The check judges the spec's parsed entry; a mirror whose + * `visible` stays the authored string gets no refusal by attaching it as + * it is. That is the export's docblock, measured, so a later change to + * what the function reads flips this leg on purpose. + * + * What it does NOT prove: reference identity between the export and the + * check object the schema holds. zod 4 wraps the function handed to + * `superRefine` in a closure and keeps no handle to it; legs 2 + 3 are the + * substitute, as in the two sibling export pins. + * + * The fixtures are SHAPE-VALID on purpose and the parse leg throws otherwise, + * so "both refuse" can never be true for the wrong reason. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { NavigationItemSchema, navItemVisibleDataNeedsDataSource } from './app.zod'; +import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; +import * as ui from './index'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +interface IssueSig { + code: string; + path: string; + message: string; +} + +interface RawIssueLike { + code?: string; + path?: readonly PropertyKey[]; + message?: string; +} + +const sig = (i: RawIssueLike): IssueSig => ({ + code: String(i.code), + path: (i.path ?? []).map(String).join('.'), + message: String(i.message), +}); + +const sorted = (issues: IssueSig[]): IssueSig[] => + [...issues].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + +/** Leg (a): call the export directly with a collecting ctx. */ +function runExport(value: unknown): IssueSig[] { + const issues: RawIssueLike[] = []; + const ctx = { + value, + issues, + addIssue: (issue: string | RawIssueLike) => { + issues.push(typeof issue === 'string' ? { code: 'custom', message: issue } : { code: 'custom', ...issue }); + }, + }; + navItemVisibleDataNeedsDataSource(value, ctx as unknown as z.RefinementCtx); + return sorted(issues.map(sig)); +} + +interface ZodCheckLike { + _zod: { + def: { check: string }; + check: (payload: { value: unknown; issues: RawIssueLike[] }) => unknown; + }; +} + +/** Leg (b): run ONE of the schema's own check objects in isolation. */ +function runCheckObject(check: ZodCheckLike, value: unknown): IssueSig[] { + const payload = { value, issues: [] as RawIssueLike[] }; + check._zod.check(payload); + return sorted(payload.issues.map(sig)); +} + +/** Leg (c): the door's full parse, restricted to object-level (`custom`) issues. + * Throws when the fixture is not shape-valid — see the header. */ +function runParse(value: unknown): IssueSig[] { + const r = NavigationItemSchema.safeParse(value); + if (r.success) return []; + const foreign = r.error.issues.filter((i) => i.code !== 'custom'); + if (foreign.length > 0) { + throw new Error(`fixture is not shape-valid — the object-level check never ran: ${JSON.stringify(foreign)}`); + } + return sorted(r.error.issues.map((i) => sig(i as RawIssueLike))); +} + +/** + * The checks held by the discriminated union `NavigationItemSchema` lazily + * resolves to — the one place this module chains the refinement (its + * branches carry their own checks; those are not this export's). + */ +function unionChecksOf(schema: unknown): ZodCheckLike[] { + const lazyDef = (schema as { _zod: { def: { type: string; getter: () => unknown } } })._zod.def; + expect(lazyDef.type).toBe('lazy'); + const union = lazyDef.getter() as { _zod: { def: { type: string; checks?: ZodCheckLike[] } } }; + expect(union._zod.def.type).toBe('union'); + return union._zod.def.checks ?? []; +} + +/** One issue vector per runner over the matrix — the equality's key. */ +const vectorOf = (run: (value: unknown) => IssueSig[], values: unknown[]): string => + JSON.stringify(values.map(run)); + +// --------------------------------------------------------------------------- +// Fixture matrix — every refusing branch, plus every accepting path +// --------------------------------------------------------------------------- + +interface Fixture { + label: string; + value: Record; + /** The paths the check is expected to refuse at; `[]` is an accepting path. */ + refusesAt: string[]; +} + +/** The card's entry, as written there. */ +const CARD_ENTRY = { id: 'nav_envs', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' } as const; +const API_BINDING = { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' } as const; +const cel = (source: string) => ({ dialect: 'cel', source }) as const; + +const fixtures: Fixture[] = [ + // Refusals — the one rule, on the branches that spread the base. + { label: 'the card\'s entry, `visible` as the envelope', value: { ...CARD_ENTRY, visible: cel('data.total == 1') }, refusesAt: ['visible'] }, + { label: 'a `dashboard` entry', value: { id: 'nav_ops', type: 'dashboard', dashboardName: 'ops', visible: cel('!data.has_more') }, refusesAt: ['visible'] }, + { label: 'a `url` entry reading the bare root', value: { id: 'nav_cloud', type: 'url', url: '/apps/cloud', visible: cel('data') }, refusesAt: ['visible'] }, + { label: 'a `group` entry', value: { id: 'nav_group', type: 'group', children: [], visible: cel('has(data.total)') }, refusesAt: ['visible'] }, + // Accepting paths — the control the card names, and the reader's edges. + { label: 'the card\'s entry WITH a `dataSource` (the control)', value: { ...CARD_ENTRY, visible: cel('data.total == 1'), dataSource: API_BINDING }, refusesAt: [] }, + { label: 'an ambient root only (`current_user`)', value: { ...CARD_ENTRY, visible: cel('\'org_admin\' in current_user.positions') }, refusesAt: [] }, + { label: 'a member named `data`, not the root', value: { ...CARD_ENTRY, visible: cel('features.data == true') }, refusesAt: [] }, + { label: 'no `visible` at all', value: { id: 'nav_envs', type: 'object', objectName: 'sys_environment' }, refusesAt: [] }, +]; + +const matrix = fixtures.map((f) => f.value); +const refusing = fixtures.filter((f) => f.refusesAt.length > 0); +const accepting = fixtures.filter((f) => f.refusesAt.length === 0); + +// --------------------------------------------------------------------------- +// Leg 1 — parity on every fixture between the export and the door +// --------------------------------------------------------------------------- + +describe('navItemVisibleDataNeedsDataSource — parity between the export and NavigationItemSchema', () => { + it.each(fixtures)('$label — the direct call refuses at exactly the declared paths', ({ value, refusesAt }) => { + const direct = runExport(value); + expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort()); + for (const issue of direct) expect(issue.code).toBe('custom'); + }); + + it.each(fixtures)('$label — the NavigationItemSchema parse and the direct call agree issue for issue', ({ value }) => { + expect(runParse(value)).toEqual(runExport(value)); + }); + + it('the matrix has both kinds of path, so agreement is not vacuous', () => { + expect(refusing.length).toBeGreaterThanOrEqual(4); + expect(accepting.length).toBeGreaterThanOrEqual(4); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 2 — the mount: the union carries the check +// --------------------------------------------------------------------------- + +describe('the mount — the union NavigationItemSchema resolves to carries the check', () => { + it('carries exactly one `custom` check', () => { + expect(unionChecksOf(NavigationItemSchema).map((c) => c._zod.def.check)).toEqual(['custom']); + }); + + it('that check is behaviourally identical to the export over the whole matrix', () => { + const [check] = unionChecksOf(NavigationItemSchema); + expect(vectorOf((v) => runCheckObject(check, v), matrix)).toBe(vectorOf(runExport, matrix)); + // …and the export is not a no-op on its own matrix (an all-empty vector + // would match any dead check). + expect(vectorOf(runExport, matrix)).not.toBe(vectorOf(() => [], matrix)); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 3 — attached by identifier, in the module that declares the schema +// --------------------------------------------------------------------------- + +describe('app.zod.ts attaches the export BY IDENTIFIER — no inline copy', () => { + const src = fs.readFileSync(path.join(HERE, 'app.zod.ts'), 'utf8'); + // An attachment is a CODE line carrying `.superRefine(name)`; docblock + // (` * `) and `//` lines are excluded, so prose naming the same spelling is + // not counted. + const attachments = (name: string): number => + src.split(/\r?\n/).filter((line) => !/^[ \t]*(\*|\/\/)/.test(line) && line.includes(`.superRefine(${name})`)).length; + // A NAME is only a sound key for that count if the module declares it exactly + // once — a second, shadowing binding would satisfy the count while the door + // chains a different function object. + const declarations = (name: string): number => + src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; + + it('declares `export function navItemVisibleDataNeedsDataSource(` exactly once', () => { + expect(src).toContain('export function navItemVisibleDataNeedsDataSource('); + expect(declarations('navItemVisibleDataNeedsDataSource')).toBe(1); + }); + + it('chains it exactly once — on the union, by name', () => { + expect(attachments('navItemVisibleDataNeedsDataSource')).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 4 — the barrel ships the same function object +// --------------------------------------------------------------------------- + +describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function object', () => { + it('navItemVisibleDataNeedsDataSource — reference identity, and the `(value, ctx)` arity', () => { + expect((ui as Record).navItemVisibleDataNeedsDataSource).toBe(navItemVisibleDataNeedsDataSource); + expect(typeof navItemVisibleDataNeedsDataSource).toBe('function'); + expect(navItemVisibleDataNeedsDataSource.length).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Leg 5 — the input contract: the export judges the PARSED entry +// --------------------------------------------------------------------------- + +describe('the input contract — the export judges the entry after the spec\'s parse', () => { + const parsedVisible = EvaluatedExpressionInputSchema.parse(CARD_ENTRY.visible); + + it('the spec normalises the bare string to the envelope the check reads', () => { + expect(parsedVisible).toEqual(cel('data.total == 1')); + }); + + it('the door refuses the card\'s entry as written, with one `custom` issue at `visible`', () => { + const door = runParse(CARD_ENTRY); + expect(door).toHaveLength(1); + expect(door[0]).toMatchObject({ code: 'custom', path: 'visible' }); + }); + + it('the export raises the identical issue on that entry\'s parsed `visible`', () => { + expect(runExport({ ...CARD_ENTRY, visible: parsedVisible })).toEqual(runParse(CARD_ENTRY)); + }); + + it('the export raises NOTHING on the bare-string spelling — a mirror must hand it the parsed entry', () => { + expect(runExport(CARD_ENTRY)).toEqual([]); + }); + + it('the control — the same entry with a `dataSource` — is accepted by the door and by the export', () => { + const withSource = { ...CARD_ENTRY, dataSource: API_BINDING }; + expect(NavigationItemSchema.safeParse(withSource).success).toBe(true); + expect(runExport({ ...withSource, visible: parsedVisible })).toEqual([]); + }); +}); From 8cf16969428d63be720f189189127c2498dd47d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:31:59 +0000 Subject: [PATCH 3/4] chore(spec): regenerate the ui api-surface and export-origins shards, add the changeset Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .changeset/24534-export-nav-visible-data-check.md | 11 +++++++++++ packages/spec/api-surface/ui.json | 1 + packages/spec/export-origins/ui.json | 1 + 3 files changed, 13 insertions(+) create mode 100644 .changeset/24534-export-nav-visible-data-check.md diff --git a/.changeset/24534-export-nav-visible-data-check.md b/.changeset/24534-export-nav-visible-data-check.md new file mode 100644 index 00000000000..5726b8d4c75 --- /dev/null +++ b/.changeset/24534-export-nav-visible-data-check.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec/ui): `navItemVisibleDataNeedsDataSource` is exported from `@objectstack/spec/ui`, so a mirror of `NavigationItemSchema` attaches the spec's own rule by identifier + +Clause-②: yes (widening) + +- **What is added.** One function export, `navItemVisibleDataNeedsDataSource(item, ctx)`: the refinement `NavigationItemSchema` already runs, which refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the way `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth` are, so a hand-written mirror of the navigation item can chain the same function in its own `superRefine` instead of restating the rule. +- **What does not change.** No schema accepts or refuses anything new. The issue is the same: one `custom` issue at `visible`, with the same message. `NavigationItemSchema` still attaches the same function, once, by name. +- **What the function reads.** It judges the entry after the spec's parse, where `visible` is the `{ dialect, source }` envelope. A bare-string `visible` (`'data.total == 1'`, the shorthand an author writes before the spec normalises it) is not read and raises nothing. So a mirror whose own `visible` stays the authored string must hand the function the parsed `{ dialect: 'cel', source }` form, or it refuses nothing. diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a355f4edb28..c8211de9452 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -540,6 +540,7 @@ "isViewContainerShaped (function)", "listViewGroupKeyPredicate (function)", "misplacedDefaultPageRefusal (function)", + "navItemVisibleDataNeedsDataSource (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "objectNavTargetExclusivity (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 63a29ea37b9..d77feb6e84c 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -525,6 +525,7 @@ "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", + "navItemVisibleDataNeedsDataSource": "src/ui/app.zod.ts#navItemVisibleDataNeedsDataSource (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "objectNavTargetExclusivity": "src/ui/app.zod.ts#objectNavTargetExclusivity (function)", From e058ce508e78c913754deabacd401acb6d2e20c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:00:00 +0000 Subject: [PATCH 4/4] feat(spec/ui): navItemVisibleDataNeedsDataSource reads an authored bare-string visible too The export now normalises a bare-string visible through EvaluatedExpressionInputSchema, the slot's own schema, so a mirror whose visible stays the authored string chains it as it is. NavigationItemSchema accepts and refuses exactly what it did: its own check only ever sees the parsed envelope. Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK Co-authored-by: Claude --- .../24534-export-nav-visible-data-check.md | 6 +- ...ible-data-needs-data-source-export.test.ts | 55 +++++++++++++++---- packages/spec/src/ui/app.zod.ts | 41 ++++++++++---- 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/.changeset/24534-export-nav-visible-data-check.md b/.changeset/24534-export-nav-visible-data-check.md index 5726b8d4c75..9550ff6d423 100644 --- a/.changeset/24534-export-nav-visible-data-check.md +++ b/.changeset/24534-export-nav-visible-data-check.md @@ -8,4 +8,8 @@ Clause-②: yes (widening) - **What is added.** One function export, `navItemVisibleDataNeedsDataSource(item, ctx)`: the refinement `NavigationItemSchema` already runs, which refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the way `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth` are, so a hand-written mirror of the navigation item can chain the same function in its own `superRefine` instead of restating the rule. - **What does not change.** No schema accepts or refuses anything new. The issue is the same: one `custom` issue at `visible`, with the same message. `NavigationItemSchema` still attaches the same function, once, by name. -- **What the function reads.** It judges the entry after the spec's parse, where `visible` is the `{ dialect, source }` envelope. A bare-string `visible` (`'data.total == 1'`, the shorthand an author writes before the spec normalises it) is not read and raises nothing. So a mirror whose own `visible` stays the authored string must hand the function the parsed `{ dialect: 'cel', source }` form, or it refuses nothing. +- **What the function reads.** Both spellings of `visible`, so it answers the same on an entry as authored and as parsed: + - the authored bare string (`'data.total == 1'`), normalised by `EvaluatedExpressionInputSchema`, the slot's own schema, exactly as the spec's parse normalises it; + - the parsed `{ dialect: 'cel', source }` envelope. + + A boolean or absent `visible` judges nothing. So a mirror whose own `visible` stays the authored string chains the function as it is and refuses what the spec refuses. diff --git a/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts b/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts index 53ba98595a8..b035990e1fc 100644 --- a/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts +++ b/packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts @@ -23,13 +23,15 @@ * cannot be running an inline copy that merely agrees on this matrix. * 4. BARREL identity: `./index` (what `@objectstack/spec/ui` ships) exports * the very same function object, with the `(value, ctx)` arity. - * 5. INPUT CONTRACT: the card's own entry, written with the bare-string - * `visible`, is refused by the door, and the export raises the identical - * issue on that entry's PARSED `visible`, but nothing on the bare string - * itself. The check judges the spec's parsed entry; a mirror whose - * `visible` stays the authored string gets no refusal by attaching it as - * it is. That is the export's docblock, measured, so a later change to - * what the function reads flips this leg on purpose. + * 5. BOTH SPELLINGS: the export accepts `visible` as authored (the bare + * string a mirror whose own `visible` stays the authored string hands it) + * and as parsed (the envelope the spec's door hands it), and answers the + * same on both. The card's own entry as written draws the door's identical + * issue from a direct call, and so does its parsed `visible`. Over the leg-1 + * matrix rewritten with bare strings, the direct call and the door agree + * issue for issue. The same bare string with a `dataSource` draws nothing, + * and neither does a boolean or absent `visible`. So a mirror attaches the + * export as it is, by identifier. * * What it does NOT prove: reference identity between the export and the * check object the schema holds. zod 4 wraps the function handed to @@ -245,10 +247,19 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi }); // --------------------------------------------------------------------------- -// Leg 5 — the input contract: the export judges the PARSED entry +// Leg 5 — both spellings: the export reads `visible` as authored and as parsed // --------------------------------------------------------------------------- -describe('the input contract — the export judges the entry after the spec\'s parse', () => { +/** The leg-1 matrix with each envelope `visible` written back as the bare string an author writes. */ +const authoredFixtures: Fixture[] = fixtures + .filter((f) => f.value.visible !== undefined) + .map((f) => ({ + ...f, + label: `${f.label}, \`visible\` written as the bare string`, + value: { ...f.value, visible: (f.value.visible as { source: string }).source }, + })); + +describe('both spellings — the export reads `visible` as authored and as parsed, and answers the same', () => { const parsedVisible = EvaluatedExpressionInputSchema.parse(CARD_ENTRY.visible); it('the spec normalises the bare string to the envelope the check reads', () => { @@ -265,13 +276,33 @@ describe('the input contract — the export judges the entry after the spec\'s p expect(runExport({ ...CARD_ENTRY, visible: parsedVisible })).toEqual(runParse(CARD_ENTRY)); }); - it('the export raises NOTHING on the bare-string spelling — a mirror must hand it the parsed entry', () => { - expect(runExport(CARD_ENTRY)).toEqual([]); + it('the export raises the door\'s identical issue (code, path and message) on the card\'s entry AS WRITTEN', () => { + const direct = runExport(CARD_ENTRY); + expect(direct).toHaveLength(1); + expect(direct).toEqual(runParse(CARD_ENTRY)); + }); + + it.each(authoredFixtures)('$label — the direct call refuses at exactly the declared paths, issue for issue with the door', ({ value, refusesAt }) => { + const direct = runExport(value); + expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort()); + expect(runParse(value)).toEqual(direct); }); - it('the control — the same entry with a `dataSource` — is accepted by the door and by the export', () => { + it('the authored matrix has both kinds of path, so agreement is not vacuous', () => { + expect(authoredFixtures.filter((f) => f.refusesAt.length > 0).length).toBeGreaterThanOrEqual(4); + expect(authoredFixtures.filter((f) => f.refusesAt.length === 0).length).toBeGreaterThanOrEqual(3); + }); + + it('the control — the same bare string with a `dataSource` — draws nothing, from the door or the export', () => { const withSource = { ...CARD_ENTRY, dataSource: API_BINDING }; expect(NavigationItemSchema.safeParse(withSource).success).toBe(true); + expect(runExport(withSource)).toEqual([]); expect(runExport({ ...withSource, visible: parsedVisible })).toEqual([]); }); + + it('a boolean or absent `visible` judges nothing', () => { + expect(runExport({ ...CARD_ENTRY, visible: true })).toEqual([]); + expect(runExport({ ...CARD_ENTRY, visible: false })).toEqual([]); + expect(runExport({ id: CARD_ENTRY.id, type: CARD_ENTRY.type, objectName: CARD_ENTRY.objectName })).toEqual([]); + }); }); diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 8755bfe0b8e..74d3de955a2 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1017,6 +1017,17 @@ function celReadsRootData(source: string): boolean { return reads; } +/** + * An authored bare-string `visible` as the envelope the spec's parse makes of + * it. It goes through {@link EvaluatedExpressionInputSchema}, the slot's own + * schema, so the shorthand normalisation is reused and never restated here. + * `undefined` when that schema refuses the string (a blank one). + */ +function authoredVisibleEnvelope(source: string): unknown { + const parsed = EvaluatedExpressionInputSchema.safeParse(source); + return parsed.success ? parsed.data : undefined; +} + /** * A navigation entry whose `visible` reads `data` must declare `dataSource` — * the narrowing arm of #24332's ruling, question 2 (letter A). @@ -1029,8 +1040,8 @@ function celReadsRootData(source: string): boolean { * parsed, validated and shipped as an entry the console never shows, with the * author told nothing. Refused at the entry's own `visible`, with the fix. * - * Judged on the PARSED entry, where `visible` is always the `{ dialect, source }` - * envelope; only the `cel` dialect is read. Mounted once, on + * Judged on the `{ dialect, source }` envelope; only the `cel` dialect is read + * (see the input paragraph below for the two spellings). Mounted once, on * {@link NavigationItemSchema} itself, so it reaches every navigation door — * `app.navigation`, `app.areas[].navigation`, every `children` array at any * depth, and a navigation contribution's `items`. The exported per-branch @@ -1046,20 +1057,30 @@ function celReadsRootData(source: string): boolean { * Exporting it moves no accept set, and its issue (`custom` at `visible`, the * message above) is the same at every door that runs it. * - * ⚠️ What it reads is the entry AFTER the spec's parse: `visible` as the - * `{ dialect, source }` envelope {@link EvaluatedExpressionInputSchema} turns - * either authored spelling into. A bare-string `visible` (the authored - * shorthand, before that normalisation) is not read and raises nothing, so a - * mirror whose own `visible` stays the authored string refuses nothing by - * attaching this as it is. The mount, the export, the barrel identity and that - * input contract are pinned by + * It accepts both spellings of `visible`, so it answers the same on an entry + * as authored and on the entry the spec's parse produced: + * + * - the PARSED `{ dialect, source }` envelope, which is all the spec's own door + * ever hands it; + * - the AUTHORED bare string (`'data.total == 1'`), which a mirror whose own + * `visible` stays the authored string hands it. It is normalised by + * {@link EvaluatedExpressionInputSchema}, the slot's own schema, whose string + * arm turns it into `{ dialect: 'cel', source }`. It is the very normaliser + * the parse runs, reused rather than restated, so a blank string it refuses + * is judged by nothing here either. + * + * A boolean or absent `visible` judges nothing. So a mirror attaches this as it + * is, by identifier. The mount, the export, the barrel identity and both + * spellings are pinned by * `app-nav-visible-data-needs-data-source-export.test.ts`. */ export function navItemVisibleDataNeedsDataSource(item: unknown, ctx: z.RefinementCtx): void { if (!item || typeof item !== 'object') return; const entry = item as { visible?: unknown; dataSource?: unknown }; if (entry.dataSource !== undefined) return; - const visible = entry.visible as { dialect?: unknown; source?: unknown } | undefined; + const visible = (typeof entry.visible === 'string' + ? authoredVisibleEnvelope(entry.visible) + : entry.visible) as { dialect?: unknown; source?: unknown } | undefined; if (!visible || visible.dialect !== 'cel' || typeof visible.source !== 'string') return; if (!celReadsRootData(visible.source)) return; ctx.addIssue({