Skip to content

fix(sdui-parser): port the union-arm coarse type check in lockstep with objectui - #12857

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-12814-union-arm-type-mismatch
Aug 28, 2026
Merged

fix(sdui-parser): port the union-arm coarse type check in lockstep with objectui#12857
os-zhuang merged 3 commits into
mainfrom
claude/issue-12814-union-arm-type-mismatch

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#12814

Clause-②: yes — this PR changes what this copy of the parser accepts and rejects for union-typed manifest inputs, so it carries the needs:contract-review tier. It is not a new contract decision: the underlying ruling is objectui#3832, already landed on the other copy, and #12719's invariant (both copies agree on the accepted grammar and on diagnostic codes) makes not porting the drift. Out of scope, staying where they are: item 1 (dashboard-widget-options) remains open on #12810, and the manifest wiring gap recorded on #12719 remains open and unowned — neither is addressed here.

What is ported (the objectui#3832 ruling, byte-equal)

ManifestInput.type now carries one coarse kind or an array of kinds when the key's contract is a union. validateTree's coarse check clears a prop when any declared arm accepts the value; when no arm accepts it emits onetype-mismatch naming every arm — error severity when an enum arm is present, warning otherwise. Single-arm inputs keep byte-identical diagnostics, invalid-enum included.

Ported regions are byte-equal to objectui packages/sdui-parser at objectui@38a123c (sha256-compared, and the comparator was first proved able to detect a disagreement by mutating one side — the mutated section hashed differently before the real comparison was trusted):

regionverdict
src/input-type.ts (new module: inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES)whole file byte-equal
src/codegen.tswhole file byte-equal
src/types.tsManifestInput blockbyte-equal
src/validate.tsenumValues/armAccepts/armExpectation/checkTypebyte-equal
src/index.tsRegistryConfigLike.type hunk + manifestFromConfigs bodybyte-equal

packages/sdui-parser/src/parse.ts is untouched; interpretBrace remains byte-identical across the copies (measured on #12810 — not re-derived here).

Behaviour delta

input declarationvaluebeforeafter
type: ['string','number']trueno diagnostic at all (fell through default: return null)one warningtype-mismatch: expected a string or a number
type: ['enum','object'], enum ['compact','detailed']"ultra"no diagnostic at allone errortype-mismatch: expected one of ["compact","detailed"] or an object; compile().ok flips to false
any single-arm declarationanyunchangedbyte-identical diagnostic (incl. single-enuminvalid-enum)
union codegen.d.ts typed a union input string (fell through)emits the TS union (string | number), deduplicated
manifestFromConfigs union config['string','number']coerced to 'string' (Set.has on an array is false)canonicalized: arrays survive, one-element arrays collapse, unknown arms dropped

The accept/reject change is latent in the production gate today (measured in this worktree): the tree has no sdui.manifest.json and the installed @objectstack/console ships no dist/ at all, so resolveSduiManifest() returns undefined and validateJsxPages runs parse-only — validateTree is unreached from os validate/os lint/os build. Landing before that wiring is the card's required sequencing; unit tests are the witness surface.

Interaction with objectui#6614 (open decision)

objectui#6614 (whether interpretBrace materializes a JS-literal subset) is still open and undecided — re-measured at dispatch time (2026-08-28; latest activity 2026-08-27T16:49:20Z). Measured reading of the dependency chain: interpretBrace either materializes strict JSON or produces the { $expr } marker (parse.ts); validateTree branches on isExpr(value)beforecheckType, so the arm check only ever sees materialized values; and every armAccepts predicate tests the runtime shape (typeof / Array.isArray / enum includes) with no reference to spelling. A #6614 ruling changes which values arrive materialized, not how any arm judges them — a value like {'foo'} materialized under Q1→A is judged identically to {"foo"} today. The port is stable under either outcome; the copies stay in lockstep because the deciding functions are byte-equal.

Tests and reverse controls

  • New pin file src/__tests__/union-arm-type-mismatch.test.ts (14 cases): any-arm acceptance, one-diagnostic-naming-every-arm (both severities), single-arm byte-identity, slot/off-vocabulary arms accept everything, manifestFromConfigs canonicalization, .d.ts union emission.
  • Ablation (fix committed first; restore via trap with absolute paths; mutation proved on disk by anchor counts 1→0/0→1 and blob hash change; restore proved by state: git diff HEAD empty, blob equal to HEAD): flipping the strictest-arm severity to always-warning turned exactly the predicted 1 test red / 38 green — the enum-arm-severity pin — with the failure shape being the severity flip. The suite resolves the subject via in-package relative src imports (no dist leg for this suite; dist was rebuilt afterwards for the consumer runs).
  • Consumer .d.ts reverse verification: a probe in packages/lint (the sole downstream importer — consumer direction, found by package.json scan) compiled the union form clean against the rebuilt dist/index.d.ts (0 probe errors), and an off-vocabulary arm drew TS2322 … not assignable to 'ManifestInputType | ManifestInputType[]' — proving the rebuilt types are what consumers read, in both directions.
  • Union at final head 59282399b (tree clean): @objectstack/sdui-parser test 39/39 + typecheck 0, @objectstack/lint test 2300/2300 + typecheck 0, full-repo pnpm lint VERDICT command-exit 0. All 21 path-derived gate families plus the test-file convention set (engine-double-contract, where-matcher, query-options-erasure, type-check-coverage, type-check-debt on the fully built closure, nul-bytes) exit 0 locally — except check-half-states, NOT MEASURED locally (PREREQUISITE NOT MET: no real GitHub credential in this container; CI runs it with one).

Refs: #12810 (parent measurement) · #12719 (the invariant) · objectui#3832 (the ruling) · objectui#6614 · ADR-0078 · ADR-0080.

Generated by Claude Code


Generated by Claude Code

…th objectui
Port objectui#3832 into this hoisted copy: checkType now checks every arm
a manifest input declares (inputTypeArms), clears the prop when any arm
accepts, and reports ONE type-mismatch naming every arm when none does,
at error severity when an enum arm is present. Single-arm inputs keep
byte-identical diagnostics. codegen and manifestFromConfigs read the
union form through the same input-type.ts module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PfaSTikked61BkcsB5Rn69
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

14 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 2 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 b489d3c725e86583410c20a91e59e0c5eb0ba255packageMentionDocs.

Which tree this was computed on

This run read content/docs from db878b8dfb7aa3068c55c84c840a6a4f61e753a1 — the merge of head 59282399bb6f7bbd9aaeec6b5a2d896fb540c687 into base b489d3c725e86583410c20a91e59e0c5eb0ba255, 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 db878b8dfb7aa3068c55c84c840a6a4f61e753a1 && git checkout db878b8dfb7aa3068c55c84c840a6a4f61e753a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b489d3c725e86583410c20a91e59e0c5eb0ba255 59282399bb6f7bbd9aaeec6b5a2d896fb540c687 && git checkout -B drift-repro b489d3c725e86583410c20a91e59e0c5eb0ba255 && git merge --no-ff 59282399bb6f7bbd9aaeec6b5a2d896fb540c687
node scripts/docs-audit/affected-docs.mjs --json b489d3c725e86583410c20a91e59e0c5eb0ba255

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

@os-zhuangClaude

Copy link
Copy Markdown
ContributorAuthor

PM 复核:ACCEPT#12814 / union-arm type-mismatch 端口(Clause ②)

head 59282399b,merge-base b489d3c72,7 文件 +493/−52。区分我自己测的转述的

一、Clause ② 的全部依据,我自己复算过了

这张卡说「不是新契约决定,是把 objectui#3832 的既有裁决搬到落后的那份 copy」。这个说法成立与否,全押在逐字节相等上,所以我自己比了一遍(比较器带阴性对照):

整文件层面

文件objectstack headobjectui@38a123c
input-type.ts8d9862fc…8d9862fc…✅ 相等
codegen.ts214cd557…214cd557…✅ 相等
parse.ts / types.ts / index.ts / validate.ts⚠️不同

⭐ 后四个整文件不同,恰恰是 PR 主张的形状——它只声称那四个文件的区域相等,没有把话说成整文件。比较器能区分(空文件 vs 真文件,哈希不同)。

区域层面 —— 真正决定接受/拒绝的四个函数(我用花括号配平抽取,抽取器带阴性对照:同一文件两个不同函数能区分):

函数osui
enumValues75af5542…75af5542…
armAcceptsd0acbfba…d0acbfba…
armExpectation65440ad7…65440ad7…
checkType70139485…70139485…

(一处诚实的不精确:enumValues 是箭头 const,配平抽了 33 行、可能越进下一段——但两侧抽的是同一段,相等结论仍成立,覆盖面比函数本身更宽。)

判接受/拒绝的那几个函数就是同一批字节。 你报告里说「无需偏离,所以 stop-and-report 的引信从未触发」——这句话的证明就是上面这张表。

并且我补了一个你已经做、但值得两侧独立成立的对照:参照点有没有漂移。

objectui 46f0bb4..38a123c -- packages/sdui-parser → 0 个提交
同一区间全仓 → 4 个提交(阳性对照,区间非空)
46f0bb4 是 38a123c 的祖先 → ✅(方向正确)

38a123c 的 parser 状态与 #12810 当初测的是同一个。这个 0 有对照撑着。

二、我 brief 的三条硬要求,逐条对账

  • needs:contract-review 双载体 ✅ 卡上原有,PR 上由你加并做了比较式读回(并发 labeler 的新增被保留,没被冲掉)。
  • 真 changeset,⛔ 不是 skip-changeset.changeset/sdui-parser-union-arm-type-mismatch.md,'@objectstack/sdui-parser': minor,frontmatter 非空、正文写清了行为变化。CI 的 Check Changeset 已绿。
  • objectui#6614 要给读数,⛔ 不许照抄卡上那句 "probably it is" ✅ 见下。

三、⭐ 关于 #6614,你做了两件我特别认可的事

① 你拒绝为我的读数背书。 报告原文:「The PM's label reading (needs-user-decision) I could not independently confirm — my payload extractor returned no label data — so that detail is relayed, not measured.」 ⭐ 完全正确,而且被你标为转述的恰恰是给的东西。我这边补上测量:MCP 读到 objectui#6614 标签为 needs-user-decision + domain:ui,状态 open,最后活动 2026-08-27T16:49:20Z。⇒ 现在它是两边都有出处的读数。

② 你真的去读了依赖链,而且读出了卡上那句话盖住的东西。 卡说「arm 检查与什么能 parse 正交,probably」。你的读数:validateTreecheckType之前就按 isExpr(value) 分叉,所以 arm 检查只见到已材料化的值;每个 armAccepts 谓词测的都是运行时形状(typeof / Array.isArray / enum includes),零处引用拼写。⇒ #6614 的裁决改变哪些值到得了,不改变arm 怎么判

⭐ 而你没有停在「所以正交」——你点名了一处真实交互:Q1→A 之下,今天画 inert-expression 的那些拼写会开始到达 arm 检查。然后正确地论证那属于语法变更本身、两份 copy 无论如何都要同步移动。这就是「读一个读数」和「照抄一句结论」的差别。

四、其它做对了的地方

五、我这边补的一项:文档面

docs-drift 机器人在本 PR 上说「无可列」并自己声明这不等于干净(按输入陈述规则的页面与发射器不共享标识符,它扫不到)。我独立读了一遍 content/docs/**:

type-mismatch 0 页 · invalid-enum 0 页 · ManifestInput 0 页 · sdui-parser 3 页
阳性对照 objectql = 58 页 · 阴性对照 = 0

那 3 页提 sdui-parser 的地方全是「保存时由它把受限 JSX 编译成树」的身份说明,没有一页陈述这次改动的粗类型检查规则或诊断码。⇒ 机器人的「无可列」在这一例里有独立读数支撑,⛔ 无文档动作待办。(content/docs/** 是本席禁区,以上全部只读。)


结论:ACCEPT。 待 32 项检查逐名全绿后转正式并挂自动合并。Check Changeset 已绿;现余 Test Core 若干分片、Dogfood (2/3)、三个 Type CheckLint & Repo Gates,零红。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 28, 2026 04:31
@os-zhuang
os-zhuang enabled auto-merge August 28, 2026 04:31
@os-zhuang
os-zhuang added this pull request to the merge queueAug 28, 2026
Merged via the queue into main with commit 4a9f461Aug 28, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-12814-union-arm-type-mismatch branch August 28, 2026 04:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationneeds:contract-reviewsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port the union-arm type-mismatch check into the hoisted sdui-parser — the half of the lockstep debt that CHANGES what this copy accepts and rejects

2 participants

@os-zhuang@claude