Skip to content

Commit 6ef8344

Browse files
committed
feat(sdui-parser): port inert-quick-add and member-type-mismatch from objectui
Folds the two-code sdui-parser port onto the pin-bump branch, verbatim: the `kanban-quick-add.ts` source, the `ManifestInput.of` member-kind check across validator, serializer and codegen, both test files with their ablation legs, and the port's minor changeset. Every file is byte-identical to the port branch's blob. The lockstep record is NOT text-merged from that branch: this branch already carries the record re-taken at the live pin, and `--update` re-reads objectui's side only, so nothing here can launder a divergence. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1a5b8f3 commit 6ef8344

8 files changed

Lines changed: 790 additions & 4 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/sdui-parser": minor
3+
---
4+
5+
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).
6+
7+
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.
8+
9+
- **`inert-quick-add`** (warning) — `quickAdd` on `<object-kanban>` 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.
10+
- **`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.
11+
12+
**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.
13+
14+
⚠️ **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.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
/**
2+
* `quickAdd` on the `ObjectKanbanRenderer` tag is DIAGNOSED, and diagnosed
3+
* truthfully — the objectui#8285 mechanism (ruling of 2026-09-08, batch #91),
4+
* ported into this copy in lockstep (objectstack#17645).
5+
*
6+
* WHY THESE PINS EXIST HERE. Two copies of this parser exist — objectui's
7+
* `packages/sdui-parser` (which the browser RENDERER runs) and this hoisted one
8+
* (which the SAVE GATE runs). These pins are the objectstack half of the
9+
* lockstep for this diagnostic; the ported module is byte-equal to objectui's.
10+
* Before this port, this copy answered an authored `quickAdd` with the generic
11+
* `unknown-prop` while objectui answered `inert-quick-add` — the same authored
12+
* page getting two different readings from the two tiers, which is the dialect
13+
* split the lockstep invariant forbids.
14+
*
15+
* ## What is red here before the change, and what is not
16+
*
17+
* ⚠️ Only the rows that assert `inert-quick-add` are red before this change.
18+
* `quickAdd` is undeclared on the kanban registrations (objectui#8201 escalated
19+
* it rather than declaring it), so this tier ALREADY reported it — as
20+
* `unknown-prop`, a message that is false against the contract
21+
* `@objectstack/spec` still publishes. The port replaces a wrong reading; it
22+
* does not add a first one.
23+
*
24+
* The remaining rows are green in BOTH worlds by construction, and each is kept
25+
* because it names a WRONG FIX that would otherwise pass:
26+
*
27+
* - the unknown-prop control — a fix that suppressed the generic walk for
28+
* this block rather than for this one key;
29+
* - the two RETIRED kanban-ish tags (`kanban`, `kanban-ui`) — a fix scoped
30+
* to "a kanban-ish tag" rather than to the one registration that serves
31+
* `ObjectKanbanRenderer`. Both left objectui's registry (objectui#8802 /
32+
* objectui#8257), so neither is a host any more; they stay declared in the
33+
* hand-built manifest below precisely so this row can still discriminate;
34+
* - `quickAdd: false` — a fix keyed on the KEY's presence rather than on the
35+
* author asking for the control, which would warn about a value that got
36+
* exactly what it asked for;
37+
* - the braced marker — a fix that reads the parser's opaque `$expr` object
38+
* as "truthy, therefore the author asked for it";
39+
* - `ok` staying true — an escalation to `error`, which the objectui#5709
40+
* ruling ("no new red gates") and objectui#6614 Q2 both put elsewhere.
41+
*/
42+
import { describe, expect, it } from 'vitest';
43+
import {
44+
INERT_QUICK_ADD,
45+
QUICK_ADD_HOST_TYPES,
46+
QUICK_ADD_KEY,
47+
compile,
48+
manifestFromConfigs,
49+
validateTree,
50+
} from '../index.js';
51+
import type { Diagnostic, Manifest } from '../types.js';
52+
53+
/**
54+
* The three kanban blocks, carrying the inputs their registrations declared —
55+
* `quickAdd` on NONE of them, which is the state this port leaves untouched.
56+
* `card` is a non-kanban control block.
57+
*
58+
* ⚠️ `kanban` and `kanban-ui` are RETIRED registrations (objectui#8802,
59+
* objectui#8257) and are kept here ON PURPOSE, as the discrimination controls
60+
* below: a manifest is an argument to `validateTree`, so this file can still
61+
* ask what the rule says about a tag the live registry no longer produces —
62+
* and the answer must be "not a host". ⛔ Do not read their presence as a
63+
* claim that either tag resolves; against a manifest built from the live
64+
* registry both answer `unknown-component`.
65+
*/
66+
const manifest: Manifest = manifestFromConfigs([
67+
{
68+
type: 'object-kanban',
69+
namespace: 'plugin-kanban',
70+
inputs: [
71+
{ name: 'objectName', type: 'string', required: true },
72+
{ name: 'groupBy', type: 'string' },
73+
],
74+
},
75+
{
76+
type: 'kanban',
77+
namespace: 'view',
78+
inputs: [
79+
{ name: 'objectName', type: 'string', required: true },
80+
{ name: 'groupBy', type: 'string' },
81+
],
82+
},
83+
{ type: 'kanban-ui', namespace: 'plugin-kanban', inputs: [{ name: 'columns', type: 'array' }] },
84+
{ type: 'card', namespace: 'ui', inputs: [] },
85+
]);
86+
87+
const diagnose = (node: Record<string, unknown>): Diagnostic[] =>
88+
validateTree(node as never, manifest).diagnostics;
89+
90+
const codesFor = (node: Record<string, unknown>, key: string): string[] =>
91+
diagnose(node).filter((d) => d.message.includes(`"${key}"`)).map((d) => d.code);
92+
93+
const HOST_TAGS = [...QUICK_ADD_HOST_TYPES].sort();
94+
95+
describe('an authored `quickAdd` is diagnosed on the ObjectKanban tags', () => {
96+
it('the host set is the one surviving ObjectKanbanRenderer tag, and is not empty', () => {
97+
// Anti-vacuity for every row below: an empty set would make the negative
98+
// rows trivially true and the positive rows unreachable.
99+
//
100+
// ⚠️ This row is the ONLY thing in this package that a change to
101+
// `QUICK_ADD_HOST_TYPES` reddens — every other row is `it.each(HOST_TAGS)`
102+
// and re-derives itself from the constant, so a narrowing would otherwise
103+
// just delete cases silently. `kanban` left the set with its registration
104+
// (objectui#8802): `checkKanbanQuickAdd` is reached only from
105+
// `validate.ts`'s prop walk, which runs only for a tag the manifest
106+
// RESOLVED, so a tag no registration produces is answered by
107+
// `unknown-component` one level up and never reaches this module.
108+
expect(HOST_TAGS).toEqual(['object-kanban']);
109+
});
110+
111+
it('the stamped literal equals the exported constant', () => {
112+
// objectui stamps `code: INERT_QUICK_ADD`; this copy stamps the inline
113+
// literal, because `check:dispatcher-error-vocabulary` cannot reduce a
114+
// kebab-case constant at a `code:` position (the divergence is documented
115+
// at the stamp, and is the same one `unconsumed-widget-option` carries).
116+
// This row is what keeps the two spellings from drifting apart — and the
117+
// lockstep gate resolves the identifier through this constant, so both
118+
// copies still agree on the same code.
119+
expect(INERT_QUICK_ADD).toBe('inert-quick-add');
120+
const [diagnostic] = diagnose({ type: 'object-kanban', objectName: 'task', quickAdd: true });
121+
expect(diagnostic.code).toBe(INERT_QUICK_ADD);
122+
});
123+
124+
it.each(HOST_TAGS)('<%s> — an authored `quickAdd: true` draws exactly one warning', (tag) => {
125+
const diagnostics = diagnose({ type: tag, objectName: 'task', quickAdd: true });
126+
expect(diagnostics).toHaveLength(1);
127+
expect(diagnostics[0].code).toBe(INERT_QUICK_ADD);
128+
expect(diagnostics[0].severity).toBe('warning');
129+
expect(diagnostics[0].tag).toBe(tag);
130+
});
131+
132+
it.each(HOST_TAGS)('<%s> — it REPLACES the false `unknown-prop`, it does not join it', (tag) => {
133+
// The reading this port exists to correct: before it, the only thing this
134+
// tier said about this key was that the block "has no prop quickAdd" —
135+
// false against `ComponentPropsMap['object-kanban']`, which publishes it.
136+
expect(codesFor({ type: tag, objectName: 'task', quickAdd: true }, QUICK_ADD_KEY)).toEqual([
137+
INERT_QUICK_ADD,
138+
]);
139+
});
140+
141+
it.each(HOST_TAGS)('<%s> — the message names the missing half and where the pair works', (tag) => {
142+
// Not a prose pin: these two tokens are what makes the diagnostic
143+
// ACTIONABLE, and a message that dropped either would send its reader back
144+
// to the contract with no explanation, which is the state being fixed.
145+
const [{ message }] = diagnose({ type: tag, objectName: 'task', quickAdd: true });
146+
expect(message).toContain('onQuickAdd');
147+
// ⚠️ The remedy names the COMPONENT, not the `kanban-ui` TAG it used to
148+
// name: objectui#8257 retired that registration, so a page written to the
149+
// old advice would now draw `unknown-component` — an ERROR. `KanbanRenderer`
150+
// is still exported from `@object-ui/plugin-kanban` and still forwards both
151+
// halves by identity, so it is the surviving way to get the pair.
152+
expect(message).toContain('KanbanRenderer');
153+
});
154+
155+
it.each(HOST_TAGS)('<%s> — control: a genuinely unknown prop is still reported', (tag) => {
156+
// Guards a fix that turned the generic walk off for this block instead of
157+
// answering for this one key. Green in both worlds by construction.
158+
expect(codesFor({ type: tag, objectName: 'task', bogusProp: 'x' }, 'bogusProp')).toEqual([
159+
'unknown-prop',
160+
]);
161+
});
162+
163+
it.each(HOST_TAGS)(
164+
'<%s> — control: a falsy `quickAdd` asks for no control, so it draws none',
165+
(tag) => {
166+
// `KanbanImpl` gates on `quickAdd && onQuickAdd`: `false` got exactly what
167+
// it wrote and nothing was dropped. Guards a fix keyed on the key's mere
168+
// presence. Green in both worlds — the `unknown-prop` half is the status
169+
// quo this port deliberately leaves alone.
170+
expect(codesFor({ type: tag, objectName: 'task', quickAdd: false }, QUICK_ADD_KEY)).toEqual([
171+
'unknown-prop',
172+
]);
173+
},
174+
);
175+
176+
it.each(HOST_TAGS)(
177+
'<%s> — control: a braced value this tier never evaluates is not read as a request',
178+
(tag) => {
179+
// The parser's deferred marker is an opaque object — truthy in JS, and no
180+
// reading at all about what the author asked for. Guards a fix that tested
181+
// truthiness alone. Green in both worlds.
182+
expect(
183+
codesFor(
184+
{ type: tag, objectName: 'task', quickAdd: { $expr: 'rows.length > 0' } },
185+
QUICK_ADD_KEY,
186+
),
187+
).toEqual(['unknown-prop']);
188+
},
189+
);
190+
191+
it.each(['kanban-ui', 'kanban'])(
192+
'control: `%s` is NOT a host — a kanban-ish tag alone does not arm this diagnostic',
193+
(tag) => {
194+
// Guards a fix scoped to "a kanban-ish tag". Both spellings are RETIRED
195+
// registrations (objectui#8257, objectui#8802), so neither can reach this
196+
// module through a manifest built from the live registry at all — they are
197+
// declared in this file's hand-built manifest so the discrimination is
198+
// still measurable here, which is the one thing a synthetic manifest can
199+
// do that the live one cannot. `kanban-ui`'s pair itself survives on the
200+
// exported `KanbanRenderer` component, which no tag resolves to.
201+
const node = { type: tag, columns: [], quickAdd: true };
202+
expect(diagnose(node).map((d) => d.code)).not.toContain(INERT_QUICK_ADD);
203+
expect(codesFor(node, QUICK_ADD_KEY)).toEqual(['unknown-prop']);
204+
},
205+
);
206+
207+
it('control: a non-kanban block is untouched', () => {
208+
expect(diagnose({ type: 'card', quickAdd: true }).map((d) => d.code)).toEqual(['unknown-prop']);
209+
});
210+
211+
it('through the whole pipeline: the diagnostic survives parse + validate on real source', () => {
212+
// A SUBJECT row, not a control — it is red without the port, like the rows
213+
// above. It is here because every row above hands `validateTree` a
214+
// hand-built node: this one starts from source text, so it also proves the
215+
// parser really materializes the braced `true` as a boolean rather than as
216+
// the `$expr` marker the falsy/braced controls are about.
217+
const { diagnostics } = compile('<object-kanban objectName="task" quickAdd={true} />', manifest);
218+
expect(diagnostics.map((d) => d.code)).toEqual([INERT_QUICK_ADD]);
219+
});
220+
221+
it('control: the page still COMPILES — a warning, not a new red gate', () => {
222+
// `ok` is `!diagnostics.some(d => d.severity === 'error')`, i.e. the save
223+
// gate's pass/fail, and it is true in BOTH worlds: `unknown-prop` was a
224+
// warning too, so nothing hardens here. Kept because it names the wrong
225+
// fix — escalating an inert authored key to `error`, which objectui#5709
226+
// ("no new red gates") and objectui#6614 Q2 both put elsewhere, and which
227+
// would stop a page that saves today from saving.
228+
expect(compile('<object-kanban objectName="task" quickAdd={true} />', manifest).ok).toBe(true);
229+
// Non-vacuity for that `true`: the same pipeline DOES turn `ok` false when
230+
// an error-severity diagnostic is present, so this is a reading about this
231+
// diagnostic's severity and not about `ok` being unreachable.
232+
expect(compile('<not-a-block />', manifest).ok).toBe(false);
233+
});
234+
});

0 commit comments

Comments
 (0)