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