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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/17645-sdui-parser-lockstep-port.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
- **`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.
9 changes: 6 additions & 3 deletions packages/sdui-parser/objectui-lockstep.json
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---------------------- */",
Expand All @@ -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",
Expand Down
234 changes: 234 additions & 0 deletions packages/sdui-parser/src/__tests__/inert-quick-add.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Diagnostic[] =>
validateTree(node as never, manifest).diagnostics;

const codesFor = (node: Record<string, unknown>, 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('<object-kanban objectName="task" quickAdd={true} />', 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('<object-kanban objectName="task" quickAdd={true} />', 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('<not-a-block />', manifest).ok).toBe(false);
});
});
Loading
Loading