Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/8344-node-recursion-point-redirect.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ and the deferral itself is the part that stayed true. ⛔ Nothing else in the pa
the nested verdict, the installed arm's check and the "narrows at all 109, widens at none"
reading are all readings of THIS card's head and cannot rot.

⚠️ **AMENDED AGAIN (objectui#9659 — also landing in THIS release).** One clause of the note
above has since moved: "the installed arm's check" is GONE. The check it names was the
`superRefine` clause on `defineNodeComponentUnion`, and once ruling A retired the key it
narrowed, it could no longer fire for any input — measured on the issue tree of a nested
refusal, which carries the arm's own tombstone and no clause-shaped issue, against a lit
control built from the pre-retirement record arm. objectui#9659 retires the clause and
re-points the pin that read its shape. ⛔ The READING is unaffected and still cannot rot: the
nested verdict is the same (refused before, refused now, by the arm instead of by the
clause), the accept set is byte-identical over 432 corpus documents plus a 60-case chatbot
sweep, and "narrows at all 109, widens at none" is still what this card's redirect does. What
changed is WHICH mechanism delivers the nested refusal, and the sentence above names the
retired one.

**3. ⚠️ KNOWN GAP, declared rather than papered over: a bundled consumer that never reads
`AnyComponentSchema` can tree-shake the redirect away.** This package declares
`"sideEffects": false` and the arm is filled by a statement in the `./zod` barrel's body, so a
Expand Down
74 changes: 74 additions & 0 deletions .changeset/9659-node-recursion-point-inert-clause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
'@object-ui/types': patch
---

Retire the objectui#8344 `superRefine` clause on the node recursion point, and re-point the
two test legs that had become readings of an inert thing (objectui#9659, carrying
non-blocking residuals a contract review named on objectui#9639).

**What was there.** `defineNodeComponentUnion` (`zod/base.zod.ts`) installed the component
union into the node slot WRAPPED in a `superRefine` clause that re-issued the node-slot's own
refusal under `body` for a `chatbot` node. #8344 needed it because `ChatbotSchema.body` was
then a record — the one redeclaration across the arms that was WIDER than the base key it
restated — so installing the union bare would have narrowed at 108 child slots and widened at
one.

**Why it is gone, measured rather than argued.** Ruling A on objectui#8572 made
`ChatbotSchema.body` an ADR-0049 retirement tombstone on both published faces, and
objectui#9639 landed it. The clause could then no longer FIRE, which is a stronger statement
than "no longer matter":

- zod skips a check once the schema it wraps has refused, and the arm refuses every DEFINED
`body` — measured over record / node / node array / string / number / boolean / null /
empty array / empty record, all REFUSED — while the clause's own first line returns early
on `undefined`. The two conditions "reaches the clause" and "has a `body` to check" are
disjoint, with no input in between.
- read off the issue tree of a nested refusal: the arm's tombstone at the child's own `body`
path and NO clause-shaped issue. LIT CONTROL, same probe, same document, with the
pre-objectui#9639 record arm rebuilt in place: the clause's issue appears there and the
tombstone does not. That control is what makes the zero a reading instead of a broken
instrument.

**The accept set does not move, and that is measured too.** 432 schema-catalog documents
through `safeValidateSchema`, plus a 60-case sweep of the three chatbot faces x ten `body`
shapes x both depths: byte-identical verdict list before and after. A clause that cannot fire
cannot be narrowing anything.

**The wrapper's OTHER role is kept, not dropped.** The read-back assertion that announces a
zod which stopped keeping its option array by reference is untouched, and
`node-recursion-point-8344.test.ts`'s `fill is LIVE` leg is re-pointed from the wrapper's
SHAPE — `not.toBe` the bare union, plus `checks` of length exactly 1 — to the INSTALLATION:
slot 0 holds the component union and not the pre-#8344 base shape. That pin still fails on
the failure it exists for; an unfilled holder answers a different object.

**A second leg was vacuous and is repaired.** `names body in the refusal` read
`JSON.stringify(issues)` for the substring `"body"`, which the PARENT card slot supplies for
any refused child. Measured: three documents with nothing wrong at `body` — a nested off-spec
`icon`, a nested unmirrored `metric-card`, a nested `chatbot` missing `messages` — all
satisfy the old assertion, and none carries an issue at the child's own `body` path. The leg
now reads that path, asserts the remedy reaches the author at DEPTH and not only at the root,
and the three controls are pinned alongside it so the discrimination is tested rather than
described.

**`patch` rather than empty frontmatter, and the grade is measured.** `packages/types`
publishes `dist`, and this change moves published bytes: the clause leaves the shipped bundle
(`dist/zod/index.zod.js` 515,520 to 516,078 bytes; `dist/zod/base.zod.js` 43,462 to 44,370),
and the declaration emit moves with it. Positive control: neighbouring shipped content is
present in both builds and two builds of the same source are byte-identical, so the delta is
this change and not build noise. ⛔ An empty frontmatter here would have been a claim that
nothing published moved, and it would have been false.

**Prose amended where this change falsified it, ⛔ not rewritten.** `zod/index.zod.ts` said
`defineNodeComponentUnion` "wraps it rather than replacing it" — true only while a wrapper
existed. `zod/base.zod.ts`'s note on the loose parameter bound still names `ChatbotSchema`'s
record `body` in the present tense; the paragraph is kept for the reason it records and
carries an AMENDED note saying the exclusion set that pin reads is now empty.

**One reading in a neighbouring table got a line, not a rewrite.** In
`content-channel-family-d-9256.test.ts` the plain `chatbot` row lists `children` only, which
on the two TWIN faces still means "`body` held out and live" — and objectui#9639 had to
re-point that table's LIVE CONTROL at a twin precisely because the plain face refuses `body`
now, for objectui#8572's reason rather than this table's. Measured: `body` ACCEPTED on
`chatbot-enhanced` and `chatbot-floating`, REFUSED on `chatbot`. The row is annotated as
one-sided so the next reader does not take it for a two-sided reading. ⛔ No assertion in that
table moved.
16 changes: 16 additions & 0 deletions packages/types/src/__tests__/content-channel-family-d-9256.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,22 @@ const ROWS: ReadonlyArray<readonly [
['table', TableMirror as unknown as Mirror, ['body', 'children'], {"data":[],"columns":[]}],
['data-table', DataTableMirror as unknown as Mirror, ['body', 'children'], {"data":[],"columns":[]}],
['calendar-view', CalendarViewMirror as unknown as Mirror, ['body', 'children'], {}],
// ⚠️ ONE-SIDED ROW, and ⛔ not a two-sided reading (objectui#9659, carrying a
// contract-review residual on objectui#9639). The three rows below list `children`
// only, and on the two TWIN faces that still means what it always meant: `children`
// dead, `body` held out and LIVE, with the same-face LIVE CONTROL below proving it.
// On the PLAIN `chatbot` face it no longer does. Ruling A on objectui#8572 retired
// `ChatbotSchema.body` as an ADR-0049 tombstone for a DIFFERENT reason than this card's
// — a naming collision, not a dead content channel — and objectui#9639 landed it, so
// the plain face refuses BOTH channels today. Measured: `body` is ACCEPTED on
// `chatbot-enhanced` and `chatbot-floating`, REFUSED on `chatbot`.
// ⇒ the absence of `body` from the plain row means "not this card's to assert", ⛔ not
// "still live here", and objectui#9639 had to re-point both controls below at a twin
// precisely because no same-face control is available any more. The `body` half of the
// plain face is pinned by `node-recursion-point-8344.test.ts`, which owns objectui#8572.
// ⛔ Do not add `'body'` to the plain row to "fix" this: the message that row's
// assertions read is objectui#9256's, and the plain face's tombstone carries
// objectui#8572's instead — the row would go red on a true statement.
['chatbot', ChatbotMirror as unknown as Mirror, ['children'], {"messages":[]}],
['chatbot-enhanced', ChatbotEnhancedMirror as unknown as Mirror, ['children'], {"messages":[]}],
['chatbot-floating', ChatbotFloatingMirror as unknown as Mirror, ['children'], {"messages":[]}],
Expand Down
94 changes: 80 additions & 14 deletions packages/types/src/__tests__/node-recursion-point-8344.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,26 +156,33 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', ()
expect(AnyComponentSchema.safeParse(nested(LEGAL_ICON)).success).toBe(true);
});

it('the fill is LIVE, and slot 0 holds the WRAPPED union, not the bare one', () => {
it('the fill is LIVE, and slot 0 holds the COMPONENT UNION, not the pre-objectui#8344 base shape', () => {
// `z.union` re-reads its option array on every parse, so the recursion point is whatever
// slot 0 holds NOW — not whatever it held when some other file in this worker first
// parsed something (the unit project runs `isolate: false`, one module graph per worker).
// ⛔ Do not assert `toBe(AnyComponentSchema)` here: what is installed is deliberately a
// `superRefine` WRAPPER rather than the bare union, and a pin on the bare union would go
// green the moment the wrapper stopped being installed. ⚠️ The clause that wrapper carries
// is objectui#8344's `chatbot` body narrowing, and objectui#8572 retired the arm it
// narrowed — so what this leg reads is the wrapper's INSTALLATION, not the clause's
// usefulness; the clause's disposition belongs to `../zod/base.zod.ts` and is not made
// here.
//
// ⭐ RE-POINTED at the INSTALLATION by objectui#9659, carrying a contract-review residual
// on objectui#9639. This leg used to read the wrapper's SHAPE — `not.toBe` the bare union,
// plus `checks` of length exactly 1 — because `defineNodeComponentUnion` installed a
// `superRefine` clause narrowing objectui#8344's `chatbot` `body`. Ruling A on
// objectui#8572 retired the arm that clause narrowed, and objectui#9659 measured the
// consequence: the clause could no longer FIRE for any input, so those two assertions had
// become a pin on the shape of an inert clause. The clause is retired at its source and
// this leg now reads what it was always really for — that the fill TOOK.
//
// ⚠️ The old warning here — "⛔ do not assert `toBe(AnyComponentSchema)`, it would go green
// the moment the wrapper stopped being installed" — was correct WHILE a wrapper existed,
// and it retires with the wrapper. It is not a licence to reintroduce one: with nothing
// wrapped, identity with the component union is the strongest reading available, and it
// FAILS on the failure this leg exists for — an unfilled holder still answers
// `BaseSchemaCore`, which is a different object.
const arm = (SchemaNodeSchema as unknown as {
_zod: { def: { getter: () => { _zod: { def: { options: readonly { _zod: { propValues?: Record< string, unknown >; def: { checks?: unknown[] } } }[] } } } } };
})._zod.def.getter()._zod.def.options[0];
expect(arm).not.toBe(AnyComponentSchema);
// it is still the discriminated union objectui#8498 built — the discrimination survives
// the wrapper, which is what keeps a nested refusal costing one arm instead of 106
expect(arm).toBe(AnyComponentSchema as unknown as typeof arm);
// and it is still the discriminated union objectui#8498 built, which is what keeps a
// nested refusal costing one arm instead of 106.
expect(Object.keys(arm._zod.propValues ?? {})).toContain('type');
// and it carries exactly the one check that narrowing adds.
expect(arm._zod.def.checks).toHaveLength(1);
});

});
Expand Down Expand Up @@ -273,10 +280,69 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a
expect(AnyComponentSchema.safeParse(nested(CHATBOT)).success).toBe(true);
});

it('names `body` in the refusal, so the author is told which key is wrong', () => {
it('names `body` AT ITS OWN PATH one slot down, and carries the remedy there too', () => {
// ⭐ RE-POINTED by objectui#9659, carrying a contract-review residual on objectui#9639.
// This leg used to read `JSON.stringify(issues)` for the substring `"body"` and was
// VACUOUS: the parent card slot puts `"body"` in the issue path for ANY refused child, so
// the old assertion held whether or not the `chatbot` arm named anything. Measured on this
// head — three documents with nothing wrong at `body`, all three satisfying the old
// assertion, none of them carrying an issue at the child's own `body` path:
//
// nested off-spec `icon` (size: 'huge') → blob contains `"body"`: true, issues at
// `body.0.body`: 0
// nested unmirrored `metric-card` → true / 0
// nested `chatbot` missing `messages` → true / 0
// nested `chatbot` with a record `body` → true / 1 ← the only one that is about `body`
//
// ⇒ the reading that discriminates is the child's OWN path, not the serialized blob. The
// controls above are kept as assertions below so the discrimination is pinned rather than
// recorded in prose.
const result = AnyComponentSchema.safeParse(nested(withRecordBody));
expect(result.success).toBe(false);
if (result.success) return;
const at = (issues: readonly unknown[], path: string): { code?: string; message?: string }[] => {
const out: { code?: string; message?: string }[] = [];
const walk = (node: unknown, prefix: readonly (string | number)[]): void => {
if (Array.isArray(node)) { for (const child of node) walk(child, prefix); return; }
const issue = node as { path?: readonly (string | number)[]; errors?: readonly unknown[]; code?: string; message?: string };
const here = [...prefix, ...(issue.path ?? [])];
if (here.join('.') === path) out.push({ code: issue.code, message: issue.message });
for (const bucket of issue.errors ?? []) walk(bucket, here);
};
walk(issues, []);
return out;
};
const named = at(result.error.issues, 'body.0.body');
expect(named.length).toBeGreaterThan(0);
// the refusal one slot down is the ARM's tombstone, and the REMEDY reaches the author at
// depth and not only at the root — the root leg above reads the same string at depth 0.
expect(named.some((i) => i.code === 'invalid_type')).toBe(true);
expect(named.some((i) => (i.message ?? '').includes('requestBody'))).toBe(true);
});

it.each([
['off-spec `icon`', { type: 'icon', icon: 'check', size: 'huge' }],
['unmirrored `metric-card`', { type: 'metric-card', title: 'x' }],
['`chatbot` missing `messages`', { type: 'chatbot' }],
])('CONTROL — a nested %s is refused with NOTHING at the child\'s `body` path', (_label, child) => {
// These are the documents that made the old leg vacuous. Each is refused for a reason that
// has nothing to do with `body`, so the leg above must find nothing at `body.0.body` here.
// ⛔ Do not "repair" a future failure by widening the path: a refusal that starts naming
// `body` for an off-spec `icon` is a defect in the recursion point, not in this control.
const result = AnyComponentSchema.safeParse(nested(child));
expect(result.success).toBe(false);
if (result.success) return;
const paths: string[] = [];
const walk = (node: unknown, prefix: readonly (string | number)[]): void => {
if (Array.isArray(node)) { for (const c of node) walk(c, prefix); return; }
const issue = node as { path?: readonly (string | number)[]; errors?: readonly unknown[] };
const here = [...prefix, ...(issue.path ?? [])];
paths.push(here.join('.'));
for (const bucket of issue.errors ?? []) walk(bucket, here);
};
walk(result.error.issues, []);
expect(paths).not.toContain('body.0.body');
// and the old assertion holds anyway — which is the whole reason it was replaced.
expect(JSON.stringify(result.error.issues)).toContain('"body"');
});
});
Expand Down
Loading
Loading