Skip to content

fix(sdui-parser): an html page literal of the wrong type is a compile error, not a warning - #21678

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21671-literal-type-mismatch-error
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-21671-literal-type-mismatch-error

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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

claude added 2 commits October 4, 2026 04:02
…h 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>
@github-actions github-actions Bot added the size/m label Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json eed2dee481126b6655cfb4a809099aa885c8e52b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 48a02bf296fdd99115fce63e7d13d274770d92d1 — the merge of head 79df1db84fb82c96586f224a63c40d7c065c71eb into base eed2dee481126b6655cfb4a809099aa885c8e52b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 48a02bf296fdd99115fce63e7d13d274770d92d1 && git checkout 48a02bf296fdd99115fce63e7d13d274770d92d1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eed2dee481126b6655cfb4a809099aa885c8e52b 79df1db84fb82c96586f224a63c40d7c065c71eb && git checkout -B drift-repro eed2dee481126b6655cfb4a809099aa885c8e52b && git merge --no-ff 79df1db84fb82c96586f224a63c40d7c065c71eb

node scripts/docs-audit/affected-docs.mjs --json eed2dee481126b6655cfb4a809099aa885c8e52b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 79df1db84fb82c96586f224a63c40d7c065c71eb
Local-runs: none

① Changeset .changeset/21671-html-literal-type-mismatch-error.md

  • Package @objectstack/sdui-parser — TRUE. The only package the diff touches (packages/sdui-parser/src/validate.ts + its tests); published (17.6.0 per the claim; no private: true).
  • Bump minor with the **BREAKING** banner, not patch — TRUE. A page source that compiled ok (so saved at the runtime gate and passed os build) is now refused: an accept-set narrowing at an authoring door. AGENTS.md step 3: (narrowing) is BREAKING; ADR-0087 "launch-window exemption" (amended [Decision] 两条裁决援引同一个 launch-window convention,却给出相反的 changeset 等级(minor vs major)—— 退役一个可写键到底发哪一级? #18003) and check-changeset-no-major.mjs: pre-GA a breaking change ships minor with the banner and the disposition as the carriers, major is refused. Precedent on origin/main with the identical shape: .changeset/21620-container-sibling-expansion-name.md (PR fix(metadata-protocol): the save door refuses a view container saved under a name another stored container of the same object expands to (#21620) #21637) and .changeset/21639-view-container-name-collision.md (PR fix(metadata-protocol): one collision predicate at the save door — a stored view container never takes a name already served from elsewhere (#21639, #21638) #21648), both minor + **BREAKING** + Clause-②: no (narrowing) + not-required (no-migration-prescription) for a save door refusing what it accepted. A patch would carry none of the breaking signals the gate reads and would misgrade the change.
  • Clause-② arm no (narrowing) — TRUE. no: the card widens no accept set and no public surface (no export, type, code or message changes; one severity constant per function). (narrowing): the accepted set of html page sources shrinks. breakingDeclaration() reads signals (2) banner and (4) narrowing arm from the changeset body, so the disposition is required and present.
  • ADR-0087 disposition not-required (no-migration-prescription) — TRUE. No authorable key, declaration, export or stored shape is removed, renamed or re-shaped; the refused values live inside a page source string that no ledger entry reads; the other categories are closed in the comment on facts (published, no registry id, not runtime-interface-only / type-surface-only). The body carries no X → Y rewrite (no arrow between code operands, REWRITE_RE), so the category is not self-contradicted; "write the value in the type the input declares" is authoring guidance for a value no conversion can supply, not a FROM → TO prescription. Same category as the two precedents above.
  • Wording for authors — TRUE. Summary line, "What changed", "Why" and "What an author now sees" match the code: validateTree sends $expr markers to inert-expression (warning) and only literals reach checkType; a bare attribute is true (parse.ts:151); os build refusal was probed by the dev (exit 1); os validate / os lint resolve the same manifest gate (packages/cli/src/commands/validate.ts, lint.ts via resolveJsxGateManifest); the save door (runtime-authoring-gate.ts:620–631) maps error severity and returns findings when !result.ok. Example inputs (aggregate, limit, invert, filter, fields) are real sdui.manifest.json inputs of the stated types. One minor nit, not blocking: "instead of compiling with a warning" describes the newly refused class; the enum-arm case already errored, which the body makes clear.
  • PR body Clause-②: no (bare) vs changeset no (narrowing) — the mismatch is NOT acceptable as a final state; the body must carry the arm. AGENTS.md step 3 defines ONE declaration line that the changeset body "also carries", and check-changeset-no-major.mjs reads the PR body as its ONE carrier: with bare no its level axis returns not-declared (stands down), so the PR as declared tells the level gate nothing about its breaking-ness, while the changeset tells the ADR-0087 gate the opposite. Both precedents (fix(metadata-protocol): the save door refuses a view container saved under a name another stored container of the same object expands to (#21620) #21637, fix(metadata-protocol): one collision predicate at the save door — a stored view container never takes a name already served from elsewhere (#21639, #21638) #21648) carried Clause-②: no (narrowing) in body and changeset alike. The dev copied the claim's line verbatim as .claude/agents/os-dev.md requires, so the discrepancy originates in the PM claim (5976312374), not in the diff. Required before enqueue (body-only edit by the seat; the head is unchanged, so this record stands): set the PR body line to Clause-②: no (narrowing). Dev open question 1: option A.

② Code vs. claims

  • validate.ts header matches the implemented behaviour — TRUE. checkType (head :491–:513) and checkMemberTypes (:435–:453) now return severity: 'error' unconditionally; the restated headers say exactly that and give the reason (two certain facts: enum closed list, literal coarse type).
  • Only literals reach checkType / checkMemberTypes — TRUE, confirmed in validateTree (head :233–:290): if (isExpr(value)) pushes the inert-expression warning; checkType runs only in the else branch, and checkMemberTypes only when checkType returned null (container kind accepted). A braced container holding an expression is one $expr marker (pinned: filter={["status", status]} → one inert-expression, ok === true). Expressions therefore never see type-mismatch; the triage's "expressions stay warning" holds by construction, and a test pins it.
  • invalid-enum byte-identical — TRUE. The single-enum-arm block (:496–:503) is outside the diff: same severity: 'error', same code, same message template; the existing pin (union-arm-type-mismatch.test.ts:138–148) is untouched. The header's wording change ("byte-identical" now scoped to invalid-enum) is accurate since the single-string-arm type-mismatch did change severity.
  • LOCKSTEP header note about objectui — TRUE. .objectui-sha is ab187972… at origin/main, at the claim ref and at head (unchanged). objectui packages/sdui-parser/src/validate.ts at that commit still has severity: arms.includes('enum') ? 'error' : 'warning' at :418 (member-type-mismatch) and :465 (type-mismatch), exactly as the report states; codes agree. scripts/check-sdui-lockstep.mjs does not compare severity (the word appears only in its self-test fixtures). No objectui path in the diff; the lead is declared in the header and PR body only, as the claim required.
  • Test edits are severity-only — TRUE. The three existing pins changed warning→error, ok true→false, and the prose that called the string-arm case "byte-identical"; codes and messages unchanged.

③ Scope vs. the triage ruling

  • Within the ruling's certainty argument — TRUE. The triage's operative sentence is "a literal's coarse type … is final at compile time, so it is certain in the same way the enum arm is. It becomes error." That argument does not distinguish a string literal on an object input from a number literal on a string input (label={42}): both are literals whose coarse type no declared arm accepts, and measurement shows every value at checkType is a literal. The title's "string literal against a non-string input" is the motivating instance, not a stated limit.
  • Within the claim's file surface — TRUE. The PM claim (5976312374) states the rule as "A literal whose coarse type no declared arm accepts becomes error" (general form), forbids a new code or gate (honoured: code type-mismatch, message unchanged), and explicitly delegates checkMemberTypes to "the dev's measured call, reported either way" — reported with the measurement (interpretBrace all-or-nothing, so every member is a literal). No triage call is needed; the generalisation relative to the triage title is recorded here as the reviewer's answer to dev open question 2: option A (keep the general rule).
  • Pins as the triage listed — TRUE, in literal-type-mismatch-error.test.ts: aggregate="count" → exactly one error type-mismatch with the stated message and ok === false; aggregate={{"function":"count"}} → zero diagnostics, ok === true; an expression on an object input → one warning inert-expression, no type-mismatch, ok === true. Extra pins: string literal on number / boolean / array inputs, container-with-expression. Inputs copied from sdui.manifest.json (verified: object-metric aggregate: object, invert: boolean, filter: array; object-kanban limit: number). "os build fails on the first pin" was a probe (exit 1, with an ablation before-leg), which is what the claim asked for ("checks"), not a committed test.
  • Census-first — TRUE. Seven rows in the PR body, compiled (not grepped) against the committed manifest, including the interpolating capability-map.page.ts, hotcrm (no html page), skills/docs fenced blocks, and a positive control; zero writers, so no example or skill edits, consistent with PR docs(skills): the html-tier page example spells object-metric's aggregate in the object form #21667 having landed first as the triage required.

Implemented-by: claude/issue-21671-literal-type-mismatch-error
Reviewed-by: session_01VDtqoecgES7ScQYGbFVDRv

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

1 participant