Skip to content

Commit 4fb37c9

Browse files
committed
fix(sdui-parser): a literal no declared arm accepts is a type-mismatch error
Every value reaching checkType is a literal (a braced value the parser cannot materialize is diverted to inert-expression first), so its coarse type is final at compile time, as certain as an enum's closed list. aggregate="count" on object-metric now fails the compile instead of passing os build with a warning. checkMemberTypes follows the same rule: members of a materialized literal are literals too. Claude-Session: https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93a54b8 commit 4fb37c9

4 files changed

Lines changed: 155 additions & 30 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* A LITERAL whose coarse type no declared arm accepts is an `error`.
3+
*
4+
* `checkType` used to grade `type-mismatch` `error` only when an `enum` arm was
5+
* present, on the ground that an enum's closed list was the one fact the layer
6+
* could be certain about. A literal's coarse type is a second certain fact:
7+
* every value that reaches `checkType` is a literal, because a braced value the
8+
* parser cannot materialize becomes the `{ $expr }` marker and `validateTree`
9+
* diverts it to `inert-expression` first. So `aggregate="count"` on an
10+
* `object-metric` compiled `ok` with one warning, `os build` stayed green, and
11+
* the tile, which reads `aggregate.function`, drew nothing.
12+
*
13+
* The inputs below are copied verbatim from the tracked `sdui.manifest.json`
14+
* (the file `os build` resolves through `packages/cli/src/utils/sdui-manifest.ts`)
15+
* and written inline, so this package's tests read nothing outside it.
16+
*/
17+
import { describe, expect, it } from 'vitest';
18+
import { compile } from '../index.js';
19+
import type { Manifest } from '../types.js';
20+
21+
const manifest: Manifest = {
22+
components: {
23+
'object-metric': {
24+
type: 'object-metric',
25+
namespace: 'ui',
26+
inputs: [
27+
{ name: 'objectName', type: 'string', required: true },
28+
{ name: 'label', type: ['string', 'object'] },
29+
{ name: 'aggregate', type: 'object' },
30+
{ name: 'invert', type: 'boolean' },
31+
{ name: 'filter', type: 'array' },
32+
],
33+
},
34+
'object-kanban': {
35+
type: 'object-kanban',
36+
namespace: 'ui',
37+
inputs: [{ name: 'limit', type: 'number' }],
38+
},
39+
},
40+
};
41+
42+
describe('a literal no declared arm accepts is an error', () => {
43+
it('aggregate="count" on an object-typed input: error, and the compile is not ok', () => {
44+
const r = compile(
45+
`<object-metric objectName="ticket" aggregate="count" label="Open tickets" />`,
46+
manifest,
47+
);
48+
expect(r.diagnostics).toEqual([
49+
{
50+
severity: 'error',
51+
code: 'type-mismatch',
52+
message: '<object-metric> prop "aggregate" expected an object',
53+
tag: 'object-metric',
54+
},
55+
]);
56+
expect(r.ok).toBe(false);
57+
});
58+
59+
it('aggregate={{"function":"count"}} is clean', () => {
60+
const r = compile(
61+
`<object-metric objectName="ticket" aggregate={{"function":"count"}} label="Open tickets" />`,
62+
manifest,
63+
);
64+
expect(r.diagnostics).toEqual([]);
65+
expect(r.ok).toBe(true);
66+
});
67+
68+
it('a string literal on a number input is an error', () => {
69+
const r = compile(`<object-kanban limit="50" />`, manifest);
70+
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
71+
expect(r.ok).toBe(false);
72+
});
73+
74+
it('a string literal on a boolean input is an error', () => {
75+
const r = compile(`<object-metric objectName="ticket" invert="true" />`, manifest);
76+
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
77+
expect(r.ok).toBe(false);
78+
});
79+
80+
it('a string literal on an array input is an error', () => {
81+
const r = compile(`<object-metric objectName="ticket" filter="status = open" />`, manifest);
82+
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['error', 'type-mismatch']]);
83+
expect(r.ok).toBe(false);
84+
});
85+
});
86+
87+
describe('an expression is not a literal and stays a warning', () => {
88+
it('an expression handed to an object input draws the inert-expression warning, never type-mismatch', () => {
89+
const r = compile(`<object-metric objectName="ticket" aggregate={count} />`, manifest);
90+
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['warning', 'inert-expression']]);
91+
expect(r.ok).toBe(true);
92+
});
93+
94+
it('a container holding an expression is one expression, not a literal with one bad member', () => {
95+
const r = compile(`<object-metric objectName="ticket" filter={["status", status]} />`, manifest);
96+
expect(r.diagnostics.map((d) => [d.severity, d.code])).toEqual([['warning', 'inert-expression']]);
97+
expect(r.ok).toBe(true);
98+
});
99+
});

‎packages/sdui-parser/src/__tests__/member-type-mismatch.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ describe('a declared member kind is READ', () => {
6767
it('names every offending position in ONE diagnostic, not one per member', () => {
6868
const [diagnostic] = diags(manifest, { actions: ['clone', 42, {}, 'convert'] });
6969
expect(diagnostic.code).toBe('member-type-mismatch');
70-
expect(diagnostic.severity).toBe('warning');
70+
// a member of a materialized literal is a literal: certain, so `error`
71+
expect(diagnostic.severity).toBe('error');
7172
expect(diagnostic.message).toBe(
7273
'<probe> prop "actions" expected every member to be a string — [1], [2] are not',
7374
);

‎packages/sdui-parser/src/__tests__/union-arm-type-mismatch.test.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
*
2222
* - ANY declared arm accepting the value clears the prop;
2323
* - when NO arm accepts, a multi-arm input draws ONE `type-mismatch` naming
24-
* every arm, at the STRICTEST arm's severity — `error` when an `enum` arm
25-
* is present, `warning` otherwise;
26-
* - a single-arm input produces the byte-identical diagnostic it always did,
27-
* `invalid-enum` included — the port adds a form, it does not restate the
28-
* old one.
24+
* every arm, at `error` severity — every value reaching the coarse check
25+
* is a literal, whose coarse type is final at compile time (the rule
26+
* `literal-type-mismatch-error.test.ts` pins; before it, a union without an
27+
* `enum` arm drew a `warning`);
28+
* - a single-arm input produces the code and message it always did, and the
29+
* single-enum `invalid-enum` diagnostic is byte-identical, severity
30+
* included — the port adds a form, it does not restate the old one.
2931
*/
3032
import { describe, expect, it } from 'vitest';
3133
import { compile, generateDts, manifestFromConfigs } from '../index.js';
@@ -91,18 +93,18 @@ describe('union-arm type-mismatch: no arm accepting draws ONE diagnostic naming
9193
// Before this port, BOTH cases below compiled with zero diagnostics: the
9294
// union fell through the single-arm switch's `default: return null`. That
9395
// silence is the drift this file closes — do not restore it.
94-
it('non-enum union → ONE warning-severity `type-mismatch` naming both arms', () => {
96+
it('non-enum union → ONE error-severity `type-mismatch` naming both arms', () => {
9597
const r = compile(`<stat-card value={true} />`, manifest);
9698
expect(r.diagnostics).toEqual([
9799
{
98-
severity: 'warning',
100+
severity: 'error',
99101
code: 'type-mismatch',
100102
message: '<stat-card> prop "value" expected a string or a number',
101103
tag: 'stat-card',
102104
},
103105
]);
104-
// warning does not move the save gate's pass/fail
105-
expect(r.ok).toBe(true);
106+
// a literal no arm accepts is certain, so it gates the save
107+
expect(r.ok).toBe(false);
106108
});
107109

108110
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
120122
});
121123
});
122124

123-
describe('single-arm inputs are byte-identical to the pre-port diagnostics', () => {
124-
it('single string arm → the same warning `type-mismatch` as always', () => {
125+
describe('single-arm inputs keep the pre-port code and message', () => {
126+
it('single string arm → the same `type-mismatch` code and message, now at error severity (a literal)', () => {
125127
const r = compile(`<stat-card label={42} />`, manifest);
126128
expect(r.diagnostics).toEqual([
127129
{
128-
severity: 'warning',
130+
severity: 'error',
129131
code: 'type-mismatch',
130132
message: '<stat-card> prop "label" expected a string',
131133
tag: 'stat-card',

‎packages/sdui-parser/src/validate.ts‎

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,13 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va
331331
* down over the member kind `of` declares). The two copies must agree on the accepted
332332
* grammar AND on diagnostic codes/severities — if they drift, the save gate
333333
* and the renderer speak different dialects. Change these functions only
334-
* together with the objectui copy. */
334+
* together with the objectui copy.
335+
*
336+
* Known lead: this copy grades every `type-mismatch` and `member-type-mismatch`
337+
* `error` (a literal's coarse type is certain; see `checkType`). objectui's
338+
* copy, `packages/sdui-parser/src/validate.ts` at the `.objectui-sha` pin,
339+
* still grades them `error` only when an `enum` arm is present. Codes and
340+
* messages agree; the severity differs until objectui ports the same rule. */
335341

336342
/** The values an `enum` arm admits, flattened from either declaration form. */
337343
const enumValues = (input: ManifestInput): unknown[] =>
@@ -417,10 +423,14 @@ function memberEntries(value: unknown): Array<[string, unknown]> | null {
417423
* made once, and N copies of it is the noise this repo treats as the thing that
418424
* trains authors to dismiss real reports.
419425
*
420-
* Severity mirrors `checkType`'s rule for the same reason — `error` when an
421-
* `enum` arm is present, because a closed list is the one fact this layer can
422-
* be certain about; `warning` otherwise, since the coarse kind is a KIND claim
423-
* and `os validate` / `os build` remain the judge of values.
426+
* Severity mirrors `checkType`'s rule for the same reason, and is therefore
427+
* always `error`. Every member reaching this function belongs to a container
428+
* the parser materialized WHOLE: `interpretBrace` either reads the entire
429+
* braced value as a literal or keeps the entire value as one `{ $expr }`
430+
* marker (`["a", foo]` is one marker, never an array holding one), and a
431+
* marker never gets here. So a member is a literal too, its coarse kind is
432+
* final at compile time, and a member no declared arm accepts is as certain a
433+
* mismatch as a prop no arm accepts.
424434
*/
425435
function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
426436
const arms = inputTypeArms(input.of);
@@ -433,7 +443,7 @@ function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Di
433443
if (offenders.length === 0) return null;
434444
const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
435445
return {
436-
severity: arms.includes('enum') ? 'error' : 'warning',
446+
severity: 'error',
437447
code: 'member-type-mismatch',
438448
message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
439449
` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
@@ -454,16 +464,29 @@ function checkMemberTypes(tag: string, input: ManifestInput, value: unknown): Di
454464
* as legal, it does not turn the check off. Two properties of the reporting are
455465
* deliberate:
456466
*
457-
* - A single-arm input produces the byte-identical diagnostic it always did,
458-
* `invalid-enum` included. This change adds a form; it does not restate the
459-
* old one.
460-
* - A multi-arm input produces ONE diagnostic naming every arm, at the
461-
* STRICTEST arm's severity — `error` when an `enum` arm is present, because
462-
* an enum's closed list is the one fact this layer can be certain about, and
463-
* a value outside it should not become dismissible merely because a second
464-
* arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`)
465-
* since the reported fact is "fits none of the declared arms", and the
466-
* message carries the allowed values so the author still sees the list.
467+
* - A single-arm input produces the code and message it always did, and a
468+
* single `enum` arm's `invalid-enum` is byte-identical, severity included.
469+
* The union form adds a form; it does not restate the old one.
470+
* - A multi-arm input produces ONE diagnostic naming every arm. Its code is
471+
* `type-mismatch` (not `invalid-enum`) since the reported fact is "fits
472+
* none of the declared arms", and the message carries the allowed values so
473+
* the author still sees the list.
474+
*
475+
* Every `type-mismatch` is an `error`, because this layer reports only what it
476+
* can be certain about, and there are two certain facts here:
477+
*
478+
* 1. An `enum` arm's closed list. A value outside it should not become
479+
* dismissible merely because a second arm was added next to it.
480+
* 2. A LITERAL's coarse type. Only literals reach this function: a braced
481+
* value the parser could not materialize is the deferred `{ $expr }`
482+
* marker, which `validateTree` diverts to `inert-expression` (a warning)
483+
* before calling here. A quoted attribute is a string, a bare attribute is
484+
* `true`, and a materialized brace is exactly the literal written. So
485+
* `aggregate="count"` against an input declared `object` is final at
486+
* compile time: no expression stands between the source and the renderer,
487+
* and the tile that reads `aggregate.function` receives a string and
488+
* draws nothing. A warning there let `os build` stay green on a page that
489+
* renders nothing.
467490
*/
468491
function checkType(tag: string, input: ManifestInput, value: unknown): Diagnostic | null {
469492
const arms = inputTypeArms(input.type);
@@ -480,7 +503,7 @@ function checkType(tag: string, input: ManifestInput, value: unknown): Diagnosti
480503
}
481504

482505
return {
483-
severity: arms.includes('enum') ? 'error' : 'warning',
506+
severity: 'error',
484507
code: 'type-mismatch',
485508
message: `<${tag}> prop "${input.name}" expected ${arms
486509
.map((arm) => armExpectation(arm, input))

0 commit comments

Comments
 (0)