Skip to content

未知键的「你是不是想写」兜底对 camelCase 键系统性偏弱:输入被小写化、候选没有 —— 每个大写字母白扣一次编辑距离 #4990

Description

@xuyushun441-sys

从 #4001 批 13 拆出(发现,不是本批修复范围)。影响 #4001 整个战役已经落地的每一条未知键错误信息的兜底建议质量。

缺陷

packages/spec/src/shared/suggestions.zod.ts 的 findClosestMatches():

const normalized = input.toLowerCase().replace(/[-\s]/g, '_');

const scored = candidates
  .map((candidate) => ({
    value: candidate,
    distance: levenshteinDistance(normalized, candidate),
  }))

输入被小写化,候选没有。 于是候选键里的每一个大写字母都要额外付一次替换代价。而 strictUnknownKeyError 给的预算是长度相对的 Math.max(2, Math.floor(key.length / 3)) —— 对短键就是 2。两者一叠加,短的 camelCase 键上一个再普通不过的笔误就够不着了。

按 AGENTS.md Prime Directive #3,「TS config keys → camelCase」是全仓约定,所以受影响的是可授权面的大多数键。

实测(origin/main fe83042)

输入            预算   findClosestMatches 结果
hideOn          2      []            ← hiddenOn,真实距离 2,加大写罚分后 3,超预算
hiddenon        2      ["hiddenOn"]  ← 同一个词全小写反而给得出建议
hiddenOnn       3      ["hiddenOn"]
maxLenght       3      ["maxLength"]

第二行是这个缺陷的完整刻画:把键写成全小写的作者,拿到的建议比写对了大小写、只错一两个字母的作者更好。

暴露面是「真实距离 + 候选里的大写字母数 超过预算」的那一类,集中在较短的 camelCase 键上。hideOn → hiddenOn 就是标准样本:一个语义完全正确、只是少了个词尾的键,得到的信息里没有任何指向正确拼法的内容 —— 而这一战役的整个卖点,恰恰是「不只是大声,而是可修」。

建议修法

在同一处对候选做同样的归一化,并且只用于打分、回显仍用候选的原始拼写:

const norm = (s: string) => s.toLowerCase().replace(/[-\s]/g, '_');
const normalized = norm(input);
const scored = candidates
  .map((candidate) => ({ value: candidate, distance: levenshteinDistance(normalized, norm(candidate)) }))

注意两点:

  1. data/object.zod.ts 的 suggestKey 是同一套长度相对上界的另一处实现,要一起检查是否同病。
  2. 这会让一批原本没有建议的错误开始给建议,也可能改变个别已有建议的选中项 —— 所以它会动到 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 已落地各批的错误信息文案,那些批次里有直接断言 message 内容的 pin 测试(如 webhook.test.ts / etl.test.ts / control-flow 系列)。属于横切改动,应独立成 PR 并跑全 spec 套件,不适合搭在某一批收紧上。
  3. 修的时候顺便加一条自证测试:同一个键的全小写形式不应比正确大小写形式得到更好的建议。这条不变量是本 issue 的实质。

现状缓解

#4001 批 13 在 ui/responsive.zod.ts 里为它自己遇到的那一例写了显式 alias(hideOn: 'hiddenOn'),并在注释里记了测量值。那是逐例绕行,不是修复 —— 每个 schema 都要为兜底够不着的短 camelCase 键手写 alias,正是 strictObject 引入时想消掉的那种重复。

相关

按 AGENTS.md Prime Directive #10 归档,未指派。

Activity

  1. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    Blocked-by: #4971

    排程记录(会话 session_01FTszibd6C8sUCCZnM4VcrL,C 包 #5163 接收方,PM 循环第 1 轮):本单与 #4971 严格串行,已挂 pm:blocked,#4971 的 PR 合入 main 后解锁派发。已知雷区,派发时写进派发词:

    1. 同文件冲突:formatZodError 把 union 分支的拒绝信息压成 "Invalid input" —— #4001 策展的散文在 CLI 路径上到不了作者 #4971 正在翻转 packages/spec/src/automation/state-machine.test.ts 的 CONTROL pin;本单的建议算法改动会波及断言建议文案的 pin(webhook.test.ts / etl.test.ts / control-flow 系列),两单同时在飞会在错误散文断言上互踩。
    2. 正文自陈的纪律:横切改动,独立成 PR,跑全 spec 套件;不搭任何收紧批次。
    3. 必加不变量测试(issue 实质):同一键的全小写形式不得比正确大小写形式得到更好建议。
    4. 顺带排查 data/object.zod.ts 的 suggestKey 是否同病;ui/responsive.zod.ts 里批 13 的 hideOn 显式 alias 是逐例绕行,修复落地后评估是否移除(若移除,注意其注释里记的测量值要保进测试)。
    5. 触 zod 文件,已在 C 包 program card:协议工具链/门禁车道(domain:spec-tooling)—— 整包移交待接收 #5163 卡上向 spec 车道做过协调声明(2026-08-04),无需重复留言。

    Generated by Claude Code

  2. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    解锁:pm:blocked 已摘除,Blocked-by: #4971 条件满足

    会话 session_01FTszibd6C8sUCCZnM4VcrL。#4971 的 PR #5342 已于刚才经合并队列合入 main(546ab3c49),两单在 packages/spec/src/automation/state-machine.test.ts 上的冲突面解除,本单恢复可派。

    排期:本单是横切改动(会动 #4001 各批已落地的错误信息文案,webhook.test.ts / etl.test.ts / control-flow 系列等断言 message 内容的 pin 都要跟改),属 build-heavy;当前容量已满(#4912 的 os-regen 同步 + #5269 + #5063 三单在飞,其中同步单同样 build-heavy)。不与另一个 build-heavy 单并发,等空槽即派 —— 这是资源纪律,不是优先级下调:本单是 C 包升级体验 4 单里的第 2 单,#5163 卡片给的顺序就是它。

    派发时写进派发词的要点(此前记录的雷区依然有效):

    1. 正文自陈的纪律:横切改动、独立成 PR、跑全 spec 套件,不搭任何收紧批次。
    2. 必加不变量测试(本单实质):同一键的全小写形式不得比正确大小写形式得到更好建议。
    3. 顺带排查 data/object.zod.ts 的 suggestKey 是否同病(同一套长度相对上界的另一处实现)。
    4. ui/responsive.zod.ts 里批 13 为 hideOn → hiddenOn 写的显式 alias 是逐例绕行;修复落地后评估是否移除,若移除,注意其注释里记的测量值要保进测试。
    5. ⚠️ 新增读数陷阱(本会话今日两次踩到,AREA_REQUIRED_PERMISSIONS_RETIRED 的处方在 #4722 之后过时:仍写着「the server does not walk areas」 #4749 / protocol-17 的 migration rationale 仍写着「the server does not walk areas」,并投影进生成的升级指南 #5337 各一次):packages/spec 的处方与建议文案一律是跨行拼接的字符串字面量,行式 git grep 看不见跨行短语,零命中不等于不存在。核验一律用邻近词反查,或直接对运行期组装后的字符串断言。
    6. 触 zod 文件,已在 C 包 program card:协议工具链/门禁车道(domain:spec-tooling)—— 整包移交待接收 #5163 卡上向 spec 车道做过协调声明,无需重复留言。

    Generated by Claude Code

  3. self-assigned this
    on Aug 5, 2026
  4. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    认领:PM 循环第 2 轮(C 包 #5163 升级体验第 2 单)
    会话:session_01FTszibd6C8sUCCZnM4VcrL
    分支:claude/issue-4990-camelcase-suggestions
    Worktree:objectstack-issue-4990
    域:domain:spec-tooling
    文件面:packages/spec/src/shared/suggestions.zod.ts(打分归一化)、packages/spec/src/data/object.zod.ts(suggestKey 同病排查)、因建议文案变化而变红的 pin 测试(webhook.test.ts / etl.test.ts / control-flow 系列等)、.changeset/*。⛔ 不碰 shared/error-map.zod.ts(#4971 刚落地)、⛔ 不碰 ui/app.zod.ts(#4749 刚落地)、⛔ 不碰 packages/spec/scripts/**(#4912 在飞)。

    Blocked-by: #4971 已解除(PR #5342 于 546ab3c49 合入 main)。容量已腾出(#4912 的 os-regen 同步收工),按 #5163 卡片顺序派发。


    Generated by Claude Code

  5. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    PM 复核(第 2 轮):ACCEPT → PR #5363

    会话 session_01FTszibd6C8sUCCZnM4VcrL。

    ⚠️ 先记一条:我在派发词里点名要求的那条测试是空转的,dev 证伪了它

    我把 issue 的原话当成硬要求写进了派发词 ——「同一个键的全小写形式不应比正确大小写形式得到更好的建议」,并要求「写成在今天的代码上会失败」。dev 照做后发现它在旧代码上不可能失败。PM 独立核实:

    $ git show origin/main:packages/spec/src/shared/suggestions.zod.ts | grep -n toLowerCase
    70:  const normalized = input.toLowerCase().replace(/[-\s]/g, '_');
    

    旧实现第一步就把输入小写化,于是 suggest(T) 与 suggest(T.toLowerCase()) 坍缩成同一次调用,462 个探针上「全部通过」,而缺陷完好无损。

    真正的不对称不在输入的两种拼法之间,而在声明键的两种拼法之间:同一个笔误,对着 hiddenOn 判一个结果、对着 hiddenon 判另一个,因为只有候选保留了大写、每个大写都向作者收费 —— 这才是标题里「对 camelCase 键系统性偏弱」的机制。dev 把不变量改成钉这个,旧代码上 462 例破坏 55 例,且双向破坏(bordeRradius 对着驼峰 borderRadius 能解析、对着扁平 borderradius 反而不能,大写恰好帮了忙)。

    这条更正比原测试有价值得多,记在这里免得下一个读本 issue 的人照抄那句空转的措辞。

    零 pin 变红 —— 而它没有把这当成通过

    我在派发词里的硬要求是「⛔ 不许把变红的 pin 批量改成新输出,每条都要判断更好还是更差」。实际零条 pin 变红。dev 没有就此宣布安全,而是把这件事当成可疑并另造了量化证据:在全部 325 组真实候选集(递归采集 src/index 每个 ZodObject 的 shape)上生成 16734 个 camelCase 笔误探针逐个新旧对比:

    数量
    结果不变 16374
    新增建议 329(正确 328 / 错误 1)
    失去建议 0
    改变选中项 31(更准 30 / 变差 1)

    两处代价如实点名,且论证为折叠后的真实平局而非排序退化(yxAis 两项指标双双打平由声明序决定;mxaRows → minRows 是「原本没有任何建议」处新增的一个错提示)。「零 pin 变红」在一个横切文案改动上本该令人不安,把它转成 16734 次实测,是比我要求的更强的形式。

    顺带修掉一个 issue 未记录的同源缺陷

    distance > 0 过滤器的本意是「别把作者写过的字符串原样回显」,但因为输入被小写化,maxLength 这种写对了的键与候选距离是 1 而不是 0,过滤器根本没拦住 —— 旧实现会把作者写对的键原样回显成「你是不是想写 maxLength」。这是反向验证时才浮出来的。

    反向验证:预测 2 红实得 4 红,如实记录偏差

    dev 事先声明方向,实测多出两条:一条是上述未记录的回显缺陷,另一条是它自己第一版那个空转的不变量。把预测落空写出来而不是事后改预测,这一点是对的。

    两处「同病」排查按要求做了

    • data/object.zod.ts 的 suggestKey —— 不同病,PM 核实 1816 行本就 editDistance(unknown.toLowerCase(), key.toLowerCase()),两侧都折叠。已补测试锁定该性质,防两个 suggester 日后分叉。
    • 批 13 的逐例 alias hideOn: 'hiddenOn' —— 已退役(现靠距离本身即可解析),且按我的要求把注释里的实测值转为断言存进 responsive.test.ts 而非删掉;hidden: 'hiddenOn' 保留,理由正确:它是另一个词,任何编辑距离都够不着。

    消费半径

    按 PR #5046 的教训外扩:packages/lint 三个 write-validator(57 files / 1194 tests 全绿)、service-automation 断言 spec 报错文案(149 tests 全绿)。spec 全套 308 files / 7903 tests。

    生成物污染

    authorable-surface.base.json 被 pnpm build 的 gen:schema 改写,dev 提交前逐文件读 git status 发现并还原,未混入本 PR;且没有另开单,而是到 #5358 补了独立复现数据 —— 并补充了两点:它经 pnpm build 触发(不止 #5358 记的 --check 路径),且因为「构建 spec 是跑任何下游包测试的前置」,污染发生在一个正常且被鼓励的工作流步骤里。查重纪律与信息增量都做对了。

    CI 全绿后入队。这是本会话的最后一单。


    Generated by Claude Code

  6. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    Contributor

    已落地 —— PR #5363 经合并队列合入 main(b4ad98435)

    会话 session_01FTszibd6C8sUCCZnM4VcrL。落地判据复核:

    $ git show origin/main:packages/spec/src/shared/suggestions.zod.ts | sed -n '68p'
    const foldForScoring = (value: string): string => value.toLowerCase().replace(/[-\s]/g, '_');
                                            # 两侧折叠,回显仍用候选原始拼写
    
    $ git grep -c "hideOn: 'hiddenOn'" origin/main -- packages/spec/src/ui/responsive.zod.ts
    0                                       # 批 13 的逐例 alias 已退役
    
    $ git grep -c "hideOn\|hiddenon" origin/main -- packages/spec/src/ui/responsive.test.ts
    8                                       # 其注释里的实测值已按要求转为断言,未删除
    

    三处都对。C 包升级体验 4 单收官(#4971 / #4749 / #4912 / #4990 全部落地)。

    本单最值得留给后来者的一条,重申一遍:issue 正文那句自证测试的措辞是空转的 —— 比较 suggest(T) 与 suggest(T.toLowerCase()) 在旧代码上不可能失败,因为旧实现第一步就把输入小写化。真正的不对称在声明键的两种拼法之间,不在输入之间。照抄那句措辞会得到一个全绿而无用的测试。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions