Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
36 changes: 36 additions & 0 deletions .changeset/sdui-parser-type-attribute-refusal.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@objectstack/sdui-parser': minor
---

html tier: an authored `type=` attribute is now refused at parse time instead of overwriting the component discriminator

On a `kind:'html'` page the tag name **is** the node's `type`, so a `type` attribute is a
name collision with the envelope's own discriminator. The parser now refuses it with one
`forbidden-attr` error naming **both** the tag and the attribute — *Attribute "type" is
not allowed on `<flex>` — on this tier the tag name IS the component…* — replacing two
outcomes, neither good:

- the value named another **registered** type (`<flex type="grid">`): the tree carried the
author's value as its discriminator, `validateTree` resolved `grid` in the manifest,
every check passed, and the page rendered a grid where the author wrote a flex — **zero
diagnostics**, on the one tier whose premise is that unreviewed, AI-authored source is
safe to accept;
- the value named **nothing** registered (`<object-chart type="bar">`, the shape a
react-tier author carries across): `unknown-component` naming `"bar"`, which reads as a
missing plugin rather than as an attribute that should not be there.

Alongside the refusal, `parseElement` builds the node as `{ ...props, type: tag }` rather
than `{ type: tag, ...props }` — defense in depth, and correct only *because* the
attribute is refused loudly: reversing the spread alone would trade a silent overwrite for
a silent discard.

The react tier is unaffected: its `specType` rescue (objectui#2880) stays where it lives
and is deliberately **not** carried over — the two tiers are two source formats, and a
consumer-side alias on a second tier is the tolerance ADR-0080's amendment declined.
`validate.ts`'s `BASE_PROPS` is unchanged (`type` is correct there for every other
member), and no warning grace period is introduced.

**This narrows what the html tier accepts**: a page that compiles today with a `type=`
attribute will be refused. The in-repo migration surface was measured before the change
and is **zero** — no html-tier page source under `content/docs/**` or the example apps
carries one. Maintainer ruling 2026-09-01, recorded as an amendment on ADR-0080.
8 changes: 6 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -131,8 +131,12 @@ One collision is worth knowing. `type` is the SDUI envelope's component discrimi
**and** a legitimate prop name on some blocks — a chart's family, for instance. The
discriminator wins the `type` slot and your value is preserved beside it as `specType`
for the block to read, so `<ObjectChart type="bar">` works as written. That rescue is the
react runtime's. On an `html` page the tag name *is* the node's `type`, a `type` attribute
overwrites it, and `<object-chart>` declares no `type` input to write in the first place.
react runtime's, and it stays there. On an `html` page the tag name *is* the node's
`type`, so a `type` attribute is a name collision, and the parser **refuses** it rather
than resolving it either way: `<flex type="grid" />` fails to compile with *Attribute
"type" is not allowed on `<flex>`* — one diagnostic naming both the tag and the attribute.
Write the tag of the component you mean; `<object-chart>` declares no `type` input to
write in the first place.

### `Block` — the escape hatch

Expand Down
35 changes: 35 additions & 0 deletions docs/adr/0080-ai-authored-ui-jsx-source.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,3 +146,38 @@ Coupling: completed `inputs` → codegen `.d.ts` (author-time) **and** serialize
1. Collapse the object collection-views into one `object-view` + `viewType` enum, or keep `object-kanban`/`object-calendar`/… as distinct named blocks (better AI recall vs. smaller vocabulary)?
2. Converge objectui's `${}` evaluator onto the framework CEL (ADR-0058) for the JSX-source expression layer — required for typed expressions, or deferred?
3. `compiledTree` persisted at save, or compiled lazily on read with a source-hash cache?

---

> **Amendment (2026-09-01 — the `type` attribute is refused on the html tier).** An
> authored `type=` attribute on an `html`-tier element is a **name collision with the
> envelope's own discriminator**, which on this tier the tag name sets, and the parser
> **refuses it at parse time** with one diagnostic naming both the tag and the attribute.
> Maintainer ruling of 2026-09-01, quoted verbatim and untranslated:
>
> > **(b) 裁「响亮拒绝」**:作者在元素上写 `type=` 属性 = 与信封判别符的名字冲突,parser 当场一条诊断**同时点名标签与属性**(两支合一…都被这条诊断替代);⛔ `specType` 别名不引入 html tier;⛔ 不设 warning 宽限期(不考虑渐进)
>
> That one diagnostic replaces two outcomes, and the **silent** one is why the ruling went
> this way: when the authored value named another *registered* type (`<flex type="grid">`)
> the tree carried the author's value as its discriminator, every manifest check passed,
> and the page rendered a different component with **zero diagnostics** — on the one tier
> whose stated premise (§2, §5) is that unreviewed, AI-authored source is safe to accept.
> When it named nothing registered, the diagnostic was `unknown-component` naming the
> *value*, which reads as a missing plugin rather than as an attribute that should not be
> there.
>
> Three boundaries the ruling drew, recorded because each was a live alternative:
> ⛔ the react tier's `specType` rescue (objectui#2880) is **not** carried over here — it
> is consumer-side tolerance, and it would spread an alias concept to a second tier
> (Prime Directive #12); the two tiers are two source formats, so refusing at the html
> tier's door does not disturb that rescue where it lives. ⛔ **No warning grace period**
> — the accept-set narrows in one payment. ⛔ The fix is **not** at the warning layer:
> `type` sits in `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
> member) and stays there. Alongside the refusal, `parseElement` now builds the node as
> `{ ...props, type: tag }` rather than `{ type: tag, ...props }` — defense in depth, and
> correct **only** because the attribute is refused loudly: reversing the spread alone
> would trade a silent overwrite for a silent discard.
>
> **Migration surface: none.** The census the ruling ordered first (scan `content/docs/**`
> and the example apps for html-tier sources carrying `type=`) measured **zero**
> occurrences; every `type=` in those trees is react-tier or plain-HTML illustration.
125 changes: 125 additions & 0 deletions packages/sdui-parser/src/__tests__/type-attribute-collision.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
/**
* html tier: an authored `type=` attribute collides with the envelope's own
* discriminator, and is REFUSED at parse time (objectstack#13957, maintainer
* ruling 2026-09-01 — ADR-0080 amendment).
*
* The two outcomes this replaces are pinned side by side on purpose, because
* they are the reason the refusal is at parse rather than at the warning layer:
* one of them produced NO diagnostic at all, and the other produced a loud one
* pointing somewhere else. A test that only asserted "an error is raised" would
* pass on the second case before this change.
*/
import { describe, expect, it } from 'vitest';
import { compile, manifestFromConfigs } from '../index.js';
import { parseJsx } from '../parse.js';

const manifest = manifestFromConfigs([
{ type: 'flex', namespace: 'ui', isContainer: true, inputs: [
{ name: 'direction', type: 'enum', enum: ['row', 'col'] },
{ name: 'gap', type: 'number' },
] },
{ type: 'grid', namespace: 'ui', isContainer: true, inputs: [{ name: 'columns', type: 'number' }] },
{ type: 'object-chart', namespace: 'plugin-charts', isContainer: false, inputs: [
{ name: 'objectName', type: 'string', binding: 'object' },
] },
]);

/** The refusal, as the author sees it: one diagnostic naming BOTH names. */
const refusals = (source: string) =>
compile(source, manifest).diagnostics.filter((d) => d.code === 'forbidden-attr');

describe('an authored `type` attribute is refused (the discriminator collision)', () => {
it('refuses when the value names ANOTHER REGISTERED type — the previously SILENT case', () => {
const r = compile('<flex type="grid" gap={4} />', manifest);

// Before this change: `grid` resolved in the manifest, every check passed,
// and the page rendered a grid where the author wrote a flex — zero
// diagnostics of any severity.
expect(r.ok).toBe(false);
expect(r.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'error', code: 'forbidden-attr', tag: 'flex' }),
);

// ONE diagnostic, naming BOTH the tag and the attribute (the ruled shape).
const [only, ...rest] = refusals('<flex type="grid" gap={4} />');
expect(rest).toEqual([]);
expect(only.message).toContain('"type"');
expect(only.message).toContain('<flex>');
});

it('refuses when the value names NOTHING registered — the previously MISDIRECTED case', () => {
// `<object-chart type="bar">` is the shape a react-tier author carries
// across: on that tier `type` is the chart family. Before this change the
// only diagnostic was `unknown-component` naming `"bar"`, which reads as a
// missing plugin rather than as an attribute that should not be there.
const r = compile('<object-chart objectName="invoice" type="bar" />', manifest);

expect(r.ok).toBe(false);
expect(refusals('<object-chart objectName="invoice" type="bar" />')).toHaveLength(1);
expect(r.diagnostics.map((d) => d.code)).not.toContain('unknown-component');
});

it('refuses a BARE `type` attribute too — the check is on the name, not the value', () => {
expect(refusals('<flex type />')).toHaveLength(1);
expect(refusals('<flex type={{"a":1}} />')).toHaveLength(1);
});

it('refuses it on a NESTED element, not only on the root', () => {
const found = refusals('<flex><grid type="flex" columns={2} /></flex>');
expect(found).toHaveLength(1);
expect(found[0].tag).toBe('grid');
expect(found[0].message).toContain('<grid>');
});

it('leaves a clean element alone — no regression', () => {
const r = compile('<flex direction="row" gap={4}><grid columns={2} /></flex>', manifest);
expect(r.ok).toBe(true);
expect(r.diagnostics).toEqual([]);
expect(r.tree).toMatchObject({ type: 'flex', direction: 'row', gap: 4 });
});
});

/**
* ⚠️ Read this before trusting the block below as coverage of the spread order.
*
* The order fix is DEFENSE IN DEPTH, which means the refusal makes it
* unobservable through the public API: measured by ablation on the committed
* tree, restoring `{ type: tag, ...props }` while LEAVING the refusal in place
* keeps all eight of these tests GREEN, because the refused attribute never
* reaches `props` to be spread. What turns them red is removing the refusal
* (5 red) and, additionally, the two spread-order assertions here when BOTH
* halves are removed together (6 red).
*
* So these tests pin the discriminator's identity, not the statement that
* produces it. That is not a gap to paper over with a stronger-sounding
* assertion — it is the ruled relationship between the two halves («拒绝使覆盖
* 不可达,顺序修复是防御纵深»), and it is stated here so the next author does
* not read a green suite as proof the order is load-bearing on its own.
*/
describe('spread order — defense in depth behind the refusal', () => {
it('keeps the TAG as the discriminator even when a `type` attribute was authored', () => {
// Parser-level, with no manifest: the refusal is a diagnostic, and the tree
// is still built. What must never happen is the tree carrying the AUTHOR's
// value as its discriminator — that is the silent redirect itself.
const { tree, diagnostics } = parseJsx('<flex type="grid" gap={4} />');

expect(tree?.type).toBe('flex');
expect(diagnostics.map((d) => d.code)).toContain('forbidden-attr');
});

it('and does not let the refused value land under its own name either', () => {
const { tree } = parseJsx('<flex type="grid" />');
expect(Object.values(tree ?? {})).not.toContain('grid');
});

it('every other prop still survives the reordered spread', () => {
const { tree } = parseJsx('<flex direction="col" gap={8} wrap><grid columns={3} /></flex>');
expect(tree).toMatchObject({
type: 'flex',
direction: 'col',
gap: 8,
wrap: true,
children: [{ type: 'grid', columns: 3 }],
});
});
});
71 changes: 68 additions & 3 deletions packages/sdui-parser/src/parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,6 +20,40 @@ import type { Diagnostic, ParseOptions, ParseResult, SchemaElement, SchemaNode }
const EVENT_ATTR = /^on[A-Z]/;
const FORBIDDEN_ATTRS = new Set(['dangerouslySetInnerHTML', 'ref', 'key']);

/**
* The envelope's own discriminator, which on THIS tier the tag name sets.
*
* An authored `type=` attribute is a NAME COLLISION with it, and the parser
* refuses it at parse time (maintainer ruling 2026-09-01, recorded as an
* amendment on ADR-0080 — 「响亮拒绝」, quoted verbatim there). One diagnostic
* naming BOTH the tag and the attribute replaces two bad outcomes:
*
* - the value named another REGISTERED type (`<flex type="grid">`) — the tree
* carried `type:'grid'`, `validateTree` found `grid` in the manifest, every
* check passed, and the page rendered a grid where the author wrote a flex.
* ZERO diagnostics. On the one tier whose whole premise is that unreviewed
* and AI-authored source is safe to accept.
* - the value named NOTHING registered (`<object-chart type="bar">`, the shape
* a react-tier author carries across) — loud, but `unknown-component`
* naming `"bar"` reads as a missing plugin, never as a bad prop.
*
* ⛔ NOT rescued as `specType` the way the react tier rescues it (objectui#2880):
* that is consumer-side tolerance, and it would spread an alias concept to a
* second tier. ⛔ NOT a warning grace period either — the same ruling declined
* a staged rollout. ⛔ And NOT fixable at the warning layer: `type` is in
* `validate.ts`'s `BASE_PROPS` deliberately (it is correct for every other
* member), so removing it there would make every legitimate node warn. The
* refusal belongs here, at parse.
*
* ⚠️ The code is the EXISTING `forbidden-attr`, not a new one, and that is
* load-bearing rather than lazy: `scripts/check-sdui-lockstep.mjs` holds this
* copy's diagnostic-code set equal to objectui's at the pinned revision, so a
* code minted on one side only IS the dialect split that gate exists to catch
* (#12719). `forbidden-attr` already carries this shape — an attribute this
* tier refuses, named beside its element — and both copies stamp it.
*/
const DISCRIMINATOR_ATTR = 'type';

export function parseJsx(source: string, options: ParseOptions = {}): ParseResult {
return new Parser(source, options).parseDocument();
}
Expand DownExpand Up@@ -69,7 +103,15 @@ class Parser {
if (c === '' || c === '>' || c === '/') break;
const attr = this.parseAttr(start, tag);
if (!attr) break;
props[attr.name] = attr.value;
// `drop` is set only for the refused discriminator attribute, and only so
// that ONE diagnostic is what the author gets. The `__forbidden_<name>`
// sentinel the other refusals park in `props` reaches `validateTree`,
// which knows no such prop and adds `unknown-prop` naming a key nobody
// wrote — loud, and pointing at the wrong thing, which is the species of
// diagnostic this whole change exists to remove. The existing sentinel
// behaviour is left exactly as it was for the attributes that already had
// it (`ref`, `key`, `dangerouslySetInnerHTML`, `on*`).
if (!attr.drop) props[attr.name] = attr.value;
}

this.skipWs();
Expand All@@ -82,12 +124,22 @@ class Parser {
this.error('unterminated-open-tag', `Unterminated <${tag}> open tag`, start, tag);
}

const node: SchemaElement = { type: tag, ...props };
// DEFENSE IN DEPTH (ruled together with the refusal above). `props` used to
// be spread AFTER `type: tag`, so an authored `type` attribute overwrote the
// discriminator the tag established and nothing downstream restored it —
// `compile()` returns this tree as-is and `validateTree` then looks up
// `manifest.components[node.type]`, i.e. the value the author wrote, not the
// tag they wrote. The refusal makes that overwrite unreachable; the order
// here makes it impossible. ⚠️ Reversing the order ALONE would have been a
// regression of its own — the authored value would then be dropped in
// silence, trading one silence for another. It is correct only BECAUSE the
// attribute is refused loudly one function up.
const node: SchemaElement = { ...props, type: tag };
if (children && children.length) node.children = children;
return node;
}

private parseAttr(elStart: number, tag: string): { name: string; value: unknown } | null {
private parseAttr(elStart: number, tag: string): { name: string; value: unknown; drop?: boolean } | null {
const name = this.readName();
if (!name) {
this.error('bad-attr', `Malformed attribute on <${tag}>`, this.pos, tag);
Expand All@@ -101,6 +153,19 @@ class Parser {
this.skipWs();
value = this.parseAttrValue(tag);
}
if (name === DISCRIMINATOR_ATTR) {
// ONE diagnostic naming both the tag and the attribute — see
// DISCRIMINATOR_ATTR above for why it replaces both prior outcomes.
this.error(
'forbidden-attr',
`Attribute "${DISCRIMINATOR_ATTR}" is not allowed on <${tag}> — on this tier the tag name IS the `
+ `component, so <${tag}> already means type "${tag}". Delete the attribute, or write the tag of the `
+ 'component you meant.',
elStart,
tag,
);
return { name, value: undefined, drop: true };
}
if (EVENT_ATTR.test(name) || FORBIDDEN_ATTRS.has(name)) {
this.error('forbidden-attr', `Attribute "${name}" is not allowed on <${tag}>`, elStart, tag);
return { name: `__forbidden_${name}`, value: undefined };
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
{
"file": "packages/sdui-parser/src/parse.ts",
"adrs": [
"ADR-0080"
],
"invariant": "ADR-0080 (2026-09-01 amendment) — an authored `type=` attribute on an html-tier element is a NAME COLLISION with the envelope's discriminator, which on this tier the tag name sets, and it is REFUSED at parse with one diagnostic naming both the tag and the attribute. Two things here look local and are not. (1) The refusal is not \"an attribute we happen to dislike\": before it, an authored `type` naming another REGISTERED component (`<flex type=\"grid\">`) overwrote the discriminator, resolved cleanly against the manifest, and rendered a different component with ZERO diagnostics — on the one tier whose premise is that unreviewed, AI-authored source is safe to accept; when it named nothing registered, the only diagnostic was `unknown-component` naming the VALUE, which reads as a missing plugin rather than as a bad prop. ⛔ Do not soften this to a warning, and ⛔ do not import the react tier's `specType` rescue (objectui#2880) — the ruling declined both by name, the second because a consumer-side alias would spread that concept to a second tier (Prime Directive #12). ⛔ The remedy is also NOT at the warning layer: `type` is in validate.ts's BASE_PROPS deliberately, correct for every other member, and stays. (2) `{ ...props, type: tag }` is the ruled defense-in-depth half, and its order is correct ONLY because the attribute is refused loudly one function up — reversing the spread on its own trades a silent overwrite for a silent discard. The refusal stamps the EXISTING `forbidden-attr` code rather than a new one: `scripts/check-sdui-lockstep.mjs` holds this copy's diagnostic-code set equal to objectui's at the pinned revision, so a code minted on one side only is the dialect split that gate exists to catch (#12719)."
}
Loading