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
37 changes: 37 additions & 0 deletions .changeset/sdui-parser-inert-expression-lockstep.md
Original file line numberDiff line numberDiff line change
@@ -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.
132 changes: 132 additions & 0 deletions packages/sdui-parser/src/__tests__/inert-expression.test.ts
Original file line numberDiff line numberDiff line change
@@ -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(`<list-view objectName="account" columns={['name','amount']} />`, 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(
`<list-view objectName="account" columns={['name','amount']} />`,
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(`<list-view objectName="account" columns={[{field:"name"}]} />`, 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(`<list-view objectName="account" options={{pageSize: 25}} />`, 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 [
`<list-view objectName="account" columns={["name","amount"]} />`,
`<list-view objectName="account" columns={[{"field":"name","label":"Full Name"}]} />`,
`<list-view objectName="account" options={{"pageSize":25}} />`,
`<list-view objectName="account" />`,
]) {
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 [
`<list-view objectName="account" columns={['name','amount']} />`,
`<list-view objectName="account" columns={[{field:"name"}]} />`,
`<list-view objectName="account" options={{pageSize: 25}} />`,
]) {
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(`<list-view objectName="account" aggregate={{field:'amount'}} />`, manifest);
expect(r.diagnostics).toEqual([
expect.objectContaining({ severity: 'warning', code: 'unknown-prop' }),
]);
});
});
33 changes: 32 additions & 1 deletion packages/sdui-parser/src/validate.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
}
Expand Down
Loading