diff --git a/.changeset/17645-sdui-parser-lockstep-port.md b/.changeset/17645-sdui-parser-lockstep-port.md new file mode 100644 index 00000000000..7c171fddfc4 --- /dev/null +++ b/.changeset/17645-sdui-parser-lockstep-port.md @@ -0,0 +1,14 @@ +--- +"@objectstack/sdui-parser": minor +--- + +The save gate now stamps `inert-quick-add` and `member-type-mismatch`, the two diagnostics that existed only in objectui's copy of this parser — so a page no longer saves clean here and renders with a different verdict there (#17645). + +The two copies of this parser owe each other one thing: byte agreement on the accepted grammar and on diagnostic codes. This copy runs the **save gate** and objectui's runs the **renderer**, so a code on one side only is a dialect — the author gets one reading when they save and another when the page draws, which is surface-dependent and therefore reaches them as intermittent. Measured at the ported revision: objectui stamped 26 codes, this copy stamped 24, and the missing two were exactly these. + +- **`inert-quick-add`** (warning) — `quickAdd` on `` reaches no control. The Quick Add button is gated on **both** `quickAdd` and an `onQuickAdd` handler, and `onQuickAdd` takes a function, which no page on this tier can write (this tier parses, it never executes) and which the board substitutes none of its own for. It **replaces** the `unknown-prop` this copy used to emit for the key, which was false against the contract: `ComponentPropsMap['object-kanban']` publishes `quickAdd`, so an author who checked the spec found the warning contradicted and kept a key that will never do anything. Asked ahead of the declaration lookup on purpose — the claim is about the render path, so declaring the key must not silently disarm it. A falsy value and an unevaluated braced expression are deliberately untouched. +- **`member-type-mismatch`** (warning; `error` when an `enum` arm is present) — the coarse type check one level down, over the member kind an input declares. This brings the `ManifestInput.of` key and its three readers with it: the validator, the serializer's canonicalization, and the codegen's element type. `of: 'string'` on an array input now types the members `string[]` in the generated `.d.ts` instead of `unknown[]`, and a member no declared arm accepts draws **one** diagnostic naming every offending position rather than one per member. + +**Nothing published changes shape for an input that declares no `of`.** The key is absent-means-undeclared: the validator checks no member, the codegen emits the unnarrowed element type, and `manifestFromConfigs` publishes no `of` at all, so an entry written before the key existed serializes byte-identically. Measured on the tracked `sdui.manifest.json`: 0 of 339 inputs declare `of`, and the artefact regenerates to the same sha256 across this change. + +⚠️ **Both new codes are diagnostics, not a new red gate.** Each is a warning, so `compile().ok` — the save gate's pass/fail — is unchanged, and a page that saves today still saves. Escalating an inert authored key to `error` is a separate question and belongs at the save gate, not here. diff --git a/packages/sdui-parser/objectui-lockstep.json b/packages/sdui-parser/objectui-lockstep.json index bc425df35b9..d8b7f83ccab 100644 --- a/packages/sdui-parser/objectui-lockstep.json +++ b/packages/sdui-parser/objectui-lockstep.json @@ -6,21 +6,22 @@ ], "objectui": { "repo": "https://github.com/objectstack-ai/objectui.git", - "rev": "53ded82bf7a494f54e344e19099dbf00854b8694", - "revDate": "2026-09-05T15:42:42+00:00", + "rev": "cfcc17d9dd04ebb80c092cacb4f4034425f51b29", + "revDate": "2026-09-14T11:40:39+00:00", "source": "packages/sdui-parser/src", "files": [ "packages/sdui-parser/src/codegen.ts", "packages/sdui-parser/src/dashboard-widget-options.ts", "packages/sdui-parser/src/index.ts", "packages/sdui-parser/src/input-type.ts", + "packages/sdui-parser/src/kanban-quick-add.ts", "packages/sdui-parser/src/parse.ts", "packages/sdui-parser/src/provenance.ts", "packages/sdui-parser/src/types.ts", "packages/sdui-parser/src/validate.ts" ] }, - "recordedAgainstPin": "53ded82bf7a494f54e344e19099dbf00854b8694", + "recordedAgainstPin": "cfcc17d9dd04ebb80c092cacb4f4034425f51b29", "grammarRegion": { "file": "packages/sdui-parser/src/parse.ts", "delimiter": "/* ---------------------- the JS literal subset (#6614) ---------------------- */", @@ -37,7 +38,9 @@ "forbidden-attr", "forbidden-tag", "inert-expression", + "inert-quick-add", "invalid-enum", + "member-type-mismatch", "mismatched-tag", "missing-required-prop", "multiple-roots", diff --git a/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts b/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts new file mode 100644 index 00000000000..723cad21333 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts @@ -0,0 +1,234 @@ +/** + * `quickAdd` on the `ObjectKanbanRenderer` tag is DIAGNOSED, and diagnosed + * truthfully — the objectui#8285 mechanism (ruling of 2026-09-08, batch #91), + * ported into this copy in lockstep (objectstack#17645). + * + * WHY THESE PINS EXIST HERE. Two copies of this parser exist — objectui's + * `packages/sdui-parser` (which the browser RENDERER runs) and this hoisted one + * (which the SAVE GATE runs). These pins are the objectstack half of the + * lockstep for this diagnostic; the ported module is byte-equal to objectui's. + * Before this port, this copy answered an authored `quickAdd` with the generic + * `unknown-prop` while objectui answered `inert-quick-add` — the same authored + * page getting two different readings from the two tiers, which is the dialect + * split the lockstep invariant forbids. + * + * ## What is red here before the change, and what is not + * + * ⚠️ Only the rows that assert `inert-quick-add` are red before this change. + * `quickAdd` is undeclared on the kanban registrations (objectui#8201 escalated + * it rather than declaring it), so this tier ALREADY reported it — as + * `unknown-prop`, a message that is false against the contract + * `@objectstack/spec` still publishes. The port replaces a wrong reading; it + * does not add a first one. + * + * The remaining rows are green in BOTH worlds by construction, and each is kept + * because it names a WRONG FIX that would otherwise pass: + * + * - the unknown-prop control — a fix that suppressed the generic walk for + * this block rather than for this one key; + * - the two RETIRED kanban-ish tags (`kanban`, `kanban-ui`) — a fix scoped + * to "a kanban-ish tag" rather than to the one registration that serves + * `ObjectKanbanRenderer`. Both left objectui's registry (objectui#8802 / + * objectui#8257), so neither is a host any more; they stay declared in the + * hand-built manifest below precisely so this row can still discriminate; + * - `quickAdd: false` — a fix keyed on the KEY's presence rather than on the + * author asking for the control, which would warn about a value that got + * exactly what it asked for; + * - the braced marker — a fix that reads the parser's opaque `$expr` object + * as "truthy, therefore the author asked for it"; + * - `ok` staying true — an escalation to `error`, which the objectui#5709 + * ruling ("no new red gates") and objectui#6614 Q2 both put elsewhere. + */ +import { describe, expect, it } from 'vitest'; +import { + INERT_QUICK_ADD, + QUICK_ADD_HOST_TYPES, + QUICK_ADD_KEY, + compile, + manifestFromConfigs, + validateTree, +} from '../index.js'; +import type { Diagnostic, Manifest } from '../types.js'; + +/** + * The three kanban blocks, carrying the inputs their registrations declared — + * `quickAdd` on NONE of them, which is the state this port leaves untouched. + * `card` is a non-kanban control block. + * + * ⚠️ `kanban` and `kanban-ui` are RETIRED registrations (objectui#8802, + * objectui#8257) and are kept here ON PURPOSE, as the discrimination controls + * below: a manifest is an argument to `validateTree`, so this file can still + * ask what the rule says about a tag the live registry no longer produces — + * and the answer must be "not a host". ⛔ Do not read their presence as a + * claim that either tag resolves; against a manifest built from the live + * registry both answer `unknown-component`. + */ +const manifest: Manifest = manifestFromConfigs([ + { + type: 'object-kanban', + namespace: 'plugin-kanban', + inputs: [ + { name: 'objectName', type: 'string', required: true }, + { name: 'groupBy', type: 'string' }, + ], + }, + { + type: 'kanban', + namespace: 'view', + inputs: [ + { name: 'objectName', type: 'string', required: true }, + { name: 'groupBy', type: 'string' }, + ], + }, + { type: 'kanban-ui', namespace: 'plugin-kanban', inputs: [{ name: 'columns', type: 'array' }] }, + { type: 'card', namespace: 'ui', inputs: [] }, +]); + +const diagnose = (node: Record): Diagnostic[] => + validateTree(node as never, manifest).diagnostics; + +const codesFor = (node: Record, key: string): string[] => + diagnose(node).filter((d) => d.message.includes(`"${key}"`)).map((d) => d.code); + +const HOST_TAGS = [...QUICK_ADD_HOST_TYPES].sort(); + +describe('an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => { + it('the host set is the one surviving ObjectKanbanRenderer tag, and is not empty', () => { + // Anti-vacuity for every row below: an empty set would make the negative + // rows trivially true and the positive rows unreachable. + // + // ⚠️ This row is the ONLY thing in this package that a change to + // `QUICK_ADD_HOST_TYPES` reddens — every other row is `it.each(HOST_TAGS)` + // and re-derives itself from the constant, so a narrowing would otherwise + // just delete cases silently. `kanban` left the set with its registration + // (objectui#8802): `checkKanbanQuickAdd` is reached only from + // `validate.ts`'s prop walk, which runs only for a tag the manifest + // RESOLVED, so a tag no registration produces is answered by + // `unknown-component` one level up and never reaches this module. + expect(HOST_TAGS).toEqual(['object-kanban']); + }); + + it('the stamped literal equals the exported constant', () => { + // objectui stamps `code: INERT_QUICK_ADD`; this copy stamps the inline + // literal, because `check:dispatcher-error-vocabulary` cannot reduce a + // kebab-case constant at a `code:` position (the divergence is documented + // at the stamp, and is the same one `unconsumed-widget-option` carries). + // This row is what keeps the two spellings from drifting apart — and the + // lockstep gate resolves the identifier through this constant, so both + // copies still agree on the same code. + expect(INERT_QUICK_ADD).toBe('inert-quick-add'); + const [diagnostic] = diagnose({ type: 'object-kanban', objectName: 'task', quickAdd: true }); + expect(diagnostic.code).toBe(INERT_QUICK_ADD); + }); + + it.each(HOST_TAGS)('<%s> — an authored `quickAdd: true` draws exactly one warning', (tag) => { + const diagnostics = diagnose({ type: tag, objectName: 'task', quickAdd: true }); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe(INERT_QUICK_ADD); + expect(diagnostics[0].severity).toBe('warning'); + expect(diagnostics[0].tag).toBe(tag); + }); + + it.each(HOST_TAGS)('<%s> — it REPLACES the false `unknown-prop`, it does not join it', (tag) => { + // The reading this port exists to correct: before it, the only thing this + // tier said about this key was that the block "has no prop quickAdd" — + // false against `ComponentPropsMap['object-kanban']`, which publishes it. + expect(codesFor({ type: tag, objectName: 'task', quickAdd: true }, QUICK_ADD_KEY)).toEqual([ + INERT_QUICK_ADD, + ]); + }); + + it.each(HOST_TAGS)('<%s> — the message names the missing half and where the pair works', (tag) => { + // Not a prose pin: these two tokens are what makes the diagnostic + // ACTIONABLE, and a message that dropped either would send its reader back + // to the contract with no explanation, which is the state being fixed. + const [{ message }] = diagnose({ type: tag, objectName: 'task', quickAdd: true }); + expect(message).toContain('onQuickAdd'); + // ⚠️ The remedy names the COMPONENT, not the `kanban-ui` TAG it used to + // name: objectui#8257 retired that registration, so a page written to the + // old advice would now draw `unknown-component` — an ERROR. `KanbanRenderer` + // is still exported from `@object-ui/plugin-kanban` and still forwards both + // halves by identity, so it is the surviving way to get the pair. + expect(message).toContain('KanbanRenderer'); + }); + + it.each(HOST_TAGS)('<%s> — control: a genuinely unknown prop is still reported', (tag) => { + // Guards a fix that turned the generic walk off for this block instead of + // answering for this one key. Green in both worlds by construction. + expect(codesFor({ type: tag, objectName: 'task', bogusProp: 'x' }, 'bogusProp')).toEqual([ + 'unknown-prop', + ]); + }); + + it.each(HOST_TAGS)( + '<%s> — control: a falsy `quickAdd` asks for no control, so it draws none', + (tag) => { + // `KanbanImpl` gates on `quickAdd && onQuickAdd`: `false` got exactly what + // it wrote and nothing was dropped. Guards a fix keyed on the key's mere + // presence. Green in both worlds — the `unknown-prop` half is the status + // quo this port deliberately leaves alone. + expect(codesFor({ type: tag, objectName: 'task', quickAdd: false }, QUICK_ADD_KEY)).toEqual([ + 'unknown-prop', + ]); + }, + ); + + it.each(HOST_TAGS)( + '<%s> — control: a braced value this tier never evaluates is not read as a request', + (tag) => { + // The parser's deferred marker is an opaque object — truthy in JS, and no + // reading at all about what the author asked for. Guards a fix that tested + // truthiness alone. Green in both worlds. + expect( + codesFor( + { type: tag, objectName: 'task', quickAdd: { $expr: 'rows.length > 0' } }, + QUICK_ADD_KEY, + ), + ).toEqual(['unknown-prop']); + }, + ); + + it.each(['kanban-ui', 'kanban'])( + 'control: `%s` is NOT a host — a kanban-ish tag alone does not arm this diagnostic', + (tag) => { + // Guards a fix scoped to "a kanban-ish tag". Both spellings are RETIRED + // registrations (objectui#8257, objectui#8802), so neither can reach this + // module through a manifest built from the live registry at all — they are + // declared in this file's hand-built manifest so the discrimination is + // still measurable here, which is the one thing a synthetic manifest can + // do that the live one cannot. `kanban-ui`'s pair itself survives on the + // exported `KanbanRenderer` component, which no tag resolves to. + const node = { type: tag, columns: [], quickAdd: true }; + expect(diagnose(node).map((d) => d.code)).not.toContain(INERT_QUICK_ADD); + expect(codesFor(node, QUICK_ADD_KEY)).toEqual(['unknown-prop']); + }, + ); + + it('control: a non-kanban block is untouched', () => { + expect(diagnose({ type: 'card', quickAdd: true }).map((d) => d.code)).toEqual(['unknown-prop']); + }); + + it('through the whole pipeline: the diagnostic survives parse + validate on real source', () => { + // A SUBJECT row, not a control — it is red without the port, like the rows + // above. It is here because every row above hands `validateTree` a + // hand-built node: this one starts from source text, so it also proves the + // parser really materializes the braced `true` as a boolean rather than as + // the `$expr` marker the falsy/braced controls are about. + const { diagnostics } = compile('', manifest); + expect(diagnostics.map((d) => d.code)).toEqual([INERT_QUICK_ADD]); + }); + + it('control: the page still COMPILES — a warning, not a new red gate', () => { + // `ok` is `!diagnostics.some(d => d.severity === 'error')`, i.e. the save + // gate's pass/fail, and it is true in BOTH worlds: `unknown-prop` was a + // warning too, so nothing hardens here. Kept because it names the wrong + // fix — escalating an inert authored key to `error`, which objectui#5709 + // ("no new red gates") and objectui#6614 Q2 both put elsewhere, and which + // would stop a page that saves today from saving. + expect(compile('', manifest).ok).toBe(true); + // Non-vacuity for that `true`: the same pipeline DOES turn `ok` false when + // an error-severity diagnostic is present, so this is a reading about this + // diagnostic's severity and not about `ok` being unreachable. + expect(compile('', manifest).ok).toBe(false); + }); +}); diff --git a/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts b/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts new file mode 100644 index 00000000000..4b87cadaa06 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts @@ -0,0 +1,208 @@ +/** + * `ManifestInput.of`, the coarse MEMBER kind — the objectui#8067 mechanism, + * ported into this copy in lockstep (objectstack#17645). + * + * WHY THESE PINS EXIST HERE. Two copies of this parser exist — objectui's + * `packages/sdui-parser` (which the browser RENDERER runs) and this hoisted one + * (which the SAVE GATE runs). These pins are the objectstack half of the + * lockstep for the member check; the ported functions are byte-equal to + * objectui's. Before this port `of` did not exist in this copy at all: a member + * that drifted from the contract was invisible here while objectui reported it, + * so the same authored page produced a diagnostic on one surface and silence on + * the other — the dialect split the lockstep invariant forbids (both copies + * agree on the accepted grammar AND on diagnostic codes). + * + * WHY THE KEY EXISTS. `type: 'array'` said a value was a list and stopped + * there, so a member that drifted from the contract was invisible to every + * layer that reads a declaration. `page:header.actions` is the measured cost: + * spec `z.array(z.string())` ("Action IDs"), a renderer reading the members as + * `ActionDef` OBJECTS, and the repo-wide parity gate green for the whole life of + * the drift because both sides had the key and neither could say what was + * inside it. + * + * WHAT IS PINNED HERE, and they are different facts: + * + * 1. THE READER EXISTS — a member no declared arm accepts is REPORTED. A key + * that nothing reads is a declared-but-inert key with a new name, which is + * the defect this repo's enforce-or-remove rule exists to refuse. + * 2. BACKWARD COMPATIBILITY — an input that declares no `of` is validated, + * serialized and typed byte-identically to before the key existed. That is + * what makes this an extension of `sdui.manifest.json` rather than a new + * version of it. + * 3. THE COARSE CEILING HOLDS ONE LEVEL DOWN — `of` names a KIND, never a + * domain and never a member's KEYS, so `of: 'object'` clears every object + * whatever it contains. The maintainer ruling of 2026-08-17 ("SPEC IS THE + * SOLE JUDGE OF VALUES") is untouched. + * 4. THE OTHER CONSUMERS — the serializer canonicalizes `of` exactly as it + * canonicalizes `type`, and the JSX codegen narrows the emitted element + * type from it. A member kind the validator honours but the `.d.ts` + * contradicts would just move an author's false error one layer over. + */ +import { describe, expect, it } from 'vitest'; +import { generateDts, manifestFromConfigs, validateTree } from '../index.js'; +import type { Manifest, SchemaElement } from '../types.js'; + +const one = (inputs: Parameters[0][number]['inputs']): Manifest => + manifestFromConfigs([{ type: 'probe', namespace: 'ui', inputs }]); + +const diags = (manifest: Manifest, node: Record) => + validateTree({ type: 'probe', ...node } as SchemaElement, manifest).diagnostics; + +const codes = (manifest: Manifest, node: Record): string[] => + diags(manifest, node).map((d) => d.code); + +describe('a declared member kind is READ', () => { + const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]); + + it('clears an array whose members are all of the declared kind', () => { + expect(codes(manifest, { actions: ['clone', 'convert'] })).toEqual([]); + }); + + it('reports a member of the wrong kind — the drift this key exists to catch', () => { + // Verbatim the `page:header.actions` drift: spec says action IDs, the value + // carries `ActionDef` objects. Before `of` this node was clean. + expect(codes(manifest, { actions: [{ name: 'clone' }] })).toEqual(['member-type-mismatch']); + }); + + it('names every offending position in ONE diagnostic, not one per member', () => { + const [diagnostic] = diags(manifest, { actions: ['clone', 42, {}, 'convert'] }); + expect(diagnostic.code).toBe('member-type-mismatch'); + expect(diagnostic.severity).toBe('warning'); + expect(diagnostic.message).toBe( + ' prop "actions" expected every member to be a string — [1], [2] are not', + ); + }); + + it('an empty container conforms — there is no member to refuse', () => { + expect(codes(manifest, { actions: [] })).toEqual([]); + }); + + it('reads the ARRAY form of `of` the same way it reads the array form of `type`', () => { + // The rot this pins: a reader that forgot `Array.isArray` would fall through + // and report NOTHING, which looks exactly like a clean value. + const union = one([{ name: 'items', type: 'array', of: ['string', 'object'] }]); + expect(codes(union, { items: ['a', { b: 1 }] })).toEqual([]); + expect(codes(union, { items: [42] })).toEqual(['member-type-mismatch']); + }); + + it('judges an OBJECT container by its VALUES — the map half of the key', () => { + const map = one([{ name: 'labels', type: 'object', of: 'string' }]); + expect(codes(map, { labels: { en: 'Account', 'zh-CN': '客户' } })).toEqual([]); + const [diagnostic] = diags(map, { labels: { en: 'Account', count: 3 } }); + expect(diagnostic.message).toBe( + ' prop "labels" expected every member to be a string — [count] is not', + ); + }); +}); + +describe('the container verdict comes first', () => { + const manifest = one([{ name: 'actions', type: 'array', of: 'string' }]); + + it('a wrong CONTAINER draws one diagnostic, not two', () => { + // One mistake, one report. A member walk over a value that is not even the + // declared container would name positions of a shape the author never wrote. + expect(codes(manifest, { actions: 'clone' })).toEqual(['type-mismatch']); + }); + + it('a value that satisfied a NON-container arm of a union is not member-judged', () => { + const union = one([{ name: 'actions', type: ['string', 'array'], of: 'string' }]); + expect(codes(union, { actions: 'clone' })).toEqual([]); + expect(codes(union, { actions: [42] })).toEqual(['member-type-mismatch']); + }); +}); + +describe('the coarse ceiling holds one level down', () => { + it("`of: 'object'` clears every object, whatever keys it carries", () => { + // `of` is a KIND, never a member's KEYS. Which keys an element must have is + // spec's question and a per-block pin's, exactly as the value DOMAIN of a + // `number` arm is. + const manifest = one([{ name: 'sections', type: 'array', of: 'object' }]); + expect(codes(manifest, { sections: [{ anything: 'at all' }, {}] })).toEqual([]); + expect(codes(manifest, { sections: ['sales_info'] })).toEqual(['member-type-mismatch']); + }); + + it('an `enum` member arm raises its severity to error, as it does one level up', () => { + const manifest = one([ + { name: 'types', type: 'array', of: 'enum', enum: ['comment', 'task'] }, + ]); + expect(codes(manifest, { types: ['comment', 'task'] })).toEqual([]); + const [diagnostic] = diags(manifest, { types: ['comment', 'email'] }); + expect(diagnostic.code).toBe('member-type-mismatch'); + expect(diagnostic.severity).toBe('error'); + }); +}); + +describe('an input that declares no member kind is unchanged', () => { + // The backward-compatibility half. Every input published before this key + // existed says exactly this, so anything that moves here moves for all of + // them — including every entry of the tracked `sdui.manifest.json`. + const manifest = one([{ name: 'actions', type: 'array' }]); + + it('draws no member diagnostic on any member', () => { + expect(codes(manifest, { actions: [42, {}, 'clone', null] })).toEqual([]); + }); + + it('publishes no `of` at all — the serialized entry is byte-identical', () => { + expect(JSON.stringify(manifest.components.probe.inputs[0])).toBe( + '{"name":"actions","type":"array"}', + ); + }); + + it('emits the unnarrowed element type', () => { + expect(generateDts(manifest)).toContain('actions?: unknown[];'); + }); +}); + +describe('the serializer canonicalizes `of` exactly as it canonicalizes `type`', () => { + it('collapses a one-element array to the bare kind', () => { + expect(one([{ name: 'a', type: 'array', of: ['string'] }]).components.probe.inputs[0].of).toBe( + 'string', + ); + }); + + it('drops an off-vocabulary arm rather than inventing `string` for it', () => { + expect( + one([{ name: 'a', type: 'array', of: ['string', 'nonsense'] }]).components.probe.inputs[0].of, + ).toBe('string'); + }); + + it('dedupes a repeated arm', () => { + expect( + one([{ name: 'a', type: 'array', of: ['string', 'object', 'string'] }]).components.probe + .inputs[0].of, + ).toEqual(['string', 'object']); + }); + + it('does NOT invent a member kind for an undeclared `of`', () => { + // `canonicalizeInputType`'s no-arms fallback is `'string'`, so routing an + // undefined through it would make every array in every manifest claim + // string members it was never told it had. + expect(one([{ name: 'a', type: 'array' }]).components.probe.inputs[0].of).toBeUndefined(); + }); +}); + +describe('the JSX authoring surface narrows with it', () => { + it('types the elements rather than emitting `unknown[]`', () => { + const dts = generateDts(one([{ name: 'actions', type: 'array', of: 'string' }])); + expect(dts).toContain('actions?: string[];'); + expect(dts).not.toContain('actions?: unknown[];'); + }); + + it("types an object map's values", () => { + expect(generateDts(one([{ name: 'labels', type: 'object', of: 'string' }]))).toContain( + 'labels?: Record;', + ); + }); + + it('emits a parenthesised union for a multi-arm member declaration', () => { + expect(generateDts(one([{ name: 'items', type: 'array', of: ['string', 'object'] }]))).toContain( + 'items?: (string | Record)[];', + ); + }); + + it('a `slot`-only member declaration types no member — never a silent `string`', () => { + expect(generateDts(one([{ name: 'items', type: 'array', of: 'slot' }]))).toContain( + 'items?: unknown[];', + ); + }); +}); diff --git a/packages/sdui-parser/src/codegen.ts b/packages/sdui-parser/src/codegen.ts index 1f3c9acb2e8..76a7d654f1f 100644 --- a/packages/sdui-parser/src/codegen.ts +++ b/packages/sdui-parser/src/codegen.ts @@ -101,9 +101,9 @@ function armTsType(arm: ManifestInputType, input: ManifestInput): string { case 'boolean': return 'boolean'; case 'array': - return 'unknown[]'; + return `${memberTsType(input)}[]`; case 'object': - return 'Record'; + return `Record`; case 'enum': { const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e)); return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string'; @@ -113,6 +113,29 @@ function armTsType(arm: ManifestInputType, input: ManifestInput): string { } } +/** + * The TypeScript type of a container's MEMBERS, from `ManifestInput.of` + * (objectui#8067). + * + * `unknown` when nothing is declared, which is what every input published + * before `of` existed says — so an undeclared array still emits `unknown[]` and + * an undeclared object still emits `Record`, byte for byte. + * A declared member kind narrows it: `of: 'string'` on `page:header.actions` + * turns `unknown[]` into `string[]`, and the JSX surface finally types the + * action IDs the contract has always required. + * + * `'slot'` is dropped for the same reason {@link valueArms} drops it one level + * up: it names a child position, not a value, so it contributes no member type. + * If that leaves nothing, the result is `unknown` — a declaration that types no + * member must not silently type them all as strings. + */ +function memberTsType(input: ManifestInput): string { + const arms = inputTypeArms(input.of).filter((arm) => arm !== 'slot'); + if (arms.length === 0) return 'unknown'; + const emitted = [...new Set(arms.map((arm) => armTsType(arm, { ...input, of: undefined })))]; + return emitted.length === 1 ? emitted[0] : `(${emitted.join(' | ')})`; +} + /** * A union declaration emits a TypeScript union, so the `.d.ts` an author * type-checks their page against accepts exactly the arms the manifest gate diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 97b94de7642..2ea35795b9c 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -15,6 +15,12 @@ export { DASHBOARD_WIDGET_HOST_TYPES, UNCONSUMED_WIDGET_OPTION, } from './dashboard-widget-options.js'; +export { + checkKanbanQuickAdd, + INERT_QUICK_ADD, + QUICK_ADD_HOST_TYPES, + QUICK_ADD_KEY, +} from './kanban-quick-add.js'; export { generateDts, propsName, generateBlockList } from './codegen.js'; export type { CodegenOptions } from './codegen.js'; export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js'; @@ -76,6 +82,14 @@ export interface RegistryConfigLike { * `canonicalizeInputType` on the way in. */ type: string | string[]; + /** + * The declared member kind(s) — array elements, or an object map's values + * (objectui#8067). Typed as loosely as `type` above and for the same + * reason: this interface is the STRUCTURAL boundary that keeps the package + * free of a registry dependency, so an off-vocabulary value has to be + * representable here and is normalized on the way in. + */ + of?: string | string[]; required?: boolean; enum?: Array; /** @@ -120,6 +134,14 @@ export function manifestFromConfigs( inputs: (c.inputs ?? []).map((i) => ({ name: i.name, type: canonicalizeInputType(i.type), + // Undefined stays undefined rather than going through + // `canonicalizeInputType`, whose no-arms fallback is `'string'`: an + // input that declares no member kind must publish NO `of`, or every + // array in every manifest would suddenly claim string members it was + // never told it had. `JSON.stringify` drops the undefined key, so the + // published artifact is byte-identical for every input that does not + // declare one (objectui#8067). + of: i.of === undefined ? undefined : canonicalizeInputType(i.of), required: i.required, enum: i.enum, binding: i.binding, diff --git a/packages/sdui-parser/src/kanban-quick-add.ts b/packages/sdui-parser/src/kanban-quick-add.ts new file mode 100644 index 00000000000..20f9f74b9f0 --- /dev/null +++ b/packages/sdui-parser/src/kanban-quick-add.ts @@ -0,0 +1,177 @@ +/** + * The inert `quickAdd` on the `ObjectKanbanRenderer` tag (objectui#8285, + * director-seat ruling of 2026-09-08, decision batch #91) + * + * ## WHY THIS MODULE EXISTS HERE + * + * Two copies of this parser exist: objectui's `packages/sdui-parser` (which the + * browser RENDERER runs) and this one (which the SAVE GATE runs). Everything + * from the `import` line below to end of file is the port of objectui's copy. + * The two copies must agree on the accepted grammar AND on diagnostic codes — + * if they drift, the save gate and the renderer speak different dialects and a + * page can save clean and render inert. Change this only together with the + * objectui copy, and re-record `pnpm gen:sdui-lockstep` when you do. + * + * ## What is wrong, measured rather than argued + * + * `@objectstack/spec`'s `ComponentPropsMap['object-kanban']` publishes + * `quickAdd: z.boolean().optional()`, and the board does not honour it. + * `KanbanImpl` gates the Quick Add control on BOTH halves of a pair — + * `quickAdd && onQuickAdd`, at the in-column button and again at + * `QuickAddForm` — and `onQuickAdd` is a RUNTIME SLOT: a host-supplied + * FUNCTION, refused by name in `complex.zod.ts` because JSON has no function + * value. `ObjectKanban` substitutes its own `onCardMove` and `onCardClick` and + * stops there; it supplies no `onQuickAdd`. + * + * On THIS tier the second half is not merely absent, it is unwritable: a + * handler attribute on a parsed page draws `forbidden-attr`, because this tier + * parses and never executes. So `quickAdd` is inert for every author who can + * reach it here — there is no out-of-band consumer to protect, which is the + * fact that makes the diagnostic below unconditional rather than escape-hatched + * the way `unconsumed-widget-option` is. + * + * ## What this replaces, and why the previous reading was WRONG rather than absent + * + * `quickAdd` is deliberately absent from the kanban registrations' declared + * inputs (objectui#8201 escalated it rather than declaring it), so + * `validateTree` already reported it — as `unknown-prop`, "has no prop + * quickAdd". + * + * That message is a MISDIAGNOSIS. objectui#8201's pin header records the same + * shape from the other direction: a tier telling an author that spellings which + * WORK are unknown "trains authors (AI authors included) to delete working + * metadata". Here the falsehood runs the other way and is worse for it — the + * spec DOES publish this prop, so an author who checks the contract finds the + * warning contradicted, concludes the manifest is behind the spec, and keeps a + * key that will never do anything. It also cannot be told apart from a typo, so + * nothing anywhere records WHY the key is refused. + * + * This module says the true thing instead: the pair, the missing half, and + * where the pair does work. + * + * ## Severity — WARNING, and the escalation is deliberately not taken + * + * The ruling's word is "diagnosed", not "refused", and every diagnostic in this + * family is a warning: `unknown-prop` (what this replaces, so nothing hardens), + * `inert-expression`, and `unconsumed-widget-option`, whose objectui#5709 + * ruling states the constraint directly — "no gate weakening and no new red + * gates were ruled". `validate.ts`'s `inert-expression` note records that + * escalating an inert authored key to `error` is a SEPARATE question + * (objectui#6614 Q2) belonging at the save gate, and it is not answered here: + * an error would stop a page that saves today from saving, which is a cost, and + * costs are the maintainer's to impose. + * + * The contract-side refusal is where the hardening belongs, and it is already + * ruled: option B retires `object-kanban.quickAdd` from the spec's + * `ComponentPropsMap` (ADR-0049 enforce-or-remove), after which a strict parse + * refuses the key BY NAME and this module is deleted along with the interim it + * names. ⚠️ THIS FILE IS THAT INTERIM. + * + * ## Scope — the three places this deliberately does not fire + * + * - `KanbanRenderer`, the component. The ruling keeps the `quickAdd` / + * `onQuickAdd` pair there untouched: it forwards both halves by identity, + * and a React host mounting it CAN pass the function. ⚠️ It is no longer + * reachable as a TAG — objectui#8257 retired the `kanban-ui` registration, + * so `` is now an `unknown-component` ERROR, not a block with a + * working pair. The component stays exported from + * `@object-ui/plugin-kanban`, which is the surviving way to get the pair, + * and is what this module's message names. + * - `quickAdd: false`, and any other falsy value. The renderer's own gate is + * `quickAdd && onQuickAdd`, so a falsy value asks for no control and gets + * none — the author got what they wrote, and nothing was dropped. The + * ruling names `quickAdd: true`. + * - A braced value this tier never evaluates (the parser's `$expr` marker). + * It is opaque here, so "the author asked for the control" is not a reading + * this module can make; the value's own inertness is `inert-expression`'s + * subject, not this one's. + */ +import type { Diagnostic } from './types.js'; + +/** The diagnostic `code` this module emits. */ +export const INERT_QUICK_ADD = 'inert-quick-add'; + +/** The authored key it is about. */ +export const QUICK_ADD_KEY = 'quickAdd'; + +/** + * The tags served by `ObjectKanbanRenderer` — the registrations in objectui's + * `packages/plugin-kanban/src/index.tsx`, which is where another one would + * appear. Restated here as data because this package is deliberately free of + * any dependency on the registry or on a plugin (see `RegistryConfigLike` in + * `index.ts`). + * + * ⚠️ **`kanban` left this set with its registration** (objectui#8802, ruled + * 2026-09-09; `kanban-ui` and `kanban-enhanced` went the same way under + * objectui#8257). Keeping it would have been dead data, MEASURED and not + * assumed: `checkKanbanQuickAdd` has exactly one call site, inside + * `validate.ts`'s prop walk, and that walk runs only in the branch where + * `manifest.components[node.type]` RESOLVED. A tag no registration produces is + * answered one level up, by `unknown-component`, and its props are never walked + * at all — so on a manifest built from the live registry a `` + * node draws `error/unknown-component` and nothing else, against a firing + * control on `` that draws `warning/inert-quick-add`. + * ⛔ Nothing is silently dropped by the narrowing: the retired spelling is + * refused BY NAME at the tag, which is a louder answer than this warning, and + * stacking both would be the two-diagnostics-for-one-mistake shape + * `checkMemberTypes` already refuses (objectui#8067). The one path that could + * still reach a `kanban` entry is a HAND-BUILT manifest declaring a component + * of that name — which, after the retirement, is somebody else's component, and + * the message below asserts things about `ObjectKanban` that would be false of + * it. + */ +export const QUICK_ADD_HOST_TYPES: ReadonlySet = new Set(['object-kanban']); + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** The parser's deferred-expression marker — opaque here, never evaluated. */ +const isExpr = (v: unknown): boolean => isPlainObject(v) && '$expr' in v; + +/** + * The diagnostic for ONE authored prop, or `null` when this module has nothing + * to say about it. + * + * Per-prop rather than per-node — unlike `checkDashboardWidgetOptions`, which + * runs after the prop walk — because this diagnostic REPLACES the one the walk + * would otherwise emit for the same key. Two diagnostics for one mistake is the + * shape `validate.ts` already refuses at `checkMemberTypes` (objectui#8067). + * + * Deliberately independent of whether the registration DECLARES the key: the + * claim is about the render path, which a declaration cannot change. Reading it + * out of the `!input` branch would make declaring `quickAdd` silently disarm + * this — the one edit that must instead leave the warning standing. + */ +export function checkKanbanQuickAdd(tag: string, key: string, value: unknown): Diagnostic | null { + if (key !== QUICK_ADD_KEY) return null; + if (!QUICK_ADD_HOST_TYPES.has(tag)) return null; + if (isExpr(value)) return null; + if (!value) return null; + return { + severity: 'warning', + // DIVERGENCE FROM OBJECTUI, and the only one below this file's header: + // objectui writes `code: INERT_QUICK_ADD` here. This repo runs + // `check:dispatcher-error-vocabulary`, whose `objlitconst` shape reads the + // SCREAMING_SNAKE constant NAME at a `code:` position and then must reduce + // it to a literal — and its literal grammar is `[A-Za-z][A-Za-z0-9_]*`, + // which a KEBAB-case value cannot satisfy. So the constant form is reported + // as an unresolvable code constant, and that finding cannot be declared + // away. `inert-quick-add` is a parser DIAGNOSTIC code, not an ADR-0112 wire + // code, and an inline quoted literal is the form both vocabulary gates + // already accept for the sibling diagnostic codes in `validate.ts` and for + // `unconsumed-widget-option` next door. The emitted VALUE is unchanged, and + // the test next door pins it equal to `INERT_QUICK_ADD` so the two + // spellings cannot drift apart. ⛔ This does NOT weaken the lockstep: that + // gate resolves an identifier at a code position through the package's own + // module-level constants, so both spellings reduce to the same code and the + // two copies still agree on all 26. + code: 'inert-quick-add', + message: + `<${tag}> prop "${QUICK_ADD_KEY}" reaches no control — the Quick Add button is gated on ` + + `BOTH "${QUICK_ADD_KEY}" and an "onQuickAdd" handler, and this block supplies neither half ` + + `of the pair: "onQuickAdd" takes a FUNCTION, which no page on this tier can write (this tier ` + + `parses, never executes) and which this board substitutes none of its own for. Drop the key, ` + + `or mount KanbanRenderer from "@object-ui/plugin-kanban" in a React host that passes "onQuickAdd".`, + tag, + }; +} diff --git a/packages/sdui-parser/src/types.ts b/packages/sdui-parser/src/types.ts index 3a7fc45dac0..567bc1259be 100644 --- a/packages/sdui-parser/src/types.ts +++ b/packages/sdui-parser/src/types.ts @@ -77,6 +77,27 @@ export interface ManifestInput { * declared and every already-published entry serializes byte-identically. */ type: ManifestInputType | ManifestInputType[]; + /** + * The coarse kind of the input's MEMBERS — array elements, or the values of + * an object used as a map — as ONE kind or an ARRAY of kinds for a member + * contract that is a union (objectui#8067). + * + * Absent means "not declared", which is what every input published before + * this key existed says: {@link validateTree} checks no member and the + * codegen emits the unnarrowed element type, exactly as before. So a + * manifest gains this key only where a member kind was really declared, and + * every already-published entry serializes byte-identically. + * + * Read the arms through `inputTypeArms(input.of)` — the same accessor + * `type`'s arms go through, since the two fields carry the same shape and a + * reader that forgets the array form is silently inert on it. + * + * LOCKSTEP: this key and its three readers — `validateTree`'s member check, + * `manifestFromConfigs`' canonicalization and the codegen's element type — + * are the port of objectui's copy. The two copies must agree on the accepted + * grammar AND on diagnostic codes; change this only together with objectui. + */ + of?: ManifestInputType | ManifestInputType[]; required?: boolean; /** allowed values for `enum` inputs */ enum?: Array; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 150ccdb824e..156a264073f 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -18,6 +18,7 @@ import type { } from './types.js'; import { inputTypeArms } from './input-type.js'; import { checkDashboardWidgetOptions } from './dashboard-widget-options.js'; +import { checkKanbanQuickAdd } from './kanban-quick-add.js'; /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ const BASE_PROPS = new Set([ @@ -69,6 +70,26 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va // each provided prop for (const [key, value] of Object.entries(node)) { if (BASE_PROPS.has(key)) continue; + // The `object-kanban` Quick Add pair (objectui#8285): a key + // `@objectstack/spec` still publishes and the renderer cannot honour, + // because the control is gated on a RUNTIME SLOT no parsed page can + // write. It REPLACES whatever the rules below would say about the key — + // `unknown-prop` today, and a coarse type check if the key were ever + // declared — because two diagnostics for one mistake is what + // `checkMemberTypes` already refuses (objectui#8067), and because + // "has no prop quickAdd" is FALSE against the published contract. Asked + // AHEAD of the declaration lookup on purpose: the claim is about the + // render path, so declaring the key must not silently disarm it. + // Interim, by the ruling — the spec's refusal by name replaces it. + // + // LOCKSTEP: this call site and the module behind it are the port of + // objectui's copy. The two copies must agree on the accepted grammar + // AND on diagnostic codes — change this only together with objectui. + const quickAdd = checkKanbanQuickAdd(node.type, key, value); + if (quickAdd) { + diagnostics.push(quickAdd); + continue; + } const input = byName.get(key); if (!input) { diagnostics.push({ @@ -130,7 +151,16 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va }); } else { const typeDiag = checkType(node.type, input, value); - if (typeDiag) diagnostics.push(typeDiag); + if (typeDiag) { + diagnostics.push(typeDiag); + } else { + // Members only once the CONTAINER kind was accepted. Reporting a + // member of a value that is not even the declared container is two + // diagnostics for one mistake, and the second one names positions + // of a shape the author did not write (objectui#8067). + const memberDiag = checkMemberTypes(node.type, input, value); + if (memberDiag) diagnostics.push(memberDiag); + } } } @@ -168,7 +198,8 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va /* LOCKSTEP: everything below this line is the byte-equal port of objectui's * `packages/sdui-parser` coarse type check (objectui#3832 — union-typed inputs - * are checked over their arms). The two copies must agree on the accepted + * are checked over their arms — and objectui#8067, the same check one level + * down over the member kind `of` declares). The two copies must agree on the accepted * grammar AND on diagnostic codes/severities — if they drift, the save gate * and the renderer speak different dialects. Change these functions only * together with the objectui copy. */ @@ -226,6 +257,62 @@ function armExpectation(arm: ManifestInputType, input: ManifestInput): string { } } +/** + * The member positions of a container value, as `[position, member]` pairs, or + * `null` when the value has no member position to speak of. + * + * Arrays index by position and objects by key, which is exactly the pair + * `ManifestInput.of` describes: array ELEMENTS, and the VALUES of an object + * used as a map. A scalar returns `null` rather than an empty list, so a value + * that only satisfied a non-container arm of a union declaration + * (`type: ['string', 'array'], of: 'string'`) is not reported as an empty + * container that trivially conforms — it is simply not the arm `of` describes. + */ +function memberEntries(value: unknown): Array<[string, unknown]> | null { + if (Array.isArray(value)) return value.map((member, index) => [String(index), member]); + if (typeof value === 'object' && value !== null) return Object.entries(value); + return null; +} + +/** + * Coarse MEMBER check, over the arms `of` declares (objectui#8067). + * + * The same question `checkType` asks, one level down and with the same answer + * shape: ANY declared arm accepting a member clears it, a member no arm accepts + * is reported, and an input that declares no `of` is checked exactly as it was + * before the key existed — this function returns immediately on an empty arm + * list, so nothing published today changes severity or gains a diagnostic. + * + * ONE diagnostic per prop, naming every offending position, rather than one per + * member: a page that passes an array of the wrong member kind is one mistake + * made once, and N copies of it is the noise this repo treats as the thing that + * trains authors to dismiss real reports. + * + * Severity mirrors `checkType`'s rule for the same reason — `error` when an + * `enum` arm is present, because a closed list is the one fact this layer can + * be certain about; `warning` otherwise, since the coarse kind is a KIND claim + * and `os validate` / `os build` remain the judge of values. + */ +function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Diagnostic | null { + const arms = inputTypeArms(input.of); + if (arms.length === 0) return null; + const entries = memberEntries(value); + if (entries === null) return null; + const offenders = entries.filter( + ([, member]) => !arms.some((arm) => armAccepts(arm, input, member)), + ); + if (offenders.length === 0) return null; + const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or '); + return { + severity: arms.includes('enum') ? 'error' : 'warning', + code: 'member-type-mismatch', + message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` + + ` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` + + `${offenders.length === 1 ? 'is' : 'are'} not`, + tag, + }; +} + /** * Coarse type check, over the arms an input declares (objectui#3832). *