From c8cc1ec23415156e5d2a602048f979d7f3108c26 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:38:35 +0000 Subject: [PATCH] fix(sdui-parser): port the inert-expression diagnostic in lockstep with objectui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A braced attribute value that fails JSON materialization compiles to the parser's deferred { $expr } marker, and nothing downstream evaluates that marker — this tier parses, never executes (ADR-0080), and no renderer consumes $expr. The value reached the renderer as an opaque object, defensive non-array reads degraded it to 'not declared', and the author's binding vanished with zero diagnostics anywhere (ADR-0078's prohibited parsed-but-silently-inert state). validateTree now names it at compile time, warning severity, with the fix in the message. There are two copies of this parser and they must agree on the accepted grammar AND on diagnostic codes; if they drift, the save gate and the renderer speak different dialects. The emitted diagnostic is byte-equal to objectui PR #6613. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69 --- .../sdui-parser-inert-expression-lockstep.md | 37 +++++ .../src/__tests__/inert-expression.test.ts | 132 ++++++++++++++++++ packages/sdui-parser/src/validate.ts | 33 ++++- 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 .changeset/sdui-parser-inert-expression-lockstep.md create mode 100644 packages/sdui-parser/src/__tests__/inert-expression.test.ts diff --git a/.changeset/sdui-parser-inert-expression-lockstep.md b/.changeset/sdui-parser-inert-expression-lockstep.md new file mode 100644 index 0000000000..597eb2fcd1 --- /dev/null +++ b/.changeset/sdui-parser-inert-expression-lockstep.md @@ -0,0 +1,37 @@ +--- +'@objectstack/sdui-parser': minor +--- + +html tier: a braced attribute value that is not strict JSON now draws an `inert-expression` warning instead of vanishing silently + +`interpretBrace` materializes strict-JSON values only; anything else — the +single-quoted array every JSX author writes (`columns={['name','amount']}`), +unquoted object keys, any JS expression — compiles to the deferred `{ $expr }` +marker, and nothing downstream evaluates that marker: this tier parses, never +executes (ADR-0080), and no renderer consumes `$expr`. The value reached the +renderer as an opaque object, defensive non-array/non-object reads degraded it +to "not declared", and the author's binding vanished with zero diagnostics +anywhere — a production page's `list-view` rendered its row count and toolbar +with no data columns, through eight `columns` spellings (objectui#6598). That +is ADR-0078's prohibited parsed-but-silently-inert state. + +`validateTree` now emits a warning-severity `inert-expression` diagnostic when a +declared input's value is the `$expr` marker, with the fix in the message: write +the value as JSON (double-quoted strings and keys). + +This is the lockstep port of objectui PR #6613 into this repo's hoisted copy of +the parser. There are two copies, and the invariant is that both agree on the +accepted grammar **and** on diagnostic codes — if they drift, the save gate and +the renderer speak different dialects, and a page can save clean and render +inert. The emitted diagnostic is byte-equal to objectui's. + +Warning, not error, per the objectui#5709 posture for inert authored keys: this +reports an **already**-inert state, so the accept/reject set does not move. +Pages that compiled before still compile, and a warning is non-gating on every +consuming surface in this repo (`runtime-gate` files warnings as advisories, not +as write refusals; `os lint` exits non-zero on error-severity findings only). +The silence is what changed. Escalating the severity, widening the accepted +literal grammar (single-quoted strings, unquoted keys), and wiring the registry +manifest into `validate-jsx-pages` — without which this warning is recorded in +compile output but displayed by no production surface — are separate decisions +tracked on objectui#6614 and its follow-ups. diff --git a/packages/sdui-parser/src/__tests__/inert-expression.test.ts b/packages/sdui-parser/src/__tests__/inert-expression.test.ts new file mode 100644 index 0000000000..b55b1b913d --- /dev/null +++ b/packages/sdui-parser/src/__tests__/inert-expression.test.ts @@ -0,0 +1,132 @@ +/** + * `inert-expression` — the html tier's silent-vanish hole for braced non-JSON + * values, ported into this copy in lockstep with objectui PR #6613. + * + * `interpretBrace` materializes strict-JSON values only; anything else — the + * single-quoted array every JSX author writes, unquoted object keys, any JS + * expression — becomes the deferred `{ $expr }` marker, and NOTHING downstream + * evaluates that marker (this tier parses, never executes — ADR-0080; no + * renderer consumes `$expr`). So `columns={['name','amount']}` 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. + * + * WHY THIS FILE EXISTS HERE AND NOT ONLY THERE. There are two copies of this + * parser — objectui's `packages/sdui-parser` and this repo's hoisted + * `@objectstack/sdui-parser` — and the invariant is that both copies agree on + * the accepted grammar AND on diagnostic codes. If they drift, the save gate + * and the renderer speak different dialects: a page can save clean and render + * inert, or the reverse — surface-dependent, and therefore intermittent from + * the author's point of view. These pins are the objectstack half of that + * lockstep; the emitted diagnostic is byte-equal to objectui's. + * + * Severity is pinned as WARNING deliberately (the objectui#5709 precedent for + * inert authored keys), and `ok` is pinned true alongside it: this port reports + * an ALREADY-inert state, so it must leave the accept/reject set exactly where + * it stood. Escalating to error, widening the accepted literal grammar (single + * quotes / unquoted keys — objectui#6614), and base-prop (`style`) coverage are + * open contract decisions; a change to any of those should move these pins + * consciously, not by accident. + */ +import { describe, expect, it } from 'vitest'; +import { compile } 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' }, + ], + }, + }, +}; + +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); + expect(r.diagnostics).toEqual([ + expect.objectContaining({ + severity: 'warning', + code: 'inert-expression', + tag: 'list-view', + message: expect.stringContaining('"columns"'), + }), + ]); + // The marker itself is unchanged — the tree still carries the deferred + // value; only the silence is gone. + expect(r.tree?.columns).toEqual({ $expr: "['name','amount']" }); + }); + + it('the message carries the FIX, not merely the complaint', () => { + // An arrival pin, not a departure pin: "stopped being silent" is satisfied + // by any diagnostic at all. What this port owes the author is the remedy — + // name JSON, and show the corrected spelling next to the broken one. A + // message rewrite that drops the remedy turns this red. + const [d] = compile( + ``, + manifest, + ).diagnostics; + expect(d.message).toMatch(/JSON/); + expect(d.message).toContain('double-quoted strings and keys'); + expect(d.message).toContain('columns={["name","amount"]}'); + expect(d.message).toContain(`columns={['name','amount']}`); + }); + + it('unquoted object keys draw 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); + expect(r.diagnostics).toEqual([ + expect.objectContaining({ severity: 'warning', code: 'inert-expression', tag: 'list-view' }), + ]); + }); + + it('strict-JSON spellings stay diagnostic-free — the warning cannot fire on a working page', () => { + // This half is what stops the port from becoming a grammar change by + // accident: everything `interpretBrace` materializes must stay silent. + for (const source of [ + ``, + ``, + ``, + ``, + ]) { + const r = compile(source, manifest); + expect(r.diagnostics).toEqual([]); + expect(r.ok).toBe(true); + } + }); + + it('the accept/reject set does not move — every inert spelling still compiles', () => { + // The load-bearing property of this port: it reports an ALREADY-inert + // state, so `ok` (no error-severity diagnostic — the save gate's pass/fail) + // is exactly what it was before the diagnostic existed. Escalating the + // severity to error is objectui#6614's Q2 and would land here first. + for (const source of [ + ``, + ``, + ``, + ]) { + const r = compile(source, manifest); + expect(r.ok).toBe(true); + expect(r.diagnostics.every((d) => d.severity === 'warning')).toBe(true); + } + }); + + it('an $expr on an UNKNOWN prop keeps drawing unknown-prop, not a double report', () => { + const r = compile(``, manifest); + expect(r.diagnostics).toEqual([ + expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }), + ]); + }); +}); diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index c220cb8777..fd17e94633 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -79,7 +79,38 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va if (input.binding) { bindings.push({ tag: node.type, input: key, kind: input.binding, value }); } - if (!isExpr(value)) { + if (isExpr(value)) { + // A braced value that failed JSON materialization compiled to the + // parser's deferred `{ $expr }` marker — and NOTHING downstream + // evaluates that marker: this tier parses, never executes + // (ADR-0080), and no renderer consumes `$expr`. The value therefore + // reaches the renderer as an opaque object, every defensive + // non-array/non-object read degrades it to "not declared", and the + // author's binding silently vanishes (objectui#6598: eight `columns` + // 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. + // + // LOCKSTEP: this diagnostic is the byte-equal port of objectui's + // `packages/sdui-parser` copy (objectui PR #6613). The two copies + // must agree on the accepted grammar AND on diagnostic codes — if + // they drift, the save gate and the renderer speak different + // dialects and a page can save clean and render inert. Change this + // block only together with the objectui copy. + 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']}`, + tag: node.type, + }); + } else { const typeDiag = checkType(node.type, input, value); if (typeDiag) diagnostics.push(typeDiag); }