Skip to content

Commit 35450f0

Browse files
feat(spec/ui): export navItemVisibleDataNeedsDataSource, so a mirror of NavigationItemSchema attaches it by identifier (#24628)
Fixes #24534 Clause-②: yes (widening) ## What this does `@objectstack/spec/ui` now exports `navItemVisibleDataNeedsDataSource`. That is the refinement `NavigationItemSchema` already runs: it refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the same way as `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth`, so a hand-written mirror of the navigation item can chain the spec's own function instead of restating the rule. - `packages/spec/src/ui/app.zod.ts`: the declaration gains `export`, and its docblock gains the sibling paragraph (what it judges, the issue it raises, who attaches it) After the seat's answer A (comment 6273673166), the function also reads an authored bare-string `visible`. The string goes through `EvaluatedExpressionInputSchema`, the slot's own schema, via a module-private `authoredVisibleEnvelope`, so the shorthand normalisation is reused and never restated. The docblock says the function accepts both spellings. The `.superRefine(navItemVisibleDataNeedsDataSource)` on the union inside `NavigationItemSchema` is unchanged. - `packages/spec/src/ui/app-nav-visible-data-needs-data-source-export.test.ts`: the export pin (5 legs, 36 tests, below). - `packages/spec/api-surface/ui.json`, `packages/spec/export-origins/ui.json`: regenerated by `gen:api-surface` (it reported `touched: ui.json`) and `gen:export-origins` (`1 shard(s) rewritten`). Each gains one row. No other shard moved. - `.changeset/24534-export-nav-visible-data-check.md`: `minor` on `@objectstack/spec`, `Clause-②: yes (widening)`. A widening carries no ADR-0087 disposition marker, and `check-adr-0087-registration` is green without one. Nothing an author can write is accepted or refused differently. The issue is the same: `custom` at `visible`, with the same message. The existing rule pins in `app-nav-visible-data-needs-data-source.test.ts` are untouched and green. ## Premises **P1 holds.** On `origin/main` `5a871e4e41`: - `packages/spec/src/ui/app.zod.ts:1041` declares `function navItemVisibleDataNeedsDataSource(item: unknown, ctx: z.RefinementCtx): void` with no `export`. - `:1107` attaches it with `.superRefine(navItemVisibleDataNeedsDataSource)` on the discriminated union that `NavigationItemSchema`'s `z.lazy` returns. - The siblings are `export function checkPageRegionPlacement(` at `page.zod.ts:1766` and `export function checkPageMainRegionWidth(` at `:1846`. They reach `@objectstack/spec/ui` this way: - the barrel `src/ui/index.ts` has `export * from './app.zod'` (`:21`) and `export * from './page.zod'` (`:54`); - `package.json` `exports["./ui"]` maps to `dist/ui/index.{mjs,js,d.mts,d.ts}`; - each export has one row in `api-surface/ui.json` and one in `export-origins/ui.json`. So adding `export` is the whole route. Measured after the build, from `packages/core` (a consumer that resolves the workspace package through its `exports`): - ESM: `import('@objectstack/spec/ui')` gives `typeof navItemVisibleDataNeedsDataSource === 'function'`, length 2, resolved to `packages/spec/dist/ui/index.mjs`; - CJS: `require('@objectstack/spec/ui')` gives `function`, from `dist/ui/index.js`; - the emitted declaration is `declare function navItemVisibleDataNeedsDataSource(item: unknown, ctx: z.RefinementCtx): void;`. **P2 holds for the signature, but not for what a mirror gets when it chains the function as it is.** - *Signature.* `(item: unknown, ctx: z.RefinementCtx) => void` is what a `superRefine` callback takes. The `ctx` type is the same one `objectNavTargetExclusivity(item, ctx)` takes, and objectui's `NavigationItemSchema` already chains that function. I read objectui `origin/main` `50ba1bb0a6`, `packages/types/src/zod/app.zod.ts`. That code follows objectui#7715's rule: re-attach the spec's object-level checks by identifier. The function needs no state a mirror would have to supply. Its CEL reader (`celReadsRootData` / `lexCel`) is closed over in the module. - *Behaviour.* The function judges the entry **after the spec's parse**, where `visible` is the `{ dialect, source }` envelope that `EvaluatedExpressionInputSchema` produces. Measured on the card's entry, `{ id: 'nav_envs', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' }`: - the door (`NavigationItemSchema.safeParse`) refuses it with one `custom` issue at `visible`; - the export called on the same entry with `visible` parsed (`{ dialect: 'cel', source: 'data.total == 1' }`) raises the identical issue: code, path and message; - the export called on the entry **as written** (bare-string `visible`) raises **nothing**. - *objectui's mirror.* It declares `visible: z.union([z.boolean(), z.string()]).optional()`, so its parsed `visible` is still the authored string. Chaining this export there as it is would refuse nothing on the card's entry. - *What this PR does about it.* The first head (`50c3ba8a4a`) wrote only the export and raised the difference as an open question. The seat answered **A** (comment 6273673166 on this PR): the function also reads the authored bare string, normalised through `EvaluatedExpressionInputSchema` (the slot's own schema), so the spelling a mirror hands it is judged exactly as the spec's parse would judge it. Measured on `e058ce508e`: - the card's entry as written now draws the door's identical issue (code, path and message) from a direct call; - the same bare string with a `dataSource` draws nothing; - a boolean or absent `visible` judges nothing. `NavigationItemSchema` accepts and refuses exactly what it did, because its own check only ever sees the parsed envelope. The schema pins in `app-nav-visible-data-needs-data-source.test.ts` are unchanged and green (23 passed). ## The pin (`app-nav-visible-data-needs-data-source-export.test.ts`, 36 tests) 1. **Parity.** Over 8 fixtures (4 refusing branches: `object`, `dashboard`, `url`, `group`; 4 accepting paths: the card's entry with a `dataSource`, an ambient-root predicate, a member named `data`, and no `visible`), the direct call and `NavigationItemSchema.safeParse` agree issue for issue. These fixtures write `visible` as the envelope, the one spelling whose parsed value equals what was written. 2. **Mount.** The union the lazy schema resolves to carries exactly one `custom` check, and its issue vector over the matrix equals the export's. 3. **Attachment by identifier.** The source declares `export function navItemVisibleDataNeedsDataSource(` once and chains `.superRefine(navItemVisibleDataNeedsDataSource)` once, on a code line. 4. **Barrel identity.** `./index` exports the same function object, with arity 2. 5. **Both spellings.** - The card's bare-string entry is refused by the door. - The export raises the door's identical issue on the entry's parsed `visible` and on the entry as written. - Over the leg-1 matrix rewritten with bare strings (7 rows: 4 refusing, 3 accepting), the direct call and the door agree issue for issue. - The control (the same bare string with a `dataSource`) draws nothing from the door or the export. - A boolean or absent `visible` judges nothing. **Ablation (reverse verification), at `42945c2f43` with the implementation committed.** The mutation was `export function navItemVisibleDataNeedsDataSource(` → `/* ablated: export removed */ function navItemVisibleDataNeedsDataSource(`, applied through `node scripts/ablation-replace.mjs`: - landed on disk: anchor 1 → 0, blob `8755bfe0b8eb` → `07a6fe68bd65`; - result: **22 failed / 5 passed (27)**, red as predicted; - restored: blob == HEAD `8755bfe0b8eb`, and `git diff HEAD` is empty. The test imports `./app.zod` and `./index` relatively (src, not `dist`), so the ablation needed no rebuild. The first attempt was a no-op: the replacement text was a substring of the anchor, and the tool refused before it ran anything. It was re-run with a distinct marker. (That reading is at the first head, where the pin had 27 tests.) **Ablation of the bare-string read, at `e058ce508e` with the patch committed.** The mutation was `const visible = (typeof entry.visible === 'string'` → `const visible = (/* ablated: bare-string read removed */ false && typeof entry.visible === 'string'`, through `ablation-replace.mjs`: - landed on disk: anchor 1 → 0, blob `74d3de955a29` → `c459f2d5767d`; - result, running this file with `app-nav-visible-data-needs-data-source.test.ts`: **5 failed / 54 passed (59)**; - exactly the new legs went red: the as-written leg and the 4 refusing rows of the bare-string matrix; - the accepting rows, the controls and the whole schema pin file stayed green; - restored: blob == HEAD `74d3de955a29`, and `git diff HEAD` is empty. ## Tests and gates (final head `e058ce508e`) `origin/main` `c3954057fe` was merged at `50c3ba8a4a` through `scripts/pm/os-regen-merge.sh`. Main's later commits touch no file this PR touches, so it was not merged again. - `pnpm --filter @objectstack/spec test`: 702 files passed, 21348 tests passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck` all pass. The test-typecheck debt is unchanged at 52 files / 242 errors / 132 pinned signatures, so the new test adds no errors. - The new pin file alone: 36 passed. The schema pin `app-nav-visible-data-needs-data-source.test.ts` is unchanged since `50c3ba8a4a` and passes 23. - `pnpm --filter @objectstack/spec check:generated`: `All 14 generated artifacts are up to date`, against the `dist` this head built. `check:api-surface` reports `public API surface + factory signatures unchanged`, and `check:export-origins` reports `current`. - The derived gate list (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, 89 commands, identical before and after the merge) was run on this head with each exit code captured before any pipe. **All 89 exited 0.** `dispatch-gates --ran` answers: `89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3)`. This includes `check:api-surface`, `check:export-origins`, `check:entry-nameability`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:exported-any`, `check:docs`, `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:published-files`, `check:nul-bytes`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. On both heads, three of them (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) first exited 3 (PREREQUISITE NOT MET, nothing measured) in a freshly created worktree. They exited 0 once their packages were built, and `--ran` records those exit-0 re-runs. - Local-only: `check:dev-prereqs` exit 0 and `check:skill-frame-freshness` exit 0. - CI check-runs on `e058ce508e` (40 runs), read when the patch report was written: every required context except `Test Core (6/6)` is green (`Lint & Repo Gates`, `TypeScript Type Check`, `Build Core`, `Governed Surface Queue Guard`, `Test Core (1/6)` to `(5/6)`). `Test Core (6/6)` then went red on a runtime share-link case that timed out at vitest's 5000 ms default. That failure is not this PR's (seat CI note 6274396318) and is filed as #24633. Its one re-run is in flight. The patch report on #24534 cites the check-runs. ## Acceptance notes - **The open question, answered A by the seat (comment 6273673166).** The function now reads an authored bare-string `visible` as well as the parsed envelope, so objectui's mirror can attach the export as it is, by identifier. B (objectui restates the shorthand) and C (objectui takes `EvaluatedExpressionInputSchema` by reference) are not taken here. C stays objectui's own call for its boolean and envelope divergences. - **Read-only observation, not filed.** objectui's `visible` arm differs from the spec's in two more ways. It accepts `visible: true` / `false`, which the spec's `EvaluatedExpressionInputSchema` refuses. It refuses the `{ dialect, source }` envelope, which the spec accepts. I read this from the source and did not measure it through `objectui validate`. It belongs to the objectui follow-up this card names (Done-when 2). --- _Generated by [Claude Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e8b628 commit 35450f0

5 files changed

Lines changed: 367 additions & 4 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec/ui): `navItemVisibleDataNeedsDataSource` is exported from `@objectstack/spec/ui`, so a mirror of `NavigationItemSchema` attaches the spec's own rule by identifier
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **What the function reads.** Both spellings of `visible`, so it answers the same on an entry as authored and as parsed:
12+
- the authored bare string (`'data.total == 1'`), normalised by `EvaluatedExpressionInputSchema`, the slot's own schema, exactly as the spec's parse normalises it;
13+
- the parsed `{ dialect: 'cel', source }` envelope.
14+
15+
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.

‎packages/spec/api-surface/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,7 @@
540540
"isViewContainerShaped (function)",
541541
"listViewGroupKeyPredicate (function)",
542542
"misplacedDefaultPageRefusal (function)",
543+
"navItemVisibleDataNeedsDataSource (function)",
543544
"normalizeFilterOperator (function)",
544545
"normalizeInlineAction (function)",
545546
"objectNavTargetExclusivity (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@
525525
"isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)",
526526
"listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)",
527527
"misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)",
528+
"navItemVisibleDataNeedsDataSource": "src/ui/app.zod.ts#navItemVisibleDataNeedsDataSource (function)",
528529
"normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)",
529530
"normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)",
530531
"objectNavTargetExclusivity": "src/ui/app.zod.ts#objectNavTargetExclusivity (function)",
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24534] `navItemVisibleDataNeedsDataSource` is EXPORTED, and the export IS
5+
* the check `NavigationItemSchema` runs, so a hand-written mirror of the
6+
* navigation item attaches the spec's own rule by identifier instead of
7+
* restating it. The rule itself (what is refused, where, and with which
8+
* words) is pinned in `app-nav-visible-data-needs-data-source.test.ts`; this
9+
* file pins only the export, on the same four legs as
10+
* `app-nav-target-exclusivity-export.test.ts`, plus the export's input
11+
* contract:
12+
*
13+
* 1. PARITY over the check's matrix (every refusing branch and every
14+
* accepting path) between the export called directly with a collecting
15+
* ctx and `NavigationItemSchema`'s own parse. The matrix authors `visible`
16+
* as the `{ dialect, source }` envelope, the one spelling whose parsed
17+
* value equals what was written, so the two runners read the same input.
18+
* 2. MOUNT: the union `NavigationItemSchema` lazily resolves to carries
19+
* exactly one `custom` check, and that check's issue vector over the
20+
* matrix equals the export's.
21+
* 3. ATTACHMENT BY IDENTIFIER: the module declares the export exactly once
22+
* and chains it by name exactly once, read from the source, so the door
23+
* cannot be running an inline copy that merely agrees on this matrix.
24+
* 4. BARREL identity: `./index` (what `@objectstack/spec/ui` ships) exports
25+
* the very same function object, with the `(value, ctx)` arity.
26+
* 5. BOTH SPELLINGS: the export accepts `visible` as authored (the bare
27+
* string a mirror whose own `visible` stays the authored string hands it)
28+
* and as parsed (the envelope the spec's door hands it), and answers the
29+
* same on both. The card's own entry as written draws the door's identical
30+
* issue from a direct call, and so does its parsed `visible`. Over the leg-1
31+
* matrix rewritten with bare strings, the direct call and the door agree
32+
* issue for issue. The same bare string with a `dataSource` draws nothing,
33+
* and neither does a boolean or absent `visible`. So a mirror attaches the
34+
* export as it is, by identifier.
35+
*
36+
* What it does NOT prove: reference identity between the export and the
37+
* check object the schema holds. zod 4 wraps the function handed to
38+
* `superRefine` in a closure and keeps no handle to it; legs 2 + 3 are the
39+
* substitute, as in the two sibling export pins.
40+
*
41+
* The fixtures are SHAPE-VALID on purpose and the parse leg throws otherwise,
42+
* so "both refuse" can never be true for the wrong reason.
43+
*/
44+
45+
import { describe, it, expect } from 'vitest';
46+
import type { z } from 'zod';
47+
import fs from 'node:fs';
48+
import path from 'node:path';
49+
import { fileURLToPath } from 'node:url';
50+
import { NavigationItemSchema, navItemVisibleDataNeedsDataSource } from './app.zod';
51+
import { EvaluatedExpressionInputSchema } from '../shared/expression.zod';
52+
import * as ui from './index';
53+
54+
const HERE = path.dirname(fileURLToPath(import.meta.url));
55+
56+
// ---------------------------------------------------------------------------
57+
// Harness
58+
// ---------------------------------------------------------------------------
59+
60+
interface IssueSig {
61+
code: string;
62+
path: string;
63+
message: string;
64+
}
65+
66+
interface RawIssueLike {
67+
code?: string;
68+
path?: readonly PropertyKey[];
69+
message?: string;
70+
}
71+
72+
const sig = (i: RawIssueLike): IssueSig => ({
73+
code: String(i.code),
74+
path: (i.path ?? []).map(String).join('.'),
75+
message: String(i.message),
76+
});
77+
78+
const sorted = (issues: IssueSig[]): IssueSig[] =>
79+
[...issues].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
80+
81+
/** Leg (a): call the export directly with a collecting ctx. */
82+
function runExport(value: unknown): IssueSig[] {
83+
const issues: RawIssueLike[] = [];
84+
const ctx = {
85+
value,
86+
issues,
87+
addIssue: (issue: string | RawIssueLike) => {
88+
issues.push(typeof issue === 'string' ? { code: 'custom', message: issue } : { code: 'custom', ...issue });
89+
},
90+
};
91+
navItemVisibleDataNeedsDataSource(value, ctx as unknown as z.RefinementCtx);
92+
return sorted(issues.map(sig));
93+
}
94+
95+
interface ZodCheckLike {
96+
_zod: {
97+
def: { check: string };
98+
check: (payload: { value: unknown; issues: RawIssueLike[] }) => unknown;
99+
};
100+
}
101+
102+
/** Leg (b): run ONE of the schema's own check objects in isolation. */
103+
function runCheckObject(check: ZodCheckLike, value: unknown): IssueSig[] {
104+
const payload = { value, issues: [] as RawIssueLike[] };
105+
check._zod.check(payload);
106+
return sorted(payload.issues.map(sig));
107+
}
108+
109+
/** Leg (c): the door's full parse, restricted to object-level (`custom`) issues.
110+
* Throws when the fixture is not shape-valid — see the header. */
111+
function runParse(value: unknown): IssueSig[] {
112+
const r = NavigationItemSchema.safeParse(value);
113+
if (r.success) return [];
114+
const foreign = r.error.issues.filter((i) => i.code !== 'custom');
115+
if (foreign.length > 0) {
116+
throw new Error(`fixture is not shape-valid — the object-level check never ran: ${JSON.stringify(foreign)}`);
117+
}
118+
return sorted(r.error.issues.map((i) => sig(i as RawIssueLike)));
119+
}
120+
121+
/**
122+
* The checks held by the discriminated union `NavigationItemSchema` lazily
123+
* resolves to — the one place this module chains the refinement (its
124+
* branches carry their own checks; those are not this export's).
125+
*/
126+
function unionChecksOf(schema: unknown): ZodCheckLike[] {
127+
const lazyDef = (schema as { _zod: { def: { type: string; getter: () => unknown } } })._zod.def;
128+
expect(lazyDef.type).toBe('lazy');
129+
const union = lazyDef.getter() as { _zod: { def: { type: string; checks?: ZodCheckLike[] } } };
130+
expect(union._zod.def.type).toBe('union');
131+
return union._zod.def.checks ?? [];
132+
}
133+
134+
/** One issue vector per runner over the matrix — the equality's key. */
135+
const vectorOf = (run: (value: unknown) => IssueSig[], values: unknown[]): string =>
136+
JSON.stringify(values.map(run));
137+
138+
// ---------------------------------------------------------------------------
139+
// Fixture matrix — every refusing branch, plus every accepting path
140+
// ---------------------------------------------------------------------------
141+
142+
interface Fixture {
143+
label: string;
144+
value: Record<string, unknown>;
145+
/** The paths the check is expected to refuse at; `[]` is an accepting path. */
146+
refusesAt: string[];
147+
}
148+
149+
/** The card's entry, as written there. */
150+
const CARD_ENTRY = { id: 'nav_envs', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' } as const;
151+
const API_BINDING = { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' } as const;
152+
const cel = (source: string) => ({ dialect: 'cel', source }) as const;
153+
154+
const fixtures: Fixture[] = [
155+
// Refusals — the one rule, on the branches that spread the base.
156+
{ label: 'the card\'s entry, `visible` as the envelope', value: { ...CARD_ENTRY, visible: cel('data.total == 1') }, refusesAt: ['visible'] },
157+
{ label: 'a `dashboard` entry', value: { id: 'nav_ops', type: 'dashboard', dashboardName: 'ops', visible: cel('!data.has_more') }, refusesAt: ['visible'] },
158+
{ label: 'a `url` entry reading the bare root', value: { id: 'nav_cloud', type: 'url', url: '/apps/cloud', visible: cel('data') }, refusesAt: ['visible'] },
159+
{ label: 'a `group` entry', value: { id: 'nav_group', type: 'group', children: [], visible: cel('has(data.total)') }, refusesAt: ['visible'] },
160+
// Accepting paths — the control the card names, and the reader's edges.
161+
{ label: 'the card\'s entry WITH a `dataSource` (the control)', value: { ...CARD_ENTRY, visible: cel('data.total == 1'), dataSource: API_BINDING }, refusesAt: [] },
162+
{ label: 'an ambient root only (`current_user`)', value: { ...CARD_ENTRY, visible: cel('\'org_admin\' in current_user.positions') }, refusesAt: [] },
163+
{ label: 'a member named `data`, not the root', value: { ...CARD_ENTRY, visible: cel('features.data == true') }, refusesAt: [] },
164+
{ label: 'no `visible` at all', value: { id: 'nav_envs', type: 'object', objectName: 'sys_environment' }, refusesAt: [] },
165+
];
166+
167+
const matrix = fixtures.map((f) => f.value);
168+
const refusing = fixtures.filter((f) => f.refusesAt.length > 0);
169+
const accepting = fixtures.filter((f) => f.refusesAt.length === 0);
170+
171+
// ---------------------------------------------------------------------------
172+
// Leg 1 — parity on every fixture between the export and the door
173+
// ---------------------------------------------------------------------------
174+
175+
describe('navItemVisibleDataNeedsDataSource — parity between the export and NavigationItemSchema', () => {
176+
it.each(fixtures)('$label — the direct call refuses at exactly the declared paths', ({ value, refusesAt }) => {
177+
const direct = runExport(value);
178+
expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort());
179+
for (const issue of direct) expect(issue.code).toBe('custom');
180+
});
181+
182+
it.each(fixtures)('$label — the NavigationItemSchema parse and the direct call agree issue for issue', ({ value }) => {
183+
expect(runParse(value)).toEqual(runExport(value));
184+
});
185+
186+
it('the matrix has both kinds of path, so agreement is not vacuous', () => {
187+
expect(refusing.length).toBeGreaterThanOrEqual(4);
188+
expect(accepting.length).toBeGreaterThanOrEqual(4);
189+
});
190+
});
191+
192+
// ---------------------------------------------------------------------------
193+
// Leg 2 — the mount: the union carries the check
194+
// ---------------------------------------------------------------------------
195+
196+
describe('the mount — the union NavigationItemSchema resolves to carries the check', () => {
197+
it('carries exactly one `custom` check', () => {
198+
expect(unionChecksOf(NavigationItemSchema).map((c) => c._zod.def.check)).toEqual(['custom']);
199+
});
200+
201+
it('that check is behaviourally identical to the export over the whole matrix', () => {
202+
const [check] = unionChecksOf(NavigationItemSchema);
203+
expect(vectorOf((v) => runCheckObject(check, v), matrix)).toBe(vectorOf(runExport, matrix));
204+
// …and the export is not a no-op on its own matrix (an all-empty vector
205+
// would match any dead check).
206+
expect(vectorOf(runExport, matrix)).not.toBe(vectorOf(() => [], matrix));
207+
});
208+
});
209+
210+
// ---------------------------------------------------------------------------
211+
// Leg 3 — attached by identifier, in the module that declares the schema
212+
// ---------------------------------------------------------------------------
213+
214+
describe('app.zod.ts attaches the export BY IDENTIFIER — no inline copy', () => {
215+
const src = fs.readFileSync(path.join(HERE, 'app.zod.ts'), 'utf8');
216+
// An attachment is a CODE line carrying `.superRefine(name)`; docblock
217+
// (` * `) and `//` lines are excluded, so prose naming the same spelling is
218+
// not counted.
219+
const attachments = (name: string): number =>
220+
src.split(/\r?\n/).filter((line) => !/^[ \t]*(\*|\/\/)/.test(line) && line.includes(`.superRefine(${name})`)).length;
221+
// A NAME is only a sound key for that count if the module declares it exactly
222+
// once — a second, shadowing binding would satisfy the count while the door
223+
// chains a different function object.
224+
const declarations = (name: string): number =>
225+
src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0;
226+
227+
it('declares `export function navItemVisibleDataNeedsDataSource(` exactly once', () => {
228+
expect(src).toContain('export function navItemVisibleDataNeedsDataSource(');
229+
expect(declarations('navItemVisibleDataNeedsDataSource')).toBe(1);
230+
});
231+
232+
it('chains it exactly once — on the union, by name', () => {
233+
expect(attachments('navItemVisibleDataNeedsDataSource')).toBe(1);
234+
});
235+
});
236+
237+
// ---------------------------------------------------------------------------
238+
// Leg 4 — the barrel ships the same function object
239+
// ---------------------------------------------------------------------------
240+
241+
describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function object', () => {
242+
it('navItemVisibleDataNeedsDataSource — reference identity, and the `(value, ctx)` arity', () => {
243+
expect((ui as Record<string, unknown>).navItemVisibleDataNeedsDataSource).toBe(navItemVisibleDataNeedsDataSource);
244+
expect(typeof navItemVisibleDataNeedsDataSource).toBe('function');
245+
expect(navItemVisibleDataNeedsDataSource.length).toBe(2);
246+
});
247+
});
248+
249+
// ---------------------------------------------------------------------------
250+
// Leg 5 — both spellings: the export reads `visible` as authored and as parsed
251+
// ---------------------------------------------------------------------------
252+
253+
/** The leg-1 matrix with each envelope `visible` written back as the bare string an author writes. */
254+
const authoredFixtures: Fixture[] = fixtures
255+
.filter((f) => f.value.visible !== undefined)
256+
.map((f) => ({
257+
...f,
258+
label: `${f.label}, \`visible\` written as the bare string`,
259+
value: { ...f.value, visible: (f.value.visible as { source: string }).source },
260+
}));
261+
262+
describe('both spellings — the export reads `visible` as authored and as parsed, and answers the same', () => {
263+
const parsedVisible = EvaluatedExpressionInputSchema.parse(CARD_ENTRY.visible);
264+
265+
it('the spec normalises the bare string to the envelope the check reads', () => {
266+
expect(parsedVisible).toEqual(cel('data.total == 1'));
267+
});
268+
269+
it('the door refuses the card\'s entry as written, with one `custom` issue at `visible`', () => {
270+
const door = runParse(CARD_ENTRY);
271+
expect(door).toHaveLength(1);
272+
expect(door[0]).toMatchObject({ code: 'custom', path: 'visible' });
273+
});
274+
275+
it('the export raises the identical issue on that entry\'s parsed `visible`', () => {
276+
expect(runExport({ ...CARD_ENTRY, visible: parsedVisible })).toEqual(runParse(CARD_ENTRY));
277+
});
278+
279+
it('the export raises the door\'s identical issue (code, path and message) on the card\'s entry AS WRITTEN', () => {
280+
const direct = runExport(CARD_ENTRY);
281+
expect(direct).toHaveLength(1);
282+
expect(direct).toEqual(runParse(CARD_ENTRY));
283+
});
284+
285+
it.each(authoredFixtures)('$label — the direct call refuses at exactly the declared paths, issue for issue with the door', ({ value, refusesAt }) => {
286+
const direct = runExport(value);
287+
expect(direct.map((i) => i.path).sort()).toEqual([...refusesAt].sort());
288+
expect(runParse(value)).toEqual(direct);
289+
});
290+
291+
it('the authored matrix has both kinds of path, so agreement is not vacuous', () => {
292+
expect(authoredFixtures.filter((f) => f.refusesAt.length > 0).length).toBeGreaterThanOrEqual(4);
293+
expect(authoredFixtures.filter((f) => f.refusesAt.length === 0).length).toBeGreaterThanOrEqual(3);
294+
});
295+
296+
it('the control — the same bare string with a `dataSource` — draws nothing, from the door or the export', () => {
297+
const withSource = { ...CARD_ENTRY, dataSource: API_BINDING };
298+
expect(NavigationItemSchema.safeParse(withSource).success).toBe(true);
299+
expect(runExport(withSource)).toEqual([]);
300+
expect(runExport({ ...withSource, visible: parsedVisible })).toEqual([]);
301+
});
302+
303+
it('a boolean or absent `visible` judges nothing', () => {
304+
expect(runExport({ ...CARD_ENTRY, visible: true })).toEqual([]);
305+
expect(runExport({ ...CARD_ENTRY, visible: false })).toEqual([]);
306+
expect(runExport({ id: CARD_ENTRY.id, type: CARD_ENTRY.type, objectName: CARD_ENTRY.objectName })).toEqual([]);
307+
});
308+
});

0 commit comments

Comments
 (0)