From 8bf2dbb24c283372a55f1a21375a3f263d8848da Mon Sep 17 00:00:00 2001 From: ObjectStack Agent Date: Fri, 28 Aug 2026 11:45:23 +0000 Subject: [PATCH] feat(sdui-parser): materialize the JS literal subset in braced attribute values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The html tier parses, never executes (ADR-0080) — it is the untrusted-safe DATA tier and the only safe carrier for AI- or tenant-authored pages. `interpretBrace` accepted only strict JSON inside braces while the surface called itself JSX, so `columns={['name','amount']}` compiled to the deferred `{ $expr }` marker that nothing evaluates, and the author's binding vanished at render. `interpretBrace` now materializes the JS literal subset: exactly two widenings over JSON — single-quoted strings (value and key position) and unquoted identifier object keys. Everything else JSON refuses is still refused and still draws `inert-expression`. The subset has no identifier lookup and no operator, so nothing in it is executable; an authored `__proto__` key becomes an own property as `JSON.parse` gives it, never the prototype setter. `JSON.parse` still runs first and untouched, so strict-JSON invariance is a property of the structure rather than of a test. Ruled on objectui#6614 (Q1-A, maintainer 2026-08-28). Q2 (error severity at the save gate) and Q3 (base-prop coverage) are deliberately not in this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6614-brace-literal-subset.md | 51 ++++ .../__tests__/inert-expression-6598.test.ts | 66 ++-- .../src/__tests__/literal-subset-6614.test.ts | 281 ++++++++++++++++++ packages/sdui-parser/src/parse.ts | 233 ++++++++++++++- packages/sdui-parser/src/validate.ts | 26 +- 5 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 .changeset/6614-brace-literal-subset.md create mode 100644 packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts diff --git a/.changeset/6614-brace-literal-subset.md b/.changeset/6614-brace-literal-subset.md new file mode 100644 index 0000000000..08e9f2f2a8 --- /dev/null +++ b/.changeset/6614-brace-literal-subset.md @@ -0,0 +1,51 @@ +--- +'@object-ui/sdui-parser': minor +--- + +html tier: braced attribute values now materialize the JS literal subset — single-quoted strings and unquoted identifier keys work (objectui#6614) + +The html tier is the untrusted-safe DATA tier: source is parsed, never executed +(ADR-0080), which makes it the only safe carrier for runtime AI- or +tenant-authored pages. But `interpretBrace` accepted only strict JSON inside +braces while the surface called itself JSX, so `columns={['name','amount']}` — +the spelling every JSX author and every AI author writes by habit — compiled to +the deferred `{ $expr }` marker that nothing downstream evaluates, and the +author's whole data binding vanished at render. A production page's `list-view` +rendered its row count and toolbar with zero data columns through eight +`columns` spellings before the author gave up (objectui#6598, moved from +objectstack#12649). That was a trap, not a contract. + +`interpretBrace` now materializes the JS **literal subset**. Exactly two +widenings over JSON, and nothing else: + +1. **single-quoted strings**, in value position and in key position — + `title={'Accounts'}`, `columns={['name','amount']}`, `{{'pageSize': 25}}`; +2. **unquoted identifier object keys** — `options={{pageSize: 25}}`, + `columns={[{field:'name',label:'Full Name'}]}`. + +Everything else JSON refuses is still refused, still compiles to `{ $expr }`, +and still draws the warning-severity `inert-expression` diagnostic: trailing +commas, comments, array holes, spreads, `undefined`/`NaN`/`Infinity`, +`+1`/`.5`/`1.`/`0x1f`, template literals, and every genuine expression — +identifiers, member access, calls, operators, ternaries. The subset contains no +identifier lookup and no operator, so there is nothing in it to execute: this +moves habitual spellings onto the materialized side, it does not move the +boundary between data and code. An authored `__proto__` key becomes an ordinary +own property, as `JSON.parse` gives it — never the prototype setter. + +Strict JSON is unchanged, structurally: `JSON.parse` still runs first and +untouched, so any value it accepts takes byte-identically the path it always +did, and the literal reader only ever sees input `JSON.parse` has already +thrown on. + +The `inert-expression` message changed with the grammar. It used to advise +"write it as JSON (double-quoted strings and keys)" and named +`columns={['name','amount']}` as the wrong form — advice that would now send an +author to edit working source. It names the accepted literal grammar instead. + +Maintainer ruling of 2026-08-28 (objectui#6614 Q1-A). ⛔ Two ruled items are +deliberately NOT in this change: escalating `inert-expression` from warning to +error (Q2 — it belongs at the save gate, once the framework wires the registry +manifest into `validate-jsx-pages`), and base-prop (`style`) `$expr` inertness +(Q3 — sequenced after this, so no warning is added for spellings this change +legalises). diff --git a/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts b/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts index abc8cebdd4..fe81482267 100644 --- a/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts +++ b/packages/sdui-parser/src/__tests__/inert-expression-6598.test.ts @@ -1,24 +1,33 @@ /** - * objectui#6598 — the html tier's silent-vanish hole for braced non-JSON values. + * objectui#6598 — the html tier's silent-vanish hole for braced values this + * tier cannot materialize. * - * `interpretBrace` materializes strict-JSON values only; anything else becomes - * the deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker + * `interpretBrace` materializes strict JSON plus the JS literal subset + * (objectui#6614 Q1-A, ruled 2026-08-28); a GENUINE EXPRESSION still becomes the + * deferred `{ $expr }` marker, and NOTHING downstream evaluates that marker * (this tier parses, never executes — ADR-0080; a repo-wide grep finds zero - * `$expr` consumers outside this package). So `columns={['name','amount']}` — - * the universal JSX spelling, single quotes — used to compile with ZERO - * diagnostics into a value every renderer's defensive non-array read degrades - * to "no columns declared": rows render, the author's whole data binding is - * eaten, and no surface ever says why. That is ADR-0078's prohibited - * parsed-but-silently-inert state, reported from production as objectui#6598 - * (moved from objectstack#12649). + * `$expr` consumers outside this package). Such a value reaches the renderer as + * an opaque object, every defensive non-array read degrades it to "not + * declared", and the author's binding is eaten in silence. That is ADR-0078's + * prohibited parsed-but-silently-inert state, reported from production as + * objectui#6598 (moved from objectstack#12649). * - * These cases pin the correction: a `$expr` value on a DECLARED input now draws - * the warning-severity `inert-expression` diagnostic, message carrying the fix. - * Severity is pinned as WARNING deliberately (the objectui#5709 precedent for - * inert authored keys): escalating to error, widening the accepted literal - * grammar (single quotes / unquoted keys), and base-prop (`style`) coverage are - * open contract decisions on the issue — a change to any of those should move - * these pins consciously, not by accident. + * These cases pin the correction: a `$expr` value on a DECLARED input draws the + * warning-severity `inert-expression` diagnostic, message carrying the fix. + * Severity stays WARNING deliberately (the objectui#5709 precedent for inert + * authored keys); ⛔ escalation to error is objectui#6614 **Q2**, which lands at + * the SAVE GATE once the framework wires the registry manifest into + * `validate-jsx-pages` — not here, and not at render. + * + * ⭐ WHAT MOVED IN #6614 Q1-A, AND WHY IT IS NOT AN ACCIDENT. This file + * originally pinned `columns={['name','amount']}`, `columns={[{field:"name"}]}` + * and `options={{pageSize: 25}}` as WARNING cases, and said in so many words + * that widening the literal grammar "should move these pins consciously, not by + * accident". Q1-A widened it, so those three spellings now MATERIALIZE and are + * correct — the whole point of the ruling. They moved to + * `literal-subset-6614.test.ts`, which pins their values; each was replaced here + * by a genuine expression, so this file still pins the same FACT (an inert + * braced value is never silent) on the same side of the new boundary. * * The fixture manifest mirrors the LIVE `list-view` registration's relevant * inputs (packages/plugin-list/src/index.tsx) but is deliberately synthetic — @@ -45,9 +54,12 @@ const manifest: Manifest = { }, }; -describe('inert-expression: braced non-JSON on a declared input warns instead of vanishing', () => { - it("single-quoted array — the JSX habit — draws the warning and stays in the tree as $expr", () => { - const r = compile(``, manifest); +describe('inert-expression: a braced EXPRESSION on a declared input warns instead of vanishing', () => { + it('a method call — the shape #6598 could not materialize — warns and stays as $expr', () => { + const r = compile( + ` r.name)} />`, + manifest, + ); expect(r.diagnostics).toEqual([ expect.objectContaining({ severity: 'warning', @@ -57,22 +69,22 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of }), ]); // The marker itself is unchanged — the tree still carries the deferred - // value; only the silence is gone. The message names the fix. - expect(r.tree?.columns).toEqual({ $expr: "['name','amount']" }); - expect(r.diagnostics[0].message).toMatch(/JSON/); + // value; only the silence is gone. The message names what IS accepted. + expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' }); + expect(r.diagnostics[0].message).toMatch(/LITERALS only/); // Warning, not error: the page still compiles (the objectui#5709 posture). expect(r.ok).toBe(true); }); - it('unquoted object keys draw the same warning', () => { - const r = compile(``, manifest); + it('a bare identifier draws the same warning', () => { + const r = compile(``, manifest); expect(r.diagnostics).toEqual([ expect.objectContaining({ severity: 'warning', code: 'inert-expression' }), ]); }); it('an $expr on an object-typed input is covered too', () => { - const r = compile(``, manifest); + const r = compile(``, manifest); expect(r.diagnostics).toEqual([ expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }), ]); @@ -92,7 +104,7 @@ describe('inert-expression: braced non-JSON on a declared input warns instead of }); it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => { - const r = compile(``, manifest); + const r = compile(``, manifest); expect(r.diagnostics).toEqual([ expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }), ]); diff --git a/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts b/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts new file mode 100644 index 0000000000..433099b43c --- /dev/null +++ b/packages/sdui-parser/src/__tests__/literal-subset-6614.test.ts @@ -0,0 +1,281 @@ +/** + * objectui#6614 Q1-A — `interpretBrace` materializes the JS LITERAL SUBSET. + * + * Maintainer ruling, 2026-08-28 (live director session, batch #5 item 1), + * verbatim: 「6614 也同意」 ⇒ Q1-A · Q2-A · Q3-A, adopting the card's + * recommendation whole. This file pins Q1-A and ONLY Q1-A. + * + * WHY THE RULING EXISTS. The html tier is the untrusted-safe DATA tier — parsed, + * never executed (ADR-0080) — and it is the only safe carrier for runtime AI or + * tenant authoring. Before this change it accepted strict JSON inside braces + * while calling itself JSX, so `columns={['name','amount']}` — the spelling + * every JSX author and every AI author writes — compiled to the deferred + * `{ $expr }` marker that nothing downstream evaluates, and the author's whole + * data binding vanished at render (objectui#6598, reported from production + * after eight spellings were tried). That is a trap, not a contract. The ruling + * closes it by materializing the literal subset, and it keeps genuine + * expressions loudly refused. + * + * ⭐ WHY THE REFUSAL PINS BELOW MATTER AS MUCH AS THE POSITIVE ONES. A suite + * that only asserted the newly-legal spellings would pass just as well against + * a parser that started accepting EVERYTHING — including code — which is the + * one failure this widening must never ship. So every positive pin here has a + * refusal pin naming the first thing on the far side of the boundary. + * + * THE BOUNDARY, stated once. Exactly two widenings over JSON: + * 1. single-quoted strings (value position AND key position); + * 2. unquoted identifier object keys. + * Everything else JSON refuses is still refused: trailing commas, comments, + * holes, spreads, `undefined`/`NaN`/`Infinity`, `+1`/`.5`/`1.`/`0x1f`, template + * literals, identifiers, member access, calls, and every operator. The subset + * contains no identifier lookup and no operator, so there is nothing in it to + * execute — the widening moves habitual spellings onto the materialized side, + * it does not move the data/code boundary. + * + * ⛔ NOT IN SCOPE HERE, by the same ruling: Q2 (escalating `inert-expression` + * from warning to error — that lands at the SAVE GATE once the framework wires + * the registry manifest into `validate-jsx-pages`) and Q3 (base-prop `$expr` + * inertness, sequenced deliberately AFTER this change so no warning is added + * for spellings this change is about to legalise). + */ +import { describe, expect, it } from 'vitest'; +import { compile, interpretBrace, parseJsx } from '../index.js'; +import type { Manifest } from '../types.js'; + +const manifest: Manifest = { + components: { + 'list-view': { + type: 'list-view', + namespace: 'plugin-list', + inputs: [ + { name: 'objectName', type: 'string', required: true }, + { name: 'columns', type: 'array' }, + { name: 'options', type: 'object' }, + { name: 'title', type: 'string' }, + { name: 'pageSize', type: 'number' }, + { name: 'enabled', type: 'boolean' }, + ], + }, + }, +}; + +/** The braced attribute value, as it reaches the renderer. */ +const propOf = (braced: string, prop = 'columns'): unknown => + parseJsx(``).tree?.[prop]; + +describe('#6614 Q1-A — the two ruled widenings materialize', () => { + it("the production spelling: a single-quoted array reaches the renderer as a real array", () => { + // objectui#6598's page, byte-for-byte. This is the assertion the whole card + // exists for: before the ruling this was `{ $expr: "['name','amount']" }`. + expect(propOf(`['name','amount']`)).toEqual(['name', 'amount']); + expect(interpretBrace(`['name','amount']`)).toEqual(['name', 'amount']); + }); + + it('a single-quoted string in value position materializes to the string itself', () => { + expect(propOf(`'Accounts'`, 'title')).toBe('Accounts'); + // No quotes survive into the value — the point is normalization, not a + // literal copy of the source text. + expect(interpretBrace(`'Accounts'`)).toBe('Accounts'); + }); + + it('unquoted identifier object keys materialize', () => { + expect(propOf(`{pageSize: 25}`, 'options')).toEqual({ pageSize: 25 }); + expect(propOf(`[{field:"name"},{field:"amount"}]`)).toEqual([ + { field: 'name' }, + { field: 'amount' }, + ]); + // `$` and `_` are identifier characters, and a digit is legal after the first. + expect(interpretBrace(`{_a: 1, $b: 2, c3: 3}`)).toEqual({ _a: 1, $b: 2, c3: 3 }); + }); + + it('a single-quoted KEY is covered — it is widening #1 in key position', () => { + expect(interpretBrace(`{'pageSize': 25}`)).toEqual({ pageSize: 25 }); + }); + + it('the two widenings compose, and nest', () => { + expect(interpretBrace(`{cols: ['name','amount'], page: {size: 25, deep: ['x']}}`)).toEqual({ + cols: ['name', 'amount'], + page: { size: 25, deep: ['x'] }, + }); + }); + + it('escapes inside a single-quoted string follow JSON, plus `\\u0027`', () => { + expect(interpretBrace(`'a\\'b'`)).toBe("a'b"); + expect(interpretBrace(`'tab\\there'`)).toBe('tab\there'); + expect(interpretBrace(`'\\u0041'`)).toBe('A'); + // A double quote needs no escape inside single quotes, and vice versa. + expect(interpretBrace(`'say "hi"'`)).toBe('say "hi"'); + }); + + it('the materialized spellings draw NO diagnostic — the page is simply correct now', () => { + for (const source of [ + ``, + ``, + ``, + ``, + ``, + ]) { + const r = compile(source, manifest); + expect(r.diagnostics, source).toEqual([]); + expect(r.ok, source).toBe(true); + } + }); + + it('a materialized value is type-checked like any other — the widening does not skip checkType', () => { + // Single quotes get it PAST the marker; they do not get it past the + // manifest. A string where an array is declared is still a mismatch. + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + expect.objectContaining({ code: 'type-mismatch', tag: 'list-view' }), + ]); + }); +}); + +describe('#6614 Q1-A — the far side of the boundary is still refused', () => { + /** Every one of these must stay the deferred marker, byte-for-byte. */ + const REFUSED: Array<[label: string, braced: string]> = [ + ['a bare identifier', `columns`], + ['member access', `ctx.user.name`], + ['a function call', `getColumns()`], + ['a method call on a literal', `['a'].concat(b)`], + ['the card-quoted expression', `rows.map((r) => r.name)`], + ['an arrow function', `() => 1`], + ['arithmetic', `1 + 2`], + ['a literal that merely STARTS the expression', `['a'] + x`], + ['a ternary', `a ? b : c`], + ['logical short-circuit', `flag && ['a']`], + ['a template literal', '`col-${n}`'], + ['an array spread', `[...cols]`], + ['an object spread', `{...base, pageSize: 25}`], + ['a trailing comma in an array', `['a',]`], + ['a trailing comma in an object', `{a: 1,}`], + ['an array hole', `[,1]`], + ['a block comment', `/* cols */ ['a']`], + ['a line comment', `['a'] // cols`], + ['undefined', `undefined`], + ['NaN', `NaN`], + ['Infinity', `Infinity`], + ['a leading plus', `+1`], + ['a bare fractional point', `.5`], + ['a trailing decimal point', `1.`], + ['a hex number', `0x1f`], + ['an octal-ish number', `010`], + ['a non-JSON escape', `'\\x41'`], + ['an unterminated string', `['a`], + ['a computed key', `{[k]: 1}`], + ['a keyword glued to an identifier', `nullish`], + ['an empty brace', ``], + ]; + + it.each(REFUSED)('%s stays the deferred `{ $expr }` marker', (_label, braced) => { + expect(interpretBrace(braced)).toEqual({ $expr: braced.trim() }); + }); + + it('a refused value still draws the warning-severity `inert-expression` diagnostic', () => { + const r = compile( + ` r.name)} />`, + manifest, + ); + expect(r.diagnostics).toEqual([ + expect.objectContaining({ + severity: 'warning', + code: 'inert-expression', + tag: 'list-view', + message: expect.stringContaining('"columns"'), + }), + ]); + expect(r.tree?.columns).toEqual({ $expr: 'rows.map((r) => r.name)' }); + // ⛔ Q2 is NOT this change: the render-side severity stays `warning`, and + // the page still compiles. Escalation belongs at the save gate. + expect(r.ok).toBe(true); + }); + + it('the diagnostic no longer advises a spelling that now works', () => { + const r = compile(``, manifest); + const message = r.diagnostics[0].message; + // The pre-#6614 message said "write it as JSON (double-quoted strings and + // keys)" and named `columns={['name','amount']}` as the WRONG form. That + // spelling is now correct, so the old advice would send an author to edit + // working source. Pin that it is gone. + expect(message).not.toMatch(/double-quoted/); + expect(message).toContain('LITERALS only'); + }); +}); + +describe('#6614 Q1-A — strict JSON is byte-identical to before', () => { + /** + * The invariance is structural, not incidental: `interpretBrace` still calls + * `JSON.parse` FIRST and untouched, so an input JSON accepts never reaches the + * new reader at all. This pins the property that structure guarantees. + */ + const STRICT_JSON = [ + `["name","amount"]`, + `[{"field":"name","label":"Full Name"}]`, + `{"pageSize":25}`, + `{}`, + `[]`, + `"plain string"`, + `25`, + `-0`, + `1e3`, + `-1.5e-3`, + `true`, + `false`, + `null`, + `{"nested":{"deep":[1,2,{"x":null}]}}`, + `"escaped \\" quote"`, + `"\\u00e9"`, + `{"__proto__":1}`, + `{"a":1,"a":2}`, + ]; + + it.each(STRICT_JSON)('%s parses exactly as JSON.parse does', (source) => { + expect(interpretBrace(source)).toEqual(JSON.parse(source)); + }); + + it('-0 keeps its sign, as JSON.parse gives it', () => { + expect(Object.is(interpretBrace(`-0`), -0)).toBe(true); + }); + + it('a strict-JSON page compiles with the same zero diagnostics it always did', () => { + for (const source of [ + ``, + ``, + ``, + ``, + ]) { + const r = compile(source, manifest); + expect(r.diagnostics, source).toEqual([]); + expect(r.ok, source).toBe(true); + } + }); +}); + +describe('#6614 Q1-A — the widening opens no execution or pollution lever', () => { + it('an authored `__proto__` key becomes an OWN property, never the prototype', () => { + // ⚠️ This is the one place where matching JS-literal semantics would be + // WRONG. A real JS object literal `{__proto__: {...}}` invokes the prototype + // setter; this tier exists to make untrusted source safe to parse, so the + // unquoted-key widening must follow `JSON.parse` (own data property) and not + // JS. A plain `out[key] = value` in the reader would fail this test. + const value = interpretBrace(`{__proto__: {polluted: true}}`) as Record; + expect(Object.prototype.hasOwnProperty.call(value, '__proto__')).toBe(true); + expect(Object.getPrototypeOf(value)).toBe(Object.prototype); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('a key named like a prototype member does not read through to it', () => { + const value = interpretBrace(`{constructor: 'x', toString: 'y'}`) as Record; + expect(value.constructor).toBe('x'); + expect(value.toString).toBe('y'); + }); + + it('duplicate keys take the last value, as both JSON and a JS literal do', () => { + expect(interpretBrace(`{a: 1, a: 2}`)).toEqual({ a: 2 }); + }); + + it('event handlers and raw-HTML injection stay forbidden regardless of the value grammar', () => { + const r = parseJsx(``); + expect(r.diagnostics.map((d) => d.code)).toEqual(['forbidden-attr', 'forbidden-attr']); + }); +}); diff --git a/packages/sdui-parser/src/parse.ts b/packages/sdui-parser/src/parse.ts index cf45bd2667..e162048c37 100644 --- a/packages/sdui-parser/src/parse.ts +++ b/packages/sdui-parser/src/parse.ts @@ -288,15 +288,240 @@ class Parser { /** * Interpret a braced attribute value `{...}`. - * JSON-literal values (numbers, booleans, null, strings, arrays, objects with - * quoted keys) are materialized. Anything else is kept as a deferred expression - * marker `{ $expr }` — typed and validated later, NEVER evaluated here. + * + * Strict-JSON values are materialized by `JSON.parse`, exactly as they always + * were. Beyond that, the JS **literal subset** below is materialized too + * (objectui#6614 Q1-A, maintainer ruling 2026-08-28). Anything left over — a + * genuine expression — is kept as the deferred marker `{ $expr }`: typed and + * validated later, drawing `inert-expression`, and NEVER evaluated here. + * + * ORDER IS LOAD-BEARING. `JSON.parse` runs FIRST and is untouched, so every + * input JSON accepts takes byte-identically the path it took before the literal + * subset existed. The reader below only ever sees strings `JSON.parse` has + * already thrown on, which makes strict-JSON invariance a property of the + * structure rather than of a test. */ export function interpretBrace(raw: string): unknown { const trimmed = raw.trim(); try { return JSON.parse(trimmed); } catch { - return { $expr: trimmed }; + const literal = readLiteral(trimmed); + return literal === NOT_LITERAL ? { $expr: trimmed } : literal; + } +} + +/* ---------------------- the JS literal subset (#6614) ---------------------- */ + +/** + * EXACTLY TWO widenings over JSON, and nothing else: + * + * 1. **single-quoted strings** — `{'name'}`, `{['name','amount']}`, and in + * key position `{{'pageSize': 25}}`; + * 2. **unquoted identifier object keys** — `{{pageSize: 25}}`. + * + * Everything else JSON refuses is still refused and still becomes `{ $expr }`: + * trailing commas, comments, array holes, spreads, `undefined` / `NaN` / + * `Infinity`, `+1` / `.5` / `1.` / `0x1f`, template literals, and every genuine + * expression — identifiers, member access, calls, operators, ternaries. + * + * That list is deliberately short. This is a VALUE grammar, not an evaluator: + * it contains no identifier lookup and no operator, so there is nothing here to + * execute (ADR-0080 — this tier parses, never executes). The widening moves the + * spellings an author writes by habit onto the materialized side; it does not + * move the boundary between data and code. + */ +const NOT_LITERAL = Symbol('not-a-literal'); + +/** JSON's whitespace set, not JS's — narrower, and one less thing to diverge. */ +const LITERAL_WS = /[ \t\n\r]/; +const IDENT_START = /[A-Za-z_$]/; +const IDENT_CHAR = /[A-Za-z0-9_$]/; +/** JSON's number grammar verbatim: no leading `+`, no `.5`, no `1.`, no hex. */ +const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/; +/** JSON's escape set. `\'` is added for single-quoted strings only. */ +const SIMPLE_ESCAPE: Record = { + '"': '"', + '\\': '\\', + '/': '/', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', +}; + +function readLiteral(src: string): unknown { + const reader = new LiteralReader(src); + const value = reader.value(); + if (value === NOT_LITERAL) return NOT_LITERAL; + reader.ws(); + // Trailing anything means the input was an expression that merely STARTS with + // a literal (`['a'] + x`, `1 + 2`). Refuse the whole input. + return reader.done() ? value : NOT_LITERAL; +} + +class LiteralReader { + private pos = 0; + + constructor(private readonly src: string) {} + + done(): boolean { + return this.pos >= this.src.length; + } + + ws(): void { + while (this.pos < this.src.length && LITERAL_WS.test(this.src[this.pos])) this.pos++; + } + + value(): unknown { + this.ws(); + const c = this.src[this.pos]; + if (c === undefined) return NOT_LITERAL; + if (c === '"' || c === "'") return this.string(c); + if (c === '[') return this.array(); + if (c === '{') return this.object(); + if (this.keyword('true')) return true; + if (this.keyword('false')) return false; + if (this.keyword('null')) return null; + return this.number(); + } + + /** A keyword only when it is not the prefix of a longer identifier. */ + private keyword(word: string): boolean { + if (!this.src.startsWith(word, this.pos)) return false; + const after = this.src[this.pos + word.length]; + if (after !== undefined && IDENT_CHAR.test(after)) return false; + this.pos += word.length; + return true; + } + + private number(): unknown { + const m = NUMBER.exec(this.src.slice(this.pos)); + if (!m) return NOT_LITERAL; + this.pos += m[0].length; + return Number(m[0]); + } + + private string(quote: string): unknown { + this.pos++; // opening quote + let out = ''; + for (;;) { + const c = this.src[this.pos]; + if (c === undefined) return NOT_LITERAL; // unterminated + if (c === quote) { + this.pos++; + return out; + } + if (c === '\\') { + const esc = this.src[this.pos + 1]; + if (esc === undefined) return NOT_LITERAL; + if (esc === 'u') { + const hex = this.src.slice(this.pos + 2, this.pos + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) return NOT_LITERAL; + out += String.fromCharCode(parseInt(hex, 16)); + this.pos += 6; + continue; + } + // `\'` is legal inside a single-quoted string only — JSON's set otherwise. + if (esc === "'" && quote === "'") { + out += "'"; + this.pos += 2; + continue; + } + const simple = SIMPLE_ESCAPE[esc]; + if (simple === undefined) return NOT_LITERAL; // `\x41`, `\0`, line continuation + out += simple; + this.pos += 2; + continue; + } + // JSON forbids raw control characters inside a string; so does this. + if (c < ' ') return NOT_LITERAL; + out += c; + this.pos++; + } + } + + private array(): unknown { + this.pos++; // '[' + const out: unknown[] = []; + this.ws(); + if (this.src[this.pos] === ']') { + this.pos++; + return out; + } + for (;;) { + const item = this.value(); + if (item === NOT_LITERAL) return NOT_LITERAL; + out.push(item); + this.ws(); + const c = this.src[this.pos]; + // A trailing comma leaves `value()` facing `]`, which it refuses — so + // `['a',]` is NOT in the subset. Only two widenings were ruled. + if (c === ',') { + this.pos++; + continue; + } + if (c === ']') { + this.pos++; + return out; + } + return NOT_LITERAL; + } + } + + private object(): unknown { + this.pos++; // '{' + const out: Record = {}; + this.ws(); + if (this.src[this.pos] === '}') { + this.pos++; + return out; + } + for (;;) { + this.ws(); + const key = this.key(); + if (key === NOT_LITERAL) return NOT_LITERAL; + this.ws(); + if (this.src[this.pos] !== ':') return NOT_LITERAL; + this.pos++; + const item = this.value(); + if (item === NOT_LITERAL) return NOT_LITERAL; + // ⚠️ Plain `out[key] = item` would hand an authored `__proto__` key the + // prototype SETTER. `JSON.parse` creates an ordinary own data property, + // and this path must too: the whole point of this tier is that untrusted + // source is safe to parse, so a widening must not open a + // prototype-pollution lever the strict-JSON path never had. + Object.defineProperty(out, key as string, { + value: item, + writable: true, + enumerable: true, + configurable: true, + }); + this.ws(); + const c = this.src[this.pos]; + if (c === ',') { + this.pos++; + continue; + } + if (c === '}') { + this.pos++; + return out; + } + return NOT_LITERAL; + } + } + + /** A quoted string, or a bare identifier — the second ruled widening. */ + private key(): unknown { + const c = this.src[this.pos]; + if (c === '"' || c === "'") return this.string(c); + if (c !== undefined && IDENT_START.test(c)) { + const start = this.pos; + this.pos++; + while (this.pos < this.src.length && IDENT_CHAR.test(this.src[this.pos])) this.pos++; + return this.src.slice(start, this.pos); + } + return NOT_LITERAL; } } diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 58f6c87606..1efa6d2847 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -93,17 +93,31 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma // spellings on a data block, all eaten without a single diagnostic — // rows rendered, zero data columns). ADR-0078 prohibits exactly this // parsed-but-silently-inert state, so name it at compile time, with - // the fix in the message. Warning, not error, per the objectui#5709 - // precedent for inert authored keys — escalation to error (and any - // widening of the accepted literal grammar, e.g. single-quoted - // strings) is a contract decision tracked on objectui#6598. + // the fix in the message. + // + // The message must name the CURRENT accepted grammar, and objectui#6614 + // (Q1-A, ruled 2026-08-28) moved it: `interpretBrace` now materializes + // the JS literal subset, so single-quoted strings and unquoted + // identifier keys REACH the renderer and can no longer draw this + // warning. The old wording ("write it as JSON, double-quoted") named a + // now-legal spelling as the illegal one — advice that would have sent + // an author to edit working source. What is left on this side of the + // boundary is a genuine expression, so that is what the message names. + // + // Warning, not error, per the objectui#5709 precedent for inert + // authored keys. ⛔ Escalation to error is objectui#6614 Q2 and is + // deliberately NOT part of this change: it belongs at the SAVE GATE, + // once the framework wires the registry manifest into + // `validate-jsx-pages` (objectstack#12719 records that gap). diagnostics.push({ severity: 'warning', code: 'inert-expression', message: `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` + - `the value will be silently ignored at render. Write it as JSON ` + - `(double-quoted strings and keys), e.g. columns={["name","amount"]} not columns={['name','amount']}`, + `the value will be silently ignored at render. This tier materializes LITERALS only ` + + `(strings, numbers, booleans, null, arrays, objects; quotes may be single or double, ` + + `object keys may be unquoted), e.g. columns={['name','amount']} works — ` + + `columns={rows.map((r) => r.name)} cannot`, tag: node.type, }); } else {