From 3a3a42c7284e8dc6d89be9df49ba2ea2fa333c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 21:28:08 +0000 Subject: [PATCH] refactor(sdui-parser)!: retire the zero-writer `binding: 'field'` arm on all three faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui retired the same arm from its copy of this package (maintainer ruling of 2026-09-07 on objectui#6950, director decision batch #69, on the serializer input boundary; objectui#8315 on the two faces in types.ts), citing enforce-or-remove on a zero-writer measurement. Nothing propagates a retirement between the two copies of packages/sdui-parser, so this one kept the arm on all three declarations while the renderer that ships beside it no longer has it. Re-measured on this tree rather than inherited, each reading with its control: binding: 'field' 0 writers (control binding: 'object' = 2) sdui.manifest.json 0 binding keys across 339 inputs readers outside the pkg 0 (the single importer, @objectstack/lint's validate-jsx-pages.ts, destructures { diagnostics } only) branches on either arm 0 Narrowed: RegistryConfigLike.inputs[].binding, ManifestInput.binding and ValidationResult.bindings[].kind. Compile-time only — types are erased and validateTree still forwards whatever the manifest says, which the new pin states outright so the narrowing is not mistaken for a runtime rejection. The pin goes red in both directions: a @ts-expect-error that stops being needed is ts(2578), so widening any declaration back fails the package typecheck. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- ...3-sdui-parser-binding-field-arm-retired.md | 61 ++++++++++++ .../__tests__/binding-field-retired.test.ts | 98 +++++++++++++++++++ packages/sdui-parser/src/index.ts | 19 +++- packages/sdui-parser/src/types.ts | 67 ++++++++++++- 4 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 .changeset/16583-sdui-parser-binding-field-arm-retired.md create mode 100644 packages/sdui-parser/src/__tests__/binding-field-retired.test.ts diff --git a/.changeset/16583-sdui-parser-binding-field-arm-retired.md b/.changeset/16583-sdui-parser-binding-field-arm-retired.md new file mode 100644 index 00000000000..6deca671bc2 --- /dev/null +++ b/.changeset/16583-sdui-parser-binding-field-arm-retired.md @@ -0,0 +1,61 @@ +--- +'@objectstack/sdui-parser': minor +--- + +Retire the zero-writer `binding: 'field'` arm from all three of this copy's declarations, so the save gate states the same one-word vocabulary the renderer already does (#16583) + +objectui retired the same arm from its copy of this package: the maintainer +ruling of 2026-09-07 on objectui#6950 (director decision batch #69) took the +serializer's input boundary, objectui#8315 took the two faces in `types.ts`, +both citing enforce-or-remove on a zero-writer measurement. That ruling names +coordinates in objectui only, and nothing propagates a retirement across the +two copies of `packages/sdui-parser` — so this one kept the arm on all three +declarations while the renderer that ships beside it no longer has it. This is +that port, measured here rather than inherited. + +- `RegistryConfigLike.inputs[].binding` — now `'object'` +- `ManifestInput.binding` — now `'object'` +- `ValidationResult.bindings[].kind` — now `'object'` + +**Breaking for TypeScript consumers, deliberately, and compile-time only.** A +registry config, a hand-written `Manifest` literal or a `bindings[]` entry that +spells `'field'` is now a `tsc` error. Runtime behaviour does not move: types +are erased, this package runs no validator over a `Manifest` it is handed, and +`validateTree` still forwards whatever the manifest says. A pin in +`src/__tests__/binding-field-retired.test.ts` states that limit outright, so the +narrowing is not mistaken for a runtime rejection, and it goes red in both +directions — a `@ts-expect-error` that stops being needed is itself `ts(2578)`, +so widening any of the three declarations back fails the package typecheck on +the very line that documents the retirement. + +**Nothing measured has to be rewritten, and the key was never author-writable +here.** `binding` is not a spec key, has no Zod schema and no stored +representation; it reaches this package only through the structural +`RegistryConfigLike` boundary, which exists so the package can be fed +objectui's `ComponentRegistry.getAllConfigs()` without depending on it. Four +readings on this tree, each with its control: `binding: 'field'` has zero +writers in this repository against a firing `binding: 'object'` control of 2 +(both under `packages/sdui-parser/src/__tests__/`); the tracked +`sdui.manifest.json` — the only manifest this repo produces — carries zero +`binding` keys across all 339 of its inputs; nothing outside the package reads +`binding` or `bindings[].kind` at all, the package's single importer +(`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring `{ diagnostics }` +only; and no arm of the vocabulary is branched on anywhere, so no consumer +loses a case it was handling. + +**Why the reader face is narrowed too.** The counter-argument — producer to +reader is a subset relation, so a permissive reader is not wrong — was answered +rather than assumed away. `ManifestInput` is not a pure reader face +(`manifestFromConfigs` returns it), and `bindings[].kind` is a pure **producer** +face where the relation inverts: a wider union there accepts nothing extra, it +obliges every consumer to handle an arm this package cannot emit. The two are +coupled by `validateTree`'s `kind: input.binding` assignment, so narrowing one +alone would need a cast at the only conversion site — the lenient consumer-side +fallback Prime Directive #12 bans. The reasoning now lives on the declarations +themselves, where a later reader lands. + +The reopen route is the ruling's own: a measured need for field bindings is +filed as a widening with the vocabulary decided then, not pre-declared here for +a producer that does not exist. + + diff --git a/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts b/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts new file mode 100644 index 00000000000..a56f3751010 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/binding-field-retired.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { compile, manifestFromConfigs, type RegistryConfigLike } from '../index.js'; +import type { Manifest, ManifestInput, ValidationResult } from '../types.js'; + +/** + * The `binding: 'field'` arm is retired on all THREE faces this package + * declares it on, and this file is the pin that keeps it retired. + * + * objectui retired the same arm from its copy of this package — the + * maintainer ruling of 2026-09-07 on objectui#6950 (director decision batch + * #69) on the serializer's input boundary, objectui#8315 on the two faces in + * `types.ts` — citing enforce-or-remove on a zero-writer measurement. Nothing + * carried that across to this copy. The narrowing here is that port. + * + * ## Why the pins are `@ts-expect-error` and not runtime assertions + * + * The retirement is COMPILE-TIME ONLY, deliberately and unavoidably: types are + * erased, this package runs no validator over a `Manifest` it is handed, and + * `validateTree` forwards whatever the manifest says. The last test below + * states that limit on purpose, so a later reader does not mistake the + * narrowing for a runtime rejection and go looking for the refusal path that + * would have to exist for it. + * + * ## Why this pin goes red in BOTH directions + * + * A `@ts-expect-error` that stops being needed is itself a `tsc` error + * (`ts(2578)`, "Unused '@ts-expect-error' directive"). So widening any of the + * three declarations back to `'object' | 'field'` fails `pnpm --filter + * @objectstack/sdui-parser typecheck` on the very line that documents the + * retirement, rather than leaving a green suite behind. The live + * `binding: 'object'` control beside each pin is what proves the declaration + * was not simply deleted: a pin whose positive control does not compile is + * asserting nothing. + */ +describe("binding: 'field' is retired on every face (objectui#6950, objectui#8315)", () => { + it('face 1 — RegistryConfigLike, the serializer input boundary, takes only `object`', () => { + const live: RegistryConfigLike = { + type: 'object-table', + inputs: [{ name: 'object', type: 'string', binding: 'object' }], + }; + + const retired: RegistryConfigLike = { + type: 'object-table', + // @ts-expect-error — the retired 'field' arm (objectui#6950). + inputs: [{ name: 'object', type: 'string', binding: 'field' }], + }; + + // The control still serializes, so the key itself is alive, not deleted. + expect(manifestFromConfigs([live]).components['object-table'].inputs[0].binding).toBe('object'); + expect(retired.type).toBe('object-table'); + }); + + it('face 2 — ManifestInput, the manifest reader/producer face, takes only `object`', () => { + const live: ManifestInput = { name: 'object', type: 'string', binding: 'object' }; + // @ts-expect-error — the retired 'field' arm (objectui#8315). + const retired: ManifestInput = { name: 'object', type: 'string', binding: 'field' }; + + expect(live.binding).toBe('object'); + expect(retired.name).toBe('object'); + }); + + it('face 3 — ValidationResult.bindings[].kind, the producer face, emits only `object`', () => { + const live: ValidationResult['bindings'] = [ + { tag: 'object-table', input: 'object', kind: 'object', value: 'account' }, + ]; + const retired: ValidationResult['bindings'] = [ + // @ts-expect-error — the retired 'field' arm (objectui#8315). + { tag: 'object-table', input: 'object', kind: 'field', value: 'name' }, + ]; + + expect(live[0].kind).toBe('object'); + expect(retired[0].tag).toBe('object-table'); + }); + + it('the narrowing is compile-time only — a cast-in `field` manifest still round-trips', () => { + // Unrepresentable in the type, so it takes a cast to build at all. That IS + // the limit being pinned: nothing in this package refuses the value at + // runtime, and a consumer handing us one gets it back verbatim. + const smuggled = { + components: { + 'object-table': { + type: 'object-table', + namespace: 'plugin-grid', + isContainer: false, + inputs: [{ name: 'object', type: 'string', required: true, binding: 'field' }], + }, + }, + } as unknown as Manifest; + + const r = compile('', smuggled); + + expect(r.ok).toBe(true); + expect(r.bindings).toEqual([ + { tag: 'object-table', input: 'object', kind: 'field', value: 'account' }, + ]); + }); +}); diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 686a280efb5..97b94de7642 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -78,7 +78,24 @@ export interface RegistryConfigLike { type: string | string[]; required?: boolean; enum?: Array; - binding?: 'object' | 'field'; + /** + * The binding marker — exactly `'object'`. This is the SERIALIZER'S INPUT + * BOUNDARY, the narrowest of the three faces the arm stood on, and the one + * objectui's ruling named: a config that feeds `manifestFromConfigs` a + * `binding: 'field'` input is a type error rather than a manifest entry + * the server would never resolve. + * + * Unlike {@link RegistryConfigLike.inputs}`[].type` above — typed loosely + * on purpose so an off-vocabulary value stays representable and is + * normalized on the way in — `binding` is NOT normalized anywhere: + * `manifestFromConfigs` copies it through verbatim. There is no + * canonicalizer to absorb a fourth spelling, so the boundary is where the + * vocabulary has to hold. + * + * The retirement, the answered counter-argument and the measurements are + * recorded once, on {@link ManifestInput.binding} in `types.ts`. + */ + binding?: 'object'; description?: string; }>; } diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts index 4ad6839b178..3a7fc45dac0 100644 --- a/packages/sdui-parser/src/types.ts +++ b/packages/sdui-parser/src/types.ts @@ -80,8 +80,57 @@ export interface ManifestInput { required?: boolean; /** allowed values for `enum` inputs */ enum?: Array; - /** marks a data-binding input the server must resolve (ADR-0080 §6.3) */ - binding?: 'object' | 'field'; + /** + * Marks a data-binding input the server must resolve (ADR-0080 §6.3): + * `binding: 'object'` says the input NAMES an object, so the server-side + * binding check knows what to resolve it against. Unrelated to + * `type: 'object'`, the coarse control kind — a record-shaped value and an + * object-naming input are two different facts. + * + * ## The vocabulary is exactly `'object'` + * + * `'field'` stood beside it from the first draft of ADR-0080 and was never + * written. objectui retired it from the same three declarations of its copy + * of this package — the maintainer ruling of 2026-09-07 on objectui#6950 + * (director decision batch #69) on the serializer's input boundary, and + * objectui#8315 on the two faces here — citing enforce-or-remove on the + * ground that the arm has zero writers. Nothing propagated that to this + * copy; this declaration is that port. + * + * ⚠️ The argument for leaving THIS face wide was answered, not overlooked. + * It runs: producer → reader is a subset relation, so a reader accepting a + * value no producer emits is permissive rather than wrong. Against it: + * + * 1. **This is not a pure reader face.** `manifestFromConfigs` RETURNS a + * `Manifest`, so `ManifestInput` is also this package's OUTPUT type, + * fed straight from the already-narrowed `RegistryConfigLike` boundary + * in `index.ts`. A union wider than the producer's is imprecision on + * the way out, not permissiveness on the way in. + * 2. **Its sibling is a pure producer face.** + * {@link ValidationResult.bindings}`[].kind` is written by + * `validateTree` by copying this key, so the subset relation runs the + * other way there — see that declaration. The two are COUPLED by that + * assignment: narrowing one alone needs a cast at the only conversion + * site, which is the lenient consumer-side fallback Prime Directive #12 + * bans. So "both narrow" and "both wide" were the only self-consistent + * states. + * 3. **The permissiveness protected nothing HERE either.** Re-measured on + * this tree rather than inherited: `binding: 'field'` has zero writers + * in this repository, against a firing `binding: 'object'` control of + * 2 (both under `src/__tests__/`); the tracked `sdui.manifest.json` — + * the only manifest this repo produces, serialized from objectui's live + * registry — carries zero `binding` keys across all 339 of its inputs; + * and nothing outside this package reads `binding` or + * `bindings[].kind` at all, the package's single importer + * (`@objectstack/lint`'s `validate-jsx-pages.ts`) destructuring + * `{ diagnostics }` only. + * + * The reopen route is the ruling's own: a MEASURED need for field bindings + * is filed as a widening with the vocabulary decided then — not pre-declared + * here for a producer that does not exist. The refusal is pinned in + * `src/__tests__/binding-field-retired.test.ts`. + */ + binding?: 'object'; description?: string; } @@ -103,6 +152,16 @@ export interface ValidationResult { diagnostics: Diagnostic[]; /** unique plugin namespaces referenced — the page's `requires` */ requires: string[]; - /** binding sites (object/field) the server must resolve against object schema */ - bindings: Array<{ tag: string; input: string; kind: 'object' | 'field'; value: unknown }>; + /** + * Binding sites the server must resolve against object schema. + * + * `kind` is a PRODUCER face, not a reader face: `validateTree` writes it, + * copying {@link ManifestInput.binding} at the one site that builds this + * array. So the subset relation that licenses a permissive READER runs the + * other way here — a wider union accepts nothing extra, it obliges every + * consumer to handle an arm this package cannot emit. That is why the + * retired `'field'` arm is gone from this end as well; the measurements are + * on {@link ManifestInput.binding}. + */ + bindings: Array<{ tag: string; input: string; kind: 'object'; value: unknown }>; }