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
17 changes: 17 additions & 0 deletions .changeset/21671-html-literal-type-mismatch-error.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) A severity change on an existing diagnostic of the html-tier compiler: no authorable key, input declaration, export or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. The values now refused live inside a page's `source` string, which no ledger entry reads, and which value the author meant (an object, a number, a boolean) is authoring intent no conversion can supply. The in-tree authored population is zero: every `kind: 'html'` page in the example apps and every html example in the published skills compiles without a type mismatch against the committed `sdui.manifest.json` (census on the PR). The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a compiler severity and this diff adds none (not registered / already-registered); and the change is compiler behaviour, not a runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->

**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 `<object-metric> 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.
Original file line number Diff line number Diff line change
@@ -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(
`<object-metric objectName="ticket" aggregate="count" label="Open tickets" />`,
manifest,
);
expect(r.diagnostics).toEqual([
{
severity: 'error',
code: 'type-mismatch',
message: '<object-metric> prop "aggregate" expected an object',
tag: 'object-metric',
},
]);
expect(r.ok).toBe(false);
});

it('aggregate={{"function":"count"}} is clean', () => {
const r = compile(
`<object-metric objectName="ticket" aggregate={{"function":"count"}} label="Open tickets" />`,
manifest,
);
expect(r.diagnostics).toEqual([]);
expect(r.ok).toBe(true);
});

it('a string literal on a number input is an error', () => {
const r = compile(`<object-kanban limit="50" />`, 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(`<object-metric objectName="ticket" invert="true" />`, 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(`<object-metric objectName="ticket" filter="status = open" />`, 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(`<object-metric objectName="ticket" aggregate={count} />`, 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(`<object-metric objectName="ticket" filter={["status", status]} />`, manifest);
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['warning', 'inert-expression']]);
expect(r.ok).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<probe> prop "actions" expected every member to be a string — [1], [2] are not',
);
Expand Down
26 changes: 14 additions & 12 deletions packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(`<stat-card value={true} />`, manifest);
expect(r.diagnostics).toEqual([
{
severity: 'warning',
severity: 'error',
code: 'type-mismatch',
message: '<stat-card> 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', () => {
Expand All @@ -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(`<stat-card label={42} />`, manifest);
expect(r.diagnostics).toEqual([
{
severity: 'warning',
severity: 'error',
code: 'type-mismatch',
message: '<stat-card> prop "label" expected a string',
tag: 'stat-card',
Expand Down
57 changes: 40 additions & 17 deletions packages/sdui-parser/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] =>
Expand Down Expand Up @@ -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);
Expand All @@ -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(', ')} ` +
Expand All @@ -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);
Expand All @@ -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))
Expand Down
Loading