From 8ddd53ac7cee0e2cf612fd36bf4943efae2d2f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:19:55 +0000 Subject: [PATCH 1/4] feat(sdui-parser): port inert-quick-add and member-type-mismatch from objectui Both diagnostics existed only in objectui's copy of the parser, so the save gate and the renderer disagreed about what a page means -- the dialect split the lockstep invariant forbids. - inert-quick-add: `quickAdd` on reaches no control, because the Quick Add button is gated on a runtime slot no parsed page can write. Replaces the false `unknown-prop` ("has no prop quickAdd") the prop walk emitted, which contradicts the contract spec still publishes. - member-type-mismatch: the coarse type check one level down, over the member kind `ManifestInput.of` declares. Brings the `of` key and its three readers (validator, serializer canonicalization, codegen element type) with it. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- packages/sdui-parser/src/codegen.ts | 27 +++- packages/sdui-parser/src/index.ts | 22 +++ packages/sdui-parser/src/kanban-quick-add.ts | 161 +++++++++++++++++++ packages/sdui-parser/src/types.ts | 21 +++ packages/sdui-parser/src/validate.ts | 91 ++++++++++- 5 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 packages/sdui-parser/src/kanban-quick-add.ts 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..0d5131868ac --- /dev/null +++ b/packages/sdui-parser/src/kanban-quick-add.ts @@ -0,0 +1,161 @@ +/** + * 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', + 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). * From f84826fb5e97f7631f286a2f7ec36db88dbbf47d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:24:17 +0000 Subject: [PATCH 2/4] test(sdui-parser): pin both ported diagnostics; re-record lockstep at the new pin Bumps .objectui-sha to the objectui revision the port was taken from, re-records objectui's side of the lockstep there, and follows the bump's second half by regenerating the tracked SDUI manifest. The manifest regenerates BYTE-IDENTICAL (sha256 49211fee7792cf51 before and after): both pins ship @object-ui 17.6.0, so the published registry inputs did not move. Only the record's objectuiSha stamp follows the pin. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .objectui-sha | 2 +- packages/sdui-parser/objectui-lockstep.json | 9 +- .../src/__tests__/inert-quick-add.test.ts | 221 ++++++++++++++++++ .../__tests__/member-type-mismatch.test.ts | 208 +++++++++++++++++ scripts/sdui-manifest.record.json | 4 +- 5 files changed, 438 insertions(+), 6 deletions(-) create mode 100644 packages/sdui-parser/src/__tests__/inert-quick-add.test.ts create mode 100644 packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts diff --git a/.objectui-sha b/.objectui-sha index a9b031ceea7..2efe6750602 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -53ded82bf7a494f54e344e19099dbf00854b8694 +cfcc17d9dd04ebb80c092cacb4f4034425f51b29 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..226da6498f5 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts @@ -0,0 +1,221 @@ +/** + * `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.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/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 8389fcb570e..14306b59b78 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -9,10 +9,10 @@ "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs (see its header for the offline mode)." ], - "objectuiSha": "53ded82bf7a494f54e344e19099dbf00854b8694", + "objectuiSha": "cfcc17d9dd04ebb80c092cacb4f4034425f51b29", "objectuiPackagesVersion": "17.6.0", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-09-08", + "generatedAt": "2026-09-17", "sha256": "49211fee7792cf51174930dc2c1be2169d5175f77a83491f68f90b3f0c19e69d", "components": 57 } From 8dbd1c68f72b2a6e4e2c813980d2a500b00e77a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:48:56 +0000 Subject: [PATCH 3/4] fix(sdui-parser): stamp inert-quick-add as a literal, and add the changeset check:dispatcher-error-vocabulary cannot reduce a kebab-case SCREAMING_SNAKE constant at a `code:` position, so objectui's `code: INERT_QUICK_ADD` is an unresolvable-constant finding here. This is the same documented divergence `unconsumed-widget-option` already carries: stamp the inline literal, keep the exported constant, and pin the two equal next door. The lockstep resolves the identifier through the constant, so both copies still agree on all 26 codes. The changeset is OWED and measured: both codes reach the published dist (the package ships `files: [dist]`), against a negative control that reads zero. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .changeset/17645-sdui-parser-lockstep-port.md | 14 ++++++++++++++ .../src/__tests__/inert-quick-add.test.ts | 13 +++++++++++++ packages/sdui-parser/src/kanban-quick-add.ts | 18 +++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .changeset/17645-sdui-parser-lockstep-port.md 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/src/__tests__/inert-quick-add.test.ts b/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts index 226da6498f5..723cad21333 100644 --- a/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts +++ b/packages/sdui-parser/src/__tests__/inert-quick-add.test.ts @@ -108,6 +108,19 @@ describe('an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => { 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); diff --git a/packages/sdui-parser/src/kanban-quick-add.ts b/packages/sdui-parser/src/kanban-quick-add.ts index 0d5131868ac..20f9f74b9f0 100644 --- a/packages/sdui-parser/src/kanban-quick-add.ts +++ b/packages/sdui-parser/src/kanban-quick-add.ts @@ -149,7 +149,23 @@ export function checkKanbanQuickAdd(tag: string, key: string, value: unknown): D if (!value) return null; return { severity: 'warning', - code: INERT_QUICK_ADD, + // 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 ` + From 8a778067eb58d9b79241f6546016ea411c1269c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 16:17:52 +0000 Subject: [PATCH 4/4] =?UTF-8?q?chore(sdui-parser):=20stop=20claiming=20.ob?= =?UTF-8?q?jectui-sha=20on=20this=20PR=20=E2=80=94=20the=20pin=20lands=20v?= =?UTF-8?q?ia=20#19398=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Single-Claim Path Guard on the pin-bump PR fails with "At most one open PR may modify a declared single-writer path": .objectui-sha is a declared single-writer path and this draft still rewrites it (53ded82bf7a4 to cfcc17d9dd04), so it counts as an open claimant. Per ruling batch #200 item 4 the pin bump lands on its own PR first. So this branch releases the claim: both pin-carried files are restored to main's blobs byte for byte (.objectui-sha and scripts/sdui-manifest.record.json, the latter because check-sdui-manifest requires record pin == .objectui-sha). Nothing else is touched — the parser port, its tests and the changeset are unchanged. check:sdui-lockstep is RED in the interim, by design: [pin-moved], the lockstep record was taken at cfcc17d9dd04 while .objectui-sha now reads 53ded82bf7a4. This PR stays draft and re-records the lockstep at the new pin in a merge lap after the pin bump lands. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- .objectui-sha | 2 +- scripts/sdui-manifest.record.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.objectui-sha b/.objectui-sha index 2efe6750602..a9b031ceea7 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -cfcc17d9dd04ebb80c092cacb4f4034425f51b29 +53ded82bf7a494f54e344e19099dbf00854b8694 diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 14306b59b78..8389fcb570e 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -9,10 +9,10 @@ "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", "Regenerate + re-record: node scripts/gen-sdui-manifest-node.mjs (see its header for the offline mode)." ], - "objectuiSha": "cfcc17d9dd04ebb80c092cacb4f4034425f51b29", + "objectuiSha": "53ded82bf7a494f54e344e19099dbf00854b8694", "objectuiPackagesVersion": "17.6.0", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-09-17", + "generatedAt": "2026-09-08", "sha256": "49211fee7792cf51174930dc2c1be2169d5175f77a83491f68f90b3f0c19e69d", "components": 57 }