Skip to content

Commit a4fd82a

Browse files
fix(sdui-parser): an html page literal of the wrong type is a compile error, not a warning (#21678)
Fixes #21671 Clause-②: no (narrowing) ## What changed The html-tier compiler (`@objectstack/sdui-parser` `compile`) now grades every `type-mismatch` as `error`, not only the ones whose input declares an `enum` arm. `aggregate="count"` on an `object-metric` (repo-root `sdui.manifest.json` declares `aggregate` as `type: "object"`) now fails the compile, so `os build` fails on it. Before, it compiled `ok` with one warning, the build stayed green, and the tile drew no number. The diagnostic code and message are unchanged. No new code and no new gate. ## The mechanism, measured The brief assumed that literals and expressions both reach `checkType`, and that a signal would have to be passed in to tell them apart. Measured on `93a54b87e7`, that is not how it works: - `validateTree` (`packages/sdui-parser/src/validate.ts`) sends a value that `isExpr` matches (the parser's deferred `$expr` marker) to `inert-expression`, which is a `warning`. It calls `checkType` only in the `else` branch. So **every value that reaches `checkType` is already a literal**: a quoted attribute (a string), a bare attribute (`true`), or a braced value that `interpretBrace` materialized in full. - `interpretBrace` is all-or-nothing. Probed: `["a", foo]`, `{"a": foo}` and `{a: 1, b: x.y}` each become ONE `$expr` marker; `[1,2]` and `{function: "count"}` materialize in full. So a container never reaches the type check with an expression inside it. So the expression case is already separate before `checkType` runs, and no new signal is needed. The change is the severity, plus a restated header that names the second certain fact (a literal's coarse type) next to the enum's closed list. A braced expression still gets the `inert-expression` warning, and a test pins that. **`checkMemberTypes` follows the same rule** (the brief left this to measurement). Its members come from a container that was materialized in full, so each member is a literal too, and a member that no declared arm accepts is just as certain a mismatch. Its header already said "Severity mirrors `checkType`'s rule", and that stays true. `member-type-mismatch` is now `error`. **Unchanged on purpose:** the single-arm `invalid-enum` diagnostic is byte-identical, severity included (the existing pin in `union-arm-type-mismatch.test.ts` passes untouched). ## Census (first step): every stored html page source, compiled against the committed `sdui.manifest.json` It was run with `compile` from `@objectstack/sdui-parser` (source), not with grep. The page modules were imported and each `kind: 'html'` export's `source` was compiled (`capability-map.page.ts` interpolates, so a regex would have read a different string). Every fenced block in `skills/**/*.md` and `content/docs/ui/*.mdx` that has a lowercase tag was compiled too. A positive control (`aggregate="count"`) was compiled in the same run. | source | literal handed to a non-string input | verdict | |---|---|---| | `examples/app-showcase/src/ui/pages/command-center-jsx.page.ts` (`CommandCenterJsxPage`) | none (0 diagnostics) | clean, nothing to fix | | `examples/app-showcase/src/ui/pages/capability-map.page.ts` (`CapabilityMapPage`) | none (0 diagnostics) | clean, nothing to fix | | `examples/app-showcase/src/ui/pages/start-here.page.ts` (`StartHerePage`) | none (0 diagnostics) | clean, nothing to fix | | `skills/objectstack-ui/rules/pages.md:154` block (its line 160 is the `object-metric` example) | none: line 160 reads `aggregate={{"function":"count"}}`, so PR #21667's fix is confirmed on `origin/main` | clean, nothing to fix | | hotcrm | no `kind: 'html'` page in this repo (both `packages/metadata/src/__fixtures__/hotcrm-*.artifact.json` have 0) | not applicable | | other fenced blocks in `skills/**` and `content/docs/ui/**` | React-tier or non-page code (each fails at `no-root` or `forbidden-tag`, which shows they are not html-tier sources) | not applicable | | control: `aggregate="count"` | before: `ok=true`, `[warning] type-mismatch`; after: `ok=false`, `[error] type-mismatch` | the census can see the case | There are zero writers to fix, so no example or skill file changes in this PR. ## Pins (`packages/sdui-parser/src/__tests__/literal-type-mismatch-error.test.ts`) The inputs are copied verbatim from the tracked `sdui.manifest.json` and written inline, so the test reads nothing outside its package. - `aggregate="count"`: exactly one `{ severity: 'error', code: 'type-mismatch', message: 'object-metric prop "aggregate" expected an object' }` (the real message has the tag in angle brackets), and `ok === false`. - `aggregate={{"function":"count"}}`: zero diagnostics, `ok === true`. - A string literal on a `number` (`object-kanban` `limit`), a `boolean` (`invert`) and an `array` (`filter`) input: each gives one `error` `type-mismatch`, and `ok === false`. - An expression handed to an object input (`aggregate={count}`), and a container that holds an expression: each gives exactly one `warning` `inert-expression`, no `type-mismatch`, and `ok === true`. Three existing pins described the old rule, and they were updated: the non-enum union case and the single string-arm case in `union-arm-type-mismatch.test.ts`, and the member severity in `member-type-mismatch.test.ts`. Their codes and messages are unchanged. Only severity, `ok`, and the wording that called these "byte-identical" were edited. ## `os build` probe (the html-tier path through `packages/cli/src/utils/sdui-manifest.ts`) A scratch project with one `kind: 'html'` page, the committed `sdui.manifest.json` copied beside its config, and the CLI run from source (`bin/run-dev.js build`): | page source | before (severity reverted, rebuilt) | after (this PR) | |---|---|---| | `aggregate="count"` | exit 0, a warning that the `aggregate` prop expected an object, `Build complete` | **exit 1**, `Author-time rules failed (1 issue)`, a failure that the `aggregate` prop expected an object | | `aggregate={{"function":"count"}}` | not run | exit 0, `Build complete` | The before leg is a one-off ablation run from the committed fix. It used `scripts/ablation-replace.mjs` (anchor hit, 1 marker on disk), then `pnpm --filter @objectstack/sdui-parser build`, then `ablation-dist-preflight.mjs` (marker present in `dist/`, exit 0). After the probe it was restored with `git checkout HEAD --`: `git diff HEAD` was empty and the blob matched HEAD (`86cc5784`). The package was rebuilt, the `--absent` preflight passed for both readings, and the probe was re-run with exit 1. No permanent test file was left behind. ## Tests run (at `79df1db84f`) - `pnpm --filter @objectstack/sdui-parser test`: 14 files, 225 tests passed. `typecheck`: exit 0. - Downstream consumers, after rebuilding `sdui-parser` (`dist` checked: 0 copies of the old ternary left). `pnpm --filter @objectstack/lint exec vitest run`: 119 files, 5627 tests passed. `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 209 files passed and 3 skipped, 3463 tests passed. CLI unit tier, limited to the 4 files that compile html pages (`src/utils/sdui-manifest.test.ts`, `test/validate-build-gate-parity.test.ts`, `test/platform-page-i18n-parity.test.ts`, `test/i18n-section-coverage.test.ts`): 129 tests passed. The rest of the CLI unit tier and its integration tier are left to CI. - `node scripts/pm/dispatch-gates.mjs --commands` gave 63 derived commands. 61 exited 0, including `check:sdui-lockstep`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check-adr-0087-registration` and `check-changeset-no-major`. **NOT MEASURED: `check:dual-build-cjs-loads`**: `PREREQUISITE NOT MET`, because `embedder-openai` and `service-cluster-redis` have no `dist/` (packages this diff does not touch). **NOT MEASURED: `check:type-check-debt`**: it is a whole-tree tsc ratchet and hit the 240s local timeout. CI runs both. - eslint was run on only the 4 changed `.ts` files, with `--no-inline-config --format json`: 4 files, 0 errors, 0 warnings. The repo config has no `parserOptions.project` (type-aware linting is off), so this diff cannot change the lint result of any file it does not touch. CI runs the full `pnpm lint`. ## Changeset `.changeset/21671-html-literal-type-mismatch-error.md`: `@objectstack/sdui-parser` `minor`. **Clause-② conflict for the seat to resolve:** the claim states `Clause-②: no`, and this body carries that line verbatim. The changeset declares `Clause-②: no (narrowing)`, because a page that used to compile (and save, where the host has a component manifest) is now refused. Earlier PRs treated a new refusal at an authoring door as an accept-set narrowing (`21459`, `20827`). The changeset therefore carries the `**BREAKING**` header, a `minor` bump under the launch-window convention, and an ADR-0087 `not-required (no-migration-prescription)` disposition: no key, declaration or stored shape moves. `check-adr-0087-registration` and `check-changeset-no-major` both pass, whether the body line has the arm or not (both were simulated locally). ## objectui lockstep (declared, not acted on) objectui has its own copy of this validator, `packages/sdui-parser/src/validate.ts`, at the `.objectui-sha` pin `ab187972`. It still has the old ternary in both `checkMemberTypes` (`:418`) and `checkType` (`:465`). After this PR the two copies agree on codes, messages and the accepted grammar, and differ only in **severity**. `check:sdui-lockstep` compares grammar, codes and the containment predicate, not severity, so it passes. This repo's copy is the stricter one (save gate and `os build`). The dangerous direction, a page that saves clean and then renders inert, cannot come from this difference. The lockstep header in `validate.ts` now records this lead. The port belongs to objectui's lane, and this PR does not write to objectui. ## Acceptance notes - The new severity covers every literal mismatch, including a number literal handed to a string input (`label={42}`). That was the narrower reading in the triage title ("a string literal against a declared non-string input"). The claim and the brief specify the general rule ("a literal whose coarse type no declared arm accepts"), and the measurement shows that every value at this point is a literal, so the general rule is the one implemented. The census found no writers of either form in the repo. --- _Generated by [Claude Code](https://claude.ai/code/session_01VDtqoecgES7ScQYGbFVDRv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d07814 commit a4fd82a

5 files changed

Lines changed: 172 additions & 30 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/sdui-parser': minor
3+
---
4+
5+
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.
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- 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). -->
10+
11+
**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.
12+
13+
**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.
14+
15+
**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.
16+
17+
**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.
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)