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); }