Skip to content

fix(app-shell): 唯一候选规则 — union 只剩一个成员读过值时,给出它的具名诊断 - #3693

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3678-sole-candidate-rule
Aug 7, 2026
Merged

fix(app-shell): 唯一候选规则 — union 只剩一个成员读过值时,给出它的具名诊断#3693
yinlianghui merged 1 commit into
mainfrom
claude/issue-3678-sole-candidate-rule

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3678

问题

PR #3677 把嵌套 union 的失败诊断按值的内容展开(config.columns),并刻意收窄:在 union 节点上直接拒绝了值的类型的成员,压根没读过值,内容就不能作为它的证据 —— 于是它不是候选。这条收窄是必要的,config.sort(string | ColumnSort[])就是它挡住的那个邻居。

收窄之后留下一个空档,就是本单:当这条范畴判定过后恰好只剩一个成员时,选它不是启发式。它是唯一读过这个值的成员,因此是唯一一个抱怨可能关于「作者写了什么」的成员;其余成员在类型上就停住了,展示它们等于描述一个作者从未选择的形状。

实测(@objectstack/spec,修前):

[invalid_union] path=["config","sort"] msg="Invalid input"
  member[0] (string)        [invalid_type]  path=[]           "expected string, received array"   ← 没读过值
  member[1] (ColumnSort[])  [invalid_value] path=[0,"order"]  "Invalid option: expected one of …" ← 唯一候选

spec 自己写的那句引导消息(#4001)就在下面一层,被丢掉了。

两条嵌套规则的关系(本单要求先测量再实现)

不是优先级关系,是同一个划分的两个不相交格子。k = 接受了值的类型的成员数(即范畴判定不成立的成员数):

k 谁说话 为什么
k === 1 唯一候选(#3678) 只有它读过值 —— 无歧义
k === 成员总数 内容判别(#3677) 全体都读过值,于是由值自身的内容决定;它进一步要求 union 真是「A 的数组 或 B 的数组」,否则弃权
其余(k === 0,或 k 在 2 与成员总数之间且两端不取) 无人 没有任何东西区分这些成员,#3677 已裁「不发明偏好」

k === 1k === 成员总数 只在单成员 union 上重合,而该 schema 不产生单成员 union;何况内容判别本就要求恰好两个成员。所以不存在两规则都想选的形状 —— 这正是派发单要求「先测量有无冲突形状」的那一步,测量结果见下面的普查表:12 个形状,每个只落进一个格子。

代码里两条规则并列而不是嵌套,顺序写成内容判别在前是刻意的:这样 #3677 的收窄条款仍然是「删掉它就红」的那一行(删掉后 sort: ['name'] 会被内容判别选中 string 成员,复现 #3677 测到的误选)。如果把范畴判定上提到划分函数里统一做,#3677 那行就成了死代码。

测量:每个嵌套 union 落在哪个格子

unionAt = 被测 union 节点,reportedAt = 诊断最终落在哪里。

形状 unionAt k 格子 修后 reportedAt
sort: [{field:'n',order:'bogus'}] config.sort 1 唯一候选 config.sort.0.order
sort: ['name'] config.sort 1 唯一候选 config.sort.0
sort: [42] config.sort 1 唯一候选 config.sort.0
sort: 42 config.sort 0 config.sort(不变)
columns: [{field:123}] config.columns 2/2 内容判别 config.columns.0.field(#3677 已有)
columns: ['a',42] config.columns 2/2 内容判别 config.columns.1(#3677 已有)
columns: [42] config.columns 2/2 内容判别弃权 config.columns(不变)
columns: 'nope' config.columns 0 config.columns(不变)
columns[0].summary: 'bogus' …0.summary 1 唯一候选 …0.summary(路径不变,消息变具名)
columns[0].summary: {type:'bogus'} …0.summary 2/2 内容判别弃权 …0.summary(不变)
filter[0].value: [{}] …0.value 1/5 唯一候选 config.filter.0.value.0
filter[0].value: {} …0.value 0/5 config.filter.0.value(不变)

一个被测量纠正的直觉:「规则说话了」不等于「消息变好了」,两个可观测量可以各自单独移动 ——

  • 路径动、消息不动:filter[].value: [{}] 下降到出错的那个元素,但元素自己的 union 被全体成员拒绝(k === 0),于是止步,Invalid input 就是仅剩能显示的东西。
  • 消息动、路径不动:summary: 'bogus' 选中的是 enum 成员,它的 issue 就在 union 节点上,于是地址不变,全部收益是「告诉作者有哪些选项」。

这条最初把普查断言写错了(只断言路径下降),跑出来才发现。现在断言写成析取,并单独钉了两个方向。

修前 / 修后(两条门逐一实测,逐字节相同)

body 修前 修后
sort: [{field:'n',order:'bogus'}] config.sort / Invalid input config.sort.0.order / Invalid option: expected one of "asc"|"desc"
sort: ['name'] config.sort / Invalid input config.sort.0 / expected object, received string
sort: [42] config.sort / Invalid input config.sort.0 / expected object, received number
sort: [{}] config.sort / Invalid input config.sort.0.field + config.sort.0.order(选中成员的全部 issue)
容器 list.sort: [{order:'bogus'}] list.sort / Invalid input list.sort.0.order / Invalid option …
columns[0].summary: 'bogus' config.columns.0.summary / Invalid input 同路径 / Invalid option: expected one of "none"|"count"|…
sections[0].fields: [{}] config.sections.0.fields.0 / Invalid input config.sections.0.fields.0.field / expected string, received undefined
filter[0].value: [{}] config.filter.0.value / Invalid input config.filter.0.value.0 / Invalid input(路径更近)
sort: 42columns: [42]columns: 'nope'summary: {type:'bogus'} 不变

⚠️#3677 既有测试的冲突:2 条 NARROWING 钉子被取代(派发单的「只加不改」在此不成立)

派发单要求「既有 38 条一行不改只加」。测量证明这条约束与本单授权的规则不相容,如实报告而不是绕开:

  1. does NOT answer for config.sort(sort: ['name'])—— issue metadata-admin:嵌套 union「只剩一个成员接受了值的类型」时仍塌成 Invalid input(config.sort 等) #3678 正文的对照表逐字要求这一行变成 config.sort.0 / expected object, received string
  2. does NOT answer for a filter value union(value: [{}])—— issue 的「范围」段落估计错了:它把 filter[].value 读成「值类型不匹配时全体拒绝」。对标量值确实如此(value: {} → k=0,不变),但对数组值,5 个成员里恰好数组成员接受,k === 1,唯一候选成立。

两条都按「fixture 三分诊」里的整体替换处理,并保留各自原本守的那个 claim:

  • sort 那条守的是「绝不能选中 string 成员」。新钉子仍然证明这一点(误选会读作 expected string, received array),并且直接加了这句负断言,所以它不会空过。fix(app-shell): expand the nested config.columns union into per-column diagnostics #3677 的收窄条款依然是删掉就红的那一行 —— 见下面的扰动 (c)。
  • filter 那条守的是「内容判别对五成员 union 不说话」。它依然不说话(内容判别要求恰好两个成员);说话的是另一条规则,注释里写清了这层区别。

可逆的杠杆:若维护者认为 filter[].value 不该被本规则触及,唯一的收窄方式是给唯一候选规则加 groups.length === 2 的元数限制。不建议:「唯一候选」与成员个数无关,为了保住一条旧钉子而加一条无原则的特例,正是本仓一贯拒绝的形状。但它是一行,随时可加。

逆向验证(先写预测,后运行;三个扰动)

预测全文落盘在实现之前。

扰动 (a) —— 还原实现(nestedUnionMemberIndex 退回只调内容判别)。预测:所有唯一候选锚点 + 两条重钉的 NARROWING 红;k === 0 / k >= 2 两条「维持」锚点与全部 parity 钉子两向皆绿(它们钉的是「不变」)。实际:

Tests  10 failed | 46 passed (56)
  × does NOT answer for `config.sort` …
  × does NOT answer for a filter value union …
  × CANARY: a bad `order` on a sort row …      (以及其余 6 条 #3678 锚点)

k === 0k >= 2 两条如预测未红 —— 如实注明:它们是防回归的那一半,不是「修前红修后绿」的那一半。

扰动 (b) —— 「验证验证器」,只扰动唯一性条款(把「恰好一个」改成「第一个接受的」)。预测方向不是「新锚点红」—— 内容判别先跑,columns 里它说得上话的形状不受影响,受影响的恰是它弃权的那些,于是红的应该是 #3677 自己的两条钉子。实际:

Tests  4 failed | 52 passed (56)
  × a first element that elects NEITHER variant keeps the union's own message   ← #3626 的
  × stops at the next union down, but addressed to it rather than to `columns`  ← #3626 的
  × k >= 2 — two members read the value, so this rule stays silent
  × every nested union shape lands in exactly ONE rule's cell — census

方向与预测一致。这证明唯一性条款守的是 #3677 的「不发明偏好」边界,而不只是它自己的锚点 —— 少了它,columns: [42] 会报 config.columns.0 / expected string, received number,summary: {type:'bogus'} 会报 enum 的 Invalid option

扰动 (c) —— #3677 的收窄条款是否变成死代码:删掉 if (groups.some(memberRejectedNodeType)) return null;。预测:sort: ['name'] 被内容判别选中 string 成员,重新报 expected string, received array,重新钉的 sort 测试红。实际:

Tests  2 failed | 54 passed (56)
  × does NOT answer for `config.sort` — one member never accepted the array
  × every nested union shape lands in exactly ONE rule's cell — census

该条款仍然是活的,没有因为本单变成死代码 —— 这也是把两条规则并列、而不是把范畴判定上提的原因。

判定奇偶

展开仍在 issue → SchemaFormIssue映射内部ok 判定之后、所有既有过滤器之后。判定不是「测出来没变」,而是结构上不可能变。照 #3624/#3677 的手法加了 8 条 parity pin,它们修前修后都绿 —— 这正是它们的意义,不是它们的弱点。

测试

clientValidation.viewDiagnostics.test.ts:新增 10 条(6 CANARY + 2 维持 + 1 普查 + 1 双向可观测量)+ 8 条 parity pin;改动 2 条(上述被取代的 NARROWING 钉子),其余 36 条一行未动。

vitest run packages/app-shell/src/views/metadata-admin/   →  Test Files 128 passed (128)
                                                             Tests 1225 passed | 1 skipped (1226)
pnpm --filter @object-ui/app-shell type-check             →  exit=0(tsc --noEmit + typetests)
eslint(两个改动文件)                                       →  clean
node scripts/check-control-bytes.mjs                      →  OK (3681 files)
node scripts/check-spec-symbol-derivation.mjs             →  OK

已知残留(不在本单范围)


Generated by Claude Code

… the value

PR #3677 narrowed its content rule so it could not speak for `config.sort`,
which left every union where some member rejected the value's type outright
collapsed to `Invalid input`. When exactly one member accepted the type,
naming it is a fact rather than a preference: it is the only member that read
the value, so it is the only one whose complaint can be about what the author
wrote.

`sort: [{field: 'n', order: 'bogus'}]` now reports `config.sort.0.order` with
the spec's own `expected one of "asc"|"desc"` instead of `config.sort` /
`Invalid input`. The same holds for a `columns[].summary` written as a bad
enum string, a form `sections[].fields[]` entry missing its `field`, and an
array `filter[].value` whose offending element is now addressed directly.

The two nested rules are disjoint rather than layered: censusing the members
by #3677's categorical test, k=1 is this rule, k=every-member is the content
rule, and everything else is nobody's. Order is unobservable while both guards
hold, and is written content-first so #3677's narrowing line stays the thing
that fails when removed.

Verdicts are untouched — this runs inside the issue mapping, downstream of
`ok`. Two of #3677's narrowing pins are superseded and re-pinned: they were
asserting that no rule spoke there, which is exactly what this changes.

Fixes #3678

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 7, 2026 9:42pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-C0qouk22.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 480.72KB 105.64KB
core (index.js) 2.96KB 1.13KB
create-plugin (index.js) 9.08KB 2.92KB
data-objectstack (index.js) 137.51KB 35.11KB
fields (index.js) 230.87KB 56.83KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.04KB 17.31KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 115.50KB 29.96KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 232.79KB 57.42KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 186.61KB 49.34KB
plugin-kanban (index.js) 48.30KB 13.28KB
plugin-list (index.js) 105.12KB 25.48KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

metadata-admin:嵌套 union「只剩一个成员接受了值的类型」时仍塌成 Invalid input(config.sort 等)

2 participants