Skip to content

Commit 2182bd1

Browse files
Elon Muskclaude
andauthored
feat(sdui-parser): materialize the JS literal subset in interpretBrace (#12977) (#13154)
Port the grammar half of the #12719 lockstep from objectui's copy of the parser, under the maintainer's ruling on objectui#6614 (Q1-A, 2026-08-28). `interpretBrace` now materializes exactly two widenings over JSON — single-quoted strings (value and key position) and unquoted identifier object keys — and everything else JSON refuses still becomes the deferred `{ $expr }` marker. `JSON.parse` runs first and untouched, so strict-JSON behaviour is invariant by construction. An authored `__proto__` key is written with `Object.defineProperty` as an own data property, the way `JSON.parse` gives it, never through the prototype setter: a plain assignment in the unquoted-key path would hand untrusted page source a prototype-pollution lever the strict-JSON path never had. The `inert-expression` message is reworded in the same lockstep — the old text advised "write it as JSON (double-quoted strings and keys)" and thereby named a now-legal spelling as the wrong one. Diagnostic codes are unchanged. Part of #12977 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3322527 commit 2182bd1

5 files changed

Lines changed: 743 additions & 60 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/sdui-parser': minor
3+
---
4+
5+
sdui-parser: `interpretBrace` materializes the JS literal subset, in lockstep with objectui
6+
7+
The html tier's braced attribute values accepted strict JSON only, so the spelling every
8+
JSX author and every AI author writes — `columns={['name','amount']}` — compiled to the
9+
deferred `{ $expr }` marker that nothing downstream evaluates, and the author's data
10+
binding vanished at render. Under the maintainer's ruling on objectui#6614 (Q1-A,
11+
2026-08-28) `interpretBrace` now materializes the JS **literal subset**: exactly two
12+
widenings over JSON — single-quoted strings (value position and key position) and unquoted
13+
identifier object keys.
14+
15+
Everything else JSON refuses is still refused and still becomes `{ $expr }`: trailing
16+
commas, comments, array holes, spreads, `undefined` / `NaN` / `Infinity`, `+1` / `.5` /
17+
`1.` / `0x1f`, template literals, and every genuine expression. `JSON.parse` still runs
18+
first and untouched, so strict-JSON behaviour is invariant by construction, and the subset
19+
contains no identifier lookup and no operator — the widening moves habitual spellings onto
20+
the materialized side, it does not move the data/code boundary (ADR-0080: this tier parses,
21+
never executes).
22+
23+
An authored `__proto__` key is written as an own data property, the way `JSON.parse` gives
24+
it, never through the prototype setter — a plain assignment in the unquoted-key path would
25+
hand untrusted page source a prototype-pollution lever the strict-JSON path never had.
26+
27+
The `inert-expression` diagnostic message is reworded to match: the old text advised
28+
writing the value as JSON with double-quoted strings and keys, which now names a legal
29+
spelling as the wrong one. Diagnostic **codes** are unchanged.

packages/sdui-parser/src/__tests__/inert-expression.test.ts

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
11
/**
2-
* `inert-expression` — the html tier's silent-vanish hole for braced non-JSON
3-
* values, ported into this copy in lockstep with objectui PR #6613.
2+
* `inert-expression` — the html tier's silent-vanish hole for braced values
3+
* this tier cannot materialize. Ported into this copy in lockstep with
4+
* objectui's `packages/sdui-parser` (objectui#6613, message reworded by
5+
* objectui#6614).
46
*
5-
* `interpretBrace` materializes strict-JSON values only; anything else — the
6-
* single-quoted array every JSX author writes, unquoted object keys, any JS
7-
* expression — becomes the deferred `{ $expr }` marker, and NOTHING downstream
8-
* evaluates that marker (this tier parses, never executes — ADR-0080; no
9-
* renderer consumes `$expr`). So `columns={['name','amount']}` used to compile
10-
* with ZERO diagnostics into a value every renderer's defensive non-array read
11-
* degrades to "no columns declared": rows render, the author's whole data
12-
* binding is eaten, and no surface ever says why. That is ADR-0078's prohibited
13-
* parsed-but-silently-inert state, reported from production as objectui#6598.
7+
* `interpretBrace` materializes strict JSON plus the JS LITERAL SUBSET
8+
* (objectui#6614 Q1-A, ruled 2026-08-28); a GENUINE EXPRESSION still becomes
9+
* the deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker
10+
* (this tier parses, never executes — ADR-0080; no renderer consumes `$expr`).
11+
* Such a value reaches the renderer as an opaque object, every defensive
12+
* non-array read degrades it to "not declared", and the author's binding is
13+
* eaten in silence. That is ADR-0078's prohibited parsed-but-silently-inert
14+
* state, reported from production as objectui#6598.
1415
*
1516
* WHY THIS FILE EXISTS HERE AND NOT ONLY THERE. There are two copies of this
1617
* parser — objectui's `packages/sdui-parser` and this repo's hoisted
17-
* `@objectstack/sdui-parser` — and the invariant is that both copies agree on
18-
* the accepted grammar AND on diagnostic codes. If they drift, the save gate
19-
* and the renderer speak different dialects: a page can save clean and render
20-
* inert, or the reverse — surface-dependent, and therefore intermittent from
21-
* the author's point of view. These pins are the objectstack half of that
22-
* lockstep; the emitted diagnostic is byte-equal to objectui's.
18+
* `@objectstack/sdui-parser` — and the invariant (#12719) is that both copies
19+
* agree on the accepted grammar AND on diagnostic codes. If they drift, the
20+
* save gate and the renderer speak different dialects: a page can save clean
21+
* and render inert, or the reverse — surface-dependent, and therefore
22+
* intermittent from the author's point of view. These pins are the objectstack
23+
* half of that lockstep.
2324
*
24-
* Severity is pinned as WARNING deliberately (the objectui#5709 precedent for
25-
* inert authored keys), and `ok` is pinned true alongside it: this port reports
26-
* an ALREADY-inert state, so it must leave the accept/reject set exactly where
27-
* it stood. Escalating to error, widening the accepted literal grammar (single
28-
* quotes / unquoted keys — objectui#6614), and base-prop (`style`) coverage are
29-
* open contract decisions; a change to any of those should move these pins
30-
* consciously, not by accident.
25+
* ⭐ WHAT MOVED IN #6614 Q1-A, AND WHY IT IS NOT AN ACCIDENT. This file
26+
* originally pinned `columns={['name','amount']}`, `columns={[{field:"name"}]}`
27+
* and `options={{pageSize: 25}}` as WARNING cases, and said in so many words
28+
* that widening the literal grammar "should move these pins consciously, not by
29+
* accident". Q1-A widened it, so those three spellings now MATERIALIZE and are
30+
* correct — the whole point of the ruling. They moved to
31+
* `literal-subset-6614.test.ts`, which pins their values; each was replaced
32+
* here by a genuine expression, so this file still pins the same FACT (an inert
33+
* braced value is never silent) on the same side of the new boundary.
34+
*
35+
* Severity stays WARNING deliberately (the objectui#5709 precedent for inert
36+
* authored keys), and `ok` is pinned true alongside it: this diagnostic reports
37+
* an ALREADY-inert state, so it leaves the accept/reject set exactly where it
38+
* stood. ⛔ Escalation to error is objectui#6614 **Q2**, which lands at the SAVE
39+
* GATE once the framework wires the registry manifest into `validate-jsx-pages`
40+
* (#12719 records that gap) — not here, and not at render.
3141
*/
3242
import { describe, expect, it } from 'vitest';
3343
import { compile } from '../index.js';
@@ -47,9 +57,12 @@ const manifest: Manifest = {
4757
},
4858
};
4959

50-
describe('inert-expression: braced non-JSON on a declared input warns instead of vanishing', () => {
51-
it('single-quoted array — the JSX habit — draws the warning and stays in the tree as $expr', () => {
52-
const r = compile(`<list-view objectName="account" columns={['name','amount']} />`, manifest);
60+
describe('inert-expression: a braced EXPRESSION on a declared input warns instead of vanishing', () => {
61+
it('a method call — the shape #6598 could not materialize — warns and stays as $expr', () => {
62+
const r = compile(
63+
`<list-view objectName="account" columns={rows.map((r) => r.name)} />`,
64+
manifest,
65+
);
5366
expect(r.diagnostics).toEqual([
5467
expect.objectContaining({
5568
severity: 'warning',
@@ -60,33 +73,37 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of
6073
]);
6174
// The marker itself is unchanged — the tree still carries the deferred
6275
// value; only the silence is gone.
63-
expect(r.tree?.columns).toEqual({ $expr: "['name','amount']" });
76+
expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' });
77+
// Warning, not error: the page still compiles (the objectui#5709 posture).
78+
expect(r.ok).toBe(true);
6479
});
6580

66-
it('the message carries the FIX, not merely the complaint', () => {
81+
it('the message names the CURRENT accepted grammar, not a now-legal spelling', () => {
6782
// An arrival pin, not a departure pin: "stopped being silent" is satisfied
68-
// by any diagnostic at all. What this port owes the author is the remedy —
69-
// name JSON, and show the corrected spelling next to the broken one. A
70-
// message rewrite that drops the remedy turns this red.
83+
// by any diagnostic at all. What this port owes the author is advice that
84+
// is still TRUE after objectui#6614 Q1-A — the pre-#6614 wording told the
85+
// author to "write it as JSON (double-quoted strings and keys)" and named
86+
// `columns={['name','amount']}` as the wrong form, which would now send
87+
// them to edit working source.
7188
const [d] = compile(
72-
`<list-view objectName="account" columns={['name','amount']} />`,
89+
`<list-view objectName="account" columns={rows.map((r) => r.name)} />`,
7390
manifest,
7491
).diagnostics;
75-
expect(d.message).toMatch(/JSON/);
76-
expect(d.message).toContain('double-quoted strings and keys');
77-
expect(d.message).toContain('columns={["name","amount"]}');
78-
expect(d.message).toContain(`columns={['name','amount']}`);
92+
expect(d.message).not.toMatch(/double-quoted/);
93+
expect(d.message).toContain('LITERALS only');
94+
expect(d.message).toContain(`columns={['name','amount']} works`);
95+
expect(d.message).toContain('columns={rows.map((r) => r.name)} cannot');
7996
});
8097

81-
it('unquoted object keys draw the same warning', () => {
82-
const r = compile(`<list-view objectName="account" columns={[{field:"name"}]} />`, manifest);
98+
it('a bare identifier draws the same warning', () => {
99+
const r = compile(`<list-view objectName="account" columns={savedColumns} />`, manifest);
83100
expect(r.diagnostics).toEqual([
84101
expect.objectContaining({ severity: 'warning', code: 'inert-expression' }),
85102
]);
86103
});
87104

88105
it('an $expr on an object-typed input is covered too', () => {
89-
const r = compile(`<list-view objectName="account" options={{pageSize: 25}} />`, manifest);
106+
const r = compile(`<list-view objectName="account" options={{...defaults}} />`, manifest);
90107
expect(r.diagnostics).toEqual([
91108
expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }),
92109
]);
@@ -108,14 +125,14 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of
108125
});
109126

110127
it('the accept/reject set does not move — every inert spelling still compiles', () => {
111-
// The load-bearing property of this port: it reports an ALREADY-inert
128+
// The load-bearing property: this diagnostic reports an ALREADY-inert
112129
// state, so `ok` (no error-severity diagnostic — the save gate's pass/fail)
113130
// is exactly what it was before the diagnostic existed. Escalating the
114131
// severity to error is objectui#6614's Q2 and would land here first.
115132
for (const source of [
116-
`<list-view objectName="account" columns={['name','amount']} />`,
117-
`<list-view objectName="account" columns={[{field:"name"}]} />`,
118-
`<list-view objectName="account" options={{pageSize: 25}} />`,
133+
`<list-view objectName="account" columns={rows.map((r) => r.name)} />`,
134+
`<list-view objectName="account" columns={savedColumns} />`,
135+
`<list-view objectName="account" options={{...defaults}} />`,
119136
]) {
120137
const r = compile(source, manifest);
121138
expect(r.ok).toBe(true);
@@ -124,7 +141,7 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of
124141
});
125142

126143
it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => {
127-
const r = compile(`<list-view objectName="account" aggregate={{field:'amount'}} />`, manifest);
144+
const r = compile(`<list-view objectName="account" aggregate={someTotal(amount)} />`, manifest);
128145
expect(r.diagnostics).toEqual([
129146
expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }),
130147
]);

0 commit comments

Comments
 (0)