diff --git a/.changeset/21671-html-literal-type-mismatch-error.md b/.changeset/21671-html-literal-type-mismatch-error.md
new file mode 100644
index 00000000000..d7674e66f4e
--- /dev/null
+++ b/.changeset/21671-html-literal-type-mismatch-error.md
@@ -0,0 +1,17 @@
+---
+'@objectstack/sdui-parser': minor
+---
+
+A `kind: 'html'` page that hands a component input a literal of the wrong type now fails to compile, instead of compiling with a warning.
+
+Clause-②: no (narrowing)
+
+
+
+**BREAKING**: an accept-set narrowing on the html page compiler, shipped as `minor` under the launch-window convention for accept-set narrowings. No export, type or diagnostic code changes.
+
+**What changed.** `compile()` used to grade a `type-mismatch` as an `error` only when the input declared an `enum` arm, and as a `warning` otherwise. Every value the type check sees is a literal written in the source: a quoted attribute is a string, a bare attribute is `true`, and a braced value is the exact literal written. (A braced value that is not a literal is reported separately as `inert-expression`, which stays a warning.) A literal's type is known when the page compiles, so a mismatch is certain, and it is now an `error` for every declared type. `member-type-mismatch`, the same check applied to the members of an array or map, follows the same rule. Codes and messages are unchanged.
+
+**Why.** `aggregate="count"` on an `object-metric` passed `os build` with one warning. The tile reads `aggregate.function` and `aggregate.field`, received a string, and drew no number.
+
+**What an author now sees.** `os validate`, `os build` and `os lint` fail on the page, and the save door refuses it when the host has a component manifest, with the existing message, for example ` prop "aggregate" expected an object`. To fix the page, write the value in the type the input declares: braces with JSON for an object (`aggregate={{"function":"count"}}`), braces for a number or a boolean (`limit={50}`, `invert={true}`), and braces with an array for an array (`fields={["name","amount"]}`). A string-typed input still takes a quoted value.
diff --git a/packages/sdui-parser/src/__tests__/literal-type-mismatch-error.test.ts b/packages/sdui-parser/src/__tests__/literal-type-mismatch-error.test.ts
new file mode 100644
index 00000000000..24a0ae17caf
--- /dev/null
+++ b/packages/sdui-parser/src/__tests__/literal-type-mismatch-error.test.ts
@@ -0,0 +1,99 @@
+/**
+ * A LITERAL whose coarse type no declared arm accepts is an `error`.
+ *
+ * `checkType` used to grade `type-mismatch` `error` only when an `enum` arm was
+ * present, on the ground that an enum's closed list was the one fact the layer
+ * could be certain about. A literal's coarse type is a second certain fact:
+ * every value that reaches `checkType` is a literal, because a braced value the
+ * parser cannot materialize becomes the `{ $expr }` marker and `validateTree`
+ * diverts it to `inert-expression` first. So `aggregate="count"` on an
+ * `object-metric` compiled `ok` with one warning, `os build` stayed green, and
+ * the tile, which reads `aggregate.function`, drew nothing.
+ *
+ * The inputs below are copied verbatim from the tracked `sdui.manifest.json`
+ * (the file `os build` resolves through `packages/cli/src/utils/sdui-manifest.ts`)
+ * and written inline, so this package's tests read nothing outside it.
+ */
+import { describe, expect, it } from 'vitest';
+import { compile } from '../index.js';
+import type { Manifest } from '../types.js';
+
+const manifest: Manifest = {
+ components: {
+ 'object-metric': {
+ type: 'object-metric',
+ namespace: 'ui',
+ inputs: [
+ { name: 'objectName', type: 'string', required: true },
+ { name: 'label', type: ['string', 'object'] },
+ { name: 'aggregate', type: 'object' },
+ { name: 'invert', type: 'boolean' },
+ { name: 'filter', type: 'array' },
+ ],
+ },
+ 'object-kanban': {
+ type: 'object-kanban',
+ namespace: 'ui',
+ inputs: [{ name: 'limit', type: 'number' }],
+ },
+ },
+};
+
+describe('a literal no declared arm accepts is an error', () => {
+ it('aggregate="count" on an object-typed input: error, and the compile is not ok', () => {
+ const r = compile(
+ ``,
+ manifest,
+ );
+ expect(r.diagnostics).toEqual([
+ {
+ severity: 'error',
+ code: 'type-mismatch',
+ message: ' prop "aggregate" expected an object',
+ tag: 'object-metric',
+ },
+ ]);
+ expect(r.ok).toBe(false);
+ });
+
+ it('aggregate={{"function":"count"}} is clean', () => {
+ const r = compile(
+ ``,
+ manifest,
+ );
+ expect(r.diagnostics).toEqual([]);
+ expect(r.ok).toBe(true);
+ });
+
+ it('a string literal on a number input is an error', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
+ expect(r.ok).toBe(false);
+ });
+
+ it('a string literal on a boolean input is an error', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
+ expect(r.ok).toBe(false);
+ });
+
+ it('a string literal on an array input is an error', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
+ expect(r.ok).toBe(false);
+ });
+});
+
+describe('an expression is not a literal and stays a warning', () => {
+ it('an expression handed to an object input draws the inert-expression warning, never type-mismatch', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['warning', 'inert-expression']]);
+ expect(r.ok).toBe(true);
+ });
+
+ it('a container holding an expression is one expression, not a literal with one bad member', () => {
+ const r = compile(``, manifest);
+ expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['warning', 'inert-expression']]);
+ expect(r.ok).toBe(true);
+ });
+});
diff --git a/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts b/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts
index 4b87cadaa06..9e8bdce3147 100644
--- a/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts
+++ b/packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts
@@ -67,7 +67,8 @@ describe('a declared member kind is READ', () => {
it('names every offending position in ONE diagnostic, not one per member', () => {
const [diagnostic] = diags(manifest, { actions: ['clone', 42, {}, 'convert'] });
expect(diagnostic.code).toBe('member-type-mismatch');
- expect(diagnostic.severity).toBe('warning');
+ // a member of a materialized literal is a literal: certain, so `error`
+ expect(diagnostic.severity).toBe('error');
expect(diagnostic.message).toBe(
' prop "actions" expected every member to be a string — [1], [2] are not',
);
diff --git a/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts b/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts
index 884c2c52efc..6b6f1ea761c 100644
--- a/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts
+++ b/packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts
@@ -21,11 +21,13 @@
*
* - ANY declared arm accepting the value clears the prop;
* - when NO arm accepts, a multi-arm input draws ONE `type-mismatch` naming
- * every arm, at the STRICTEST arm's severity — `error` when an `enum` arm
- * is present, `warning` otherwise;
- * - a single-arm input produces the byte-identical diagnostic it always did,
- * `invalid-enum` included — the port adds a form, it does not restate the
- * old one.
+ * every arm, at `error` severity — every value reaching the coarse check
+ * is a literal, whose coarse type is final at compile time (the rule
+ * `literal-type-mismatch-error.test.ts` pins; before it, a union without an
+ * `enum` arm drew a `warning`);
+ * - a single-arm input produces the code and message it always did, and the
+ * single-enum `invalid-enum` diagnostic is byte-identical, severity
+ * included — the port adds a form, it does not restate the old one.
*/
import { describe, expect, it } from 'vitest';
import { compile, generateDts, manifestFromConfigs } from '../index.js';
@@ -91,18 +93,18 @@ describe('union-arm type-mismatch: no arm accepting draws ONE diagnostic naming
// Before this port, BOTH cases below compiled with zero diagnostics: the
// union fell through the single-arm switch's `default: return null`. That
// silence is the drift this file closes — do not restore it.
- it('non-enum union → ONE warning-severity `type-mismatch` naming both arms', () => {
+ it('non-enum union → ONE error-severity `type-mismatch` naming both arms', () => {
const r = compile(``, manifest);
expect(r.diagnostics).toEqual([
{
- severity: 'warning',
+ severity: 'error',
code: 'type-mismatch',
message: ' prop "value" expected a string or a number',
tag: 'stat-card',
},
]);
- // warning does not move the save gate's pass/fail
- expect(r.ok).toBe(true);
+ // a literal no arm accepts is certain, so it gates the save
+ expect(r.ok).toBe(false);
});
it('enum arm present → the ONE diagnostic is ERROR severity, code `type-mismatch` (not `invalid-enum`), and carries the allowed values', () => {
@@ -120,12 +122,12 @@ describe('union-arm type-mismatch: no arm accepting draws ONE diagnostic naming
});
});
-describe('single-arm inputs are byte-identical to the pre-port diagnostics', () => {
- it('single string arm → the same warning `type-mismatch` as always', () => {
+describe('single-arm inputs keep the pre-port code and message', () => {
+ it('single string arm → the same `type-mismatch` code and message, now at error severity (a literal)', () => {
const r = compile(``, manifest);
expect(r.diagnostics).toEqual([
{
- severity: 'warning',
+ severity: 'error',
code: 'type-mismatch',
message: ' prop "label" expected a string',
tag: 'stat-card',
diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts
index 9381efe2f4e..86cc578493a 100644
--- a/packages/sdui-parser/src/validate.ts
+++ b/packages/sdui-parser/src/validate.ts
@@ -331,7 +331,13 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va
* down over the member kind `of` declares). The two copies must agree on the accepted
* grammar AND on diagnostic codes/severities — if they drift, the save gate
* and the renderer speak different dialects. Change these functions only
- * together with the objectui copy. */
+ * together with the objectui copy.
+ *
+ * Known lead: this copy grades every `type-mismatch` and `member-type-mismatch`
+ * `error` (a literal's coarse type is certain; see `checkType`). objectui's
+ * copy, `packages/sdui-parser/src/validate.ts` at the `.objectui-sha` pin,
+ * still grades them `error` only when an `enum` arm is present. Codes and
+ * messages agree; the severity differs until objectui ports the same rule. */
/** The values an `enum` arm admits, flattened from either declaration form. */
const enumValues = (input: ManifestInput): unknown[] =>
@@ -417,10 +423,14 @@ function memberEntries(value: unknown): Array<[string, unknown]> | null {
* made once, and N copies of it is the noise this repo treats as the thing that
* trains authors to dismiss real reports.
*
- * Severity mirrors `checkType`'s rule for the same reason — `error` when an
- * `enum` arm is present, because a closed list is the one fact this layer can
- * be certain about; `warning` otherwise, since the coarse kind is a KIND claim
- * and `os validate` / `os build` remain the judge of values.
+ * Severity mirrors `checkType`'s rule for the same reason, and is therefore
+ * always `error`. Every member reaching this function belongs to a container
+ * the parser materialized WHOLE: `interpretBrace` either reads the entire
+ * braced value as a literal or keeps the entire value as one `{ $expr }`
+ * marker (`["a", foo]` is one marker, never an array holding one), and a
+ * marker never gets here. So a member is a literal too, its coarse kind is
+ * final at compile time, and a member no declared arm accepts is as certain a
+ * mismatch as a prop no arm accepts.
*/
function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
const arms = inputTypeArms(input.of);
@@ -433,7 +443,7 @@ function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Di
if (offenders.length === 0) return null;
const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
return {
- severity: arms.includes('enum') ? 'error' : 'warning',
+ severity: 'error',
code: 'member-type-mismatch',
message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
@@ -454,16 +464,29 @@ function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Di
* as legal, it does not turn the check off. Two properties of the reporting are
* deliberate:
*
- * - A single-arm input produces the byte-identical diagnostic it always did,
- * `invalid-enum` included. This change adds a form; it does not restate the
- * old one.
- * - A multi-arm input produces ONE diagnostic naming every arm, at the
- * STRICTEST arm's severity — `error` when an `enum` arm is present, because
- * an enum's closed list is the one fact this layer can be certain about, and
- * a value outside it should not become dismissible merely because a second
- * arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`)
- * since the reported fact is "fits none of the declared arms", and the
- * message carries the allowed values so the author still sees the list.
+ * - A single-arm input produces the code and message it always did, and a
+ * single `enum` arm's `invalid-enum` is byte-identical, severity included.
+ * The union form adds a form; it does not restate the old one.
+ * - A multi-arm input produces ONE diagnostic naming every arm. Its code is
+ * `type-mismatch` (not `invalid-enum`) since the reported fact is "fits
+ * none of the declared arms", and the message carries the allowed values so
+ * the author still sees the list.
+ *
+ * Every `type-mismatch` is an `error`, because this layer reports only what it
+ * can be certain about, and there are two certain facts here:
+ *
+ * 1. An `enum` arm's closed list. A value outside it should not become
+ * dismissible merely because a second arm was added next to it.
+ * 2. A LITERAL's coarse type. Only literals reach this function: a braced
+ * value the parser could not materialize is the deferred `{ $expr }`
+ * marker, which `validateTree` diverts to `inert-expression` (a warning)
+ * before calling here. A quoted attribute is a string, a bare attribute is
+ * `true`, and a materialized brace is exactly the literal written. So
+ * `aggregate="count"` against an input declared `object` is final at
+ * compile time: no expression stands between the source and the renderer,
+ * and the tile that reads `aggregate.function` receives a string and
+ * draws nothing. A warning there let `os build` stay green on a page that
+ * renders nothing.
*/
function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
const arms = inputTypeArms(input.type);
@@ -480,7 +503,7 @@ function checkType(tag: string, input: ManifestInput, value: unknown): Diagnosti
}
return {
- severity: arms.includes('enum') ? 'error' : 'warning',
+ severity: 'error',
code: 'type-mismatch',
message: `<${tag}> prop "${input.name}" expected ${arms
.map((arm) => armExpectation(arm, input))