Skip to content

feat(devx): 给 @objectstack/verify 结构替身的驱动实参加护栏 (#6399) - #6525

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6399-as-never-guardrail
Aug 8, 2026
Merged

feat(devx): 给 @objectstack/verify 结构替身的驱动实参加护栏 (#6399)#6525
hotlong merged 1 commit into
mainfrom
claude/issue-6399-as-never-guardrail

Conversation

@hotlong

@hotlong hotlong commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #6399

本单锁的不是「删掉 10 个 cast」——PR #6396 已经做完,并且证明了编译期检查是活的。锁的是这个状态没有被锁住:明天任何人把这 10 个原样加回来、或在新调用点写第 11 个,没有一道门会响。

⛔ 未并 #6394domain:driversdriver.create 的 options 门,下标 1)。本 PR 的 self-test 显式钉住那个位置在本规则范围之外

一、先测量,再选型

立单正文列了三条方向且明确未预断。方向由实测裁定,不由谁复述过。

全仓 as never 分布(实测)

先纠一个计数陷阱:git grep 'as never'1122,其中含 has never 这类英文散文(h-as never)。加词边界后是 693 行;用 TypeScript AST 数 AsExpression 节点(跨行调用会多于行数)得 703 处,分布 215 个文件。

语法位置 处数 测试文件 非测试
调用实参位 550 536 14
对象字面量属性值 93 92 1
变量初值 25 24 1
嵌套在另一个断言里 16 16 0
返回值 8 6 2
箭头函数简写体 8 8 0
赋值右值 / 括号 3 3 0

调用实参位的 536 处测试站点分布在 33 个包,前几名:service-automation 162、spec 78、objectql 60、service-analytics 46、formula 26。抽样可见绝大多数是正当用法——负向测试故意构造 tsc 本就该拒绝的载荷(resolveCrudAffordances({ managedBy: 'append-only' } as never)matchesFilterCondition(rec, 'nope' as never)),或把测试台架塞进真实 API(new HttpDispatcher(kernel as never))。

三条方向的裁决

方向 1(扩 check:query-options-erasure 词表)——实测够不着,比正文的告诫更硬。

正文担心的是「语义变糊 + baseline 涌入」。实测结论更彻底:any 扩成 never 会命中本单 10 处调用点中的 0 处。query-options/no-any-erasure 的实现即知,它要求

  • callee 是成员表达式且名为 find / findOne / count / aggregate
  • 只看下标 >= 1 的实参(下标 0 是对象名)。

而本单 10 处全是裸标识符 calleecheckDateBucketParity(...)),驱动在下标 0。这一点在 R2 里被独立复现:把 as never 加回去之后,check:query-options-erasure 依然全绿、测试面 263「at the ceiling / none new」,根本没看见它

方向 2 宽口径(禁测试文件里实参位的 as never)——会一次命中 536 处既有站点。

而且这个取舍 #4918 已经对 as any 做过一模一样的判断,理由就写在 QUERY_OPTIONS_TEST_GLOBS 上方:「a blocking rule there would fight the tests that prove the contract is enforced」。同样的话逐字适用于 as never

方向 3(在替身侧加 satisfies 钉子)——认真评估过,未采纳。 正文说它「可能是最省事的一支」,但它把一致性钉在真实驱动那一侧:

  • 10 处里 4 处传真实驱动、6 处传手写假驱动。真实驱动来自生产代码、本来就大概率满足替身;正文自己写明「护栏缺失的代价,在假驱动那一侧最高」。方向 3 恰好只覆盖代价低的一侧,对 6 处假驱动完全无效
  • 它也不让「新调用点写第 11 个」响。
  • 且要在 packages/verify 里点名一个具体驱动,直接违背 read-coercion.ts 注释写明的设计意图:替身存在就是为了让树外驱动「without importing a concrete driver type」跑同一份契约。

落点是方向 2 的窄口径版本(正文括号里那句「或更窄:禁止对已知结构替身参数写任何断言」):今天 10 处调用点全干净,零 baseline——这正是它不该并进上面两道门的原因,那两道门各自要 grandfather 几百处。

二、改了什么

1. 一条专用 ESLint 插件规则 verify-stand-in/no-asserted-driver-argumenteslint.config.mjs

只禁止对 @objectstack/verify 结构替身检查的**驱动实参(下标 0)**写类型断言。那个参数类型不是装饰:对这次一致性检查而言,它就是编译期的那一半

  • 专用插件规则而非第四条 no-restricted-syntax 选择器——扁平配置不合并同名规则选项,再开一个 no-restricted-syntax 块会把 slot-lookup 那块的选择器整个替换掉(该文件已就此写过告诫)。
  • 一切断言,不只 any。query-options 规则放行 as unknown as X 是因为测试可能正当地需要越界的引擎输入;这里参数类型就是被测契约本身,用它给实参重新贴标签等于断言掉这次调用本该证明的东西。只测 any 会把 10 个历史 cast 原样放回来。
  • 覆盖测试文件(与 query-options 规则相反),理由如上:6/10 在测试文件里传手写假驱动。
  • 一并盖住拆分形态(const d: any = brokenDriver(); check(d)),用作用域分析,和 slot-lookup/no-any-assignment 同机制。

2. 一道对账门 pnpm check:verify-stand-inscripts/check-verify-stand-in-erasure.mjs

规则本身锁不住自己。pnpm lint 在三种情况下同样是绿的:树是干净的 / helper 被改名移动、规则匹配不到任何东西 / 新增了第三个替身检查、生来无人守。只有第一种是我们要的,而 lint 分辨不了。 这是本仓已经交过两次学费的死 pin 形状(#4984#5018)。所以受守集合不被当成一张名单来信任:

不变量 内容
DISCOVERED packages/verify/src 里至少发现一个候选。0 不是「没有替身」,是扫描坏了——下面每条都遍历这个集合,会全部空过
CLASSIFIED 每个候选必须被人显式归类恰好一次:受守(VERIFY_STAND_IN_CHECKS)或豁免(NOT_A_STAND_IN,带理由)。未归类即红
RECONCILED 反向:两张表里指向已不存在候选的条目即红。只能累加的名单会烂成没人信的清单
REACHED 每个受守 helper 至少有一处 packages/verify 之外的调用点,且打印计数。这是反空过的地板
CLEAN 没有调用点断言其驱动实参。在此重新推导而非信任 lint——ESLint 那块若被收窄或删掉,这道门仍然红

归类是人工的、显式的,因为「这个接口是不是结构替身」没有值得信任的语法答案:CoercibleDriver 全是方法,但 BucketableDriver 还带一个 supports 数据属性,所以「成员全是方法」在两个里已经错一个;名字规则(/Driver$/)则是下一个替身可以自由改写的约定。在这里猜,就是把死 pin 往上挪了一层。

实测输出(与 PR #6396 的测量独立吻合):

OK  check-verify-stand-in-erasure: 2 stand-in check(s) guarded, 5 candidate(s) explicitly exempt,
    10 call site(s) reached, 0 asserted driver arguments — checkDateBucketParity (8), checkReadCoercion (2)

8 + 2 = 10,与 PR #6396 数出的 cast 数一一对应。

三、反向验证(先声明,后执行)

五项全部与声明一致,无分歧可报。

注入 声明 实测
R1 复发(最强的一项) 手写假驱动调用点加回一个 as neverdate-bucket-parity-conformance.test.ts:90,正是代价最高的那一侧) lint 红、恰好 1 条本规则报告;门红、恰好 1 条 CLEAN: 指名 file:line;干净树两者皆绿 90:50 error … verify-stand-in/no-asserted-driver-argument✖ 1 problem;门 1 problem(s) 指名 :90
R2 撤掉护栏 保留该 cast,换回 #6399 之前的 eslint.config.mjs 全绿(= 立单描述的缺陷态) ✅ eslint exit=0 静默;check:query-options-erasure 全绿、263「at the ceiling / none new」——方向 1 的门确实看不见它
R3 第 11 个 helper packages/verify 加第三个替身检查,不归类 门红、恰好 1 条 CLASSIFIED:;lint 绿(规则无从知道,这正是对账那一半存在的理由) ✅ 门 1 problem(s) 指名 checkPagination(x: PageableDriver);lint exit=0
R4 空过方向 把门自己的 SCAN_ROOTS 收窄到走不到调用点处 门红、REACHED:(每个受守 helper 一条),不是在 0 处站点上绿 2 problem(s),两条 REACHED:
R5 阳性对照 isAsserted 改成恒 false --self-test 红;而普通跑仍绿(这正是它盖住的盲区) ✅ self-test 4 failure(s);普通跑 exit=0 绿 —— 所以 package.json 里 --self-test && 在前

R4 是按机制注入的(改 SCAN_ROOTS),不是搬走 10 处真实调用点;如实记下。R1/R3/R5 的注入均已回滚并复验干净。

负向断言不对称已按要求处理:CLEAN 这条负向断言全程配一条正向(REACHED 的计数地板 + --self-test 的阳性对照),且 self-test 里钉住「census 至少够到 10 处已知调用点」——10 不是魔数,是 8+2;若它悄悄掉到 3(立单表当初只看到真实驱动那三处而猜出的数),丢掉的恰好就是会漂移的手写假驱动那几处。

四、门禁(全部实跑)

结果 证据
pnpm lint 无输出(全仓,--no-inline-config
pnpm check:verify-stand-in self-test + 正式跑,见上
pnpm check:query-options-erasure 77 unswept non-test site(s) in 18 file(s), none new;测试面 263 未动
pnpm check:slot-lookup 143 unswept site(s) in 32 file(s), none new
pnpm check:nul-bytes scanned 6112 tracked text file(s) … no raw ASCII control bytes
pnpm check:workflow-status-functions 改了 lint.yml 才跑:22 workflow file(s), 41 job(s)
pnpm exec turbo run typecheck 125 successful, 125 total,3m6.7s(冷缓存)

executed vs reasoned:上表七道门、五项反向验证、AST 分布测量全部实跑。唯一推理的一处:本 PR 未改动任何 TypeScript 源文件(4 个文件 = 1 yml / 1 json / 2 mjs),故 typecheck 在原理上不受影响——但仍然实跑并附上真实结果,而不是以此免跑。

五、changeset

无。仓内工具链,零发布面(ESLint 配置 + 门禁脚本 + workflow + package.json 脚本名),故按约定打 skip-changeset


Generated by Claude Code

#6354 / PR #6396 一次删掉 10 个 `as never` —— `checkReadCoercion` /
`checkDateBucketParity` 的全部调用点 —— 实测全部是死 cast,而它们盖住的
编译期检查是活的。两件事都没有任何门禁会响:明天原样加回来,或在新调用
点写第 11 个,仍然没有一道门会响。本次把这个状态锁住。

实测先于选型。全仓 703 处 `as never`(`git grep 'as never'` 的 1122 是
把 `has never` 一并数了进去),其中 550 处在调用实参位、536 处在测试文件、
分布在 33 个包。据此排除 sweep 暗示的方向 1 与宽口径的方向 2:

- 方向 1(扩 `check:query-options-erasure` 词表)实测**根本够不着**本单的
  调用点。该规则只匹配 callee 为成员表达式且名为 find/findOne/count/
  aggregate、且实参下标 >= 1 的位置;本单 10 处全是裸标识符 callee、驱动
  在下标 0。只把 `any` 扩成 `never` 会命中其中 0 处,却要把几百个无关站点
  拖进它的 baseline,并让那道门的语义变糊。
- 方向 2 的宽口径版本会一次性命中 536 处既有站点,绝大多数是正当的
  —— 负向测试要构造 `tsc` 本就该拒绝的输入。#4918 当初对 `as any` 已经
  按同一理由做过同样的取舍(QUERY_OPTIONS_TEST_GLOBS)。

落点因此是方向 2 的窄口径版本:一条专用 ESLint 插件规则,只禁止对
`@objectstack/verify` 结构替身检查的**驱动实参**(下标 0)写类型断言。
那个参数类型不是装饰,它就是这次一致性检查的编译期一半。今天 10 处调用点
全干净,所以**零 baseline** —— 这正是它不该并进上面两道门的原因。

规则之外另加一道对账门 `check:verify-stand-in`,因为规则本身锁不住:
`pnpm lint` 在「树干净」「helper 被改名后规则匹配不到任何东西」「新增了
第三个替身检查、生来无人守」三种情况下同样是绿的。该门把受守集合与
`packages/verify` 实际导出的东西**双向对账**,并对调用点**计数**(实测
10 处 = 8 + 2,与 PR #6396 的测量独立吻合),于是「没有违规」永远不等于
「什么都没看」。

⚠️ 护栏缺失的代价在假驱动那一侧最高:10 处里 6 处传手写假驱动,4 处传真
实驱动。真实驱动来自生产代码、本来就大概率满足替身;手写字面量才是会漂移
的那一类。规则因此覆盖测试文件,与 query-options 规则的取舍相反,理由写在
规则注释里。

未采纳方向 3(在替身侧加 `satisfies` 钉子)的原因:它把一致性钉在真实驱动
那一侧,恰好是代价较低的一侧,对 6 处手写假驱动无效;且要在
`packages/verify` 里点名一个具体驱动,与 `read-coercion.ts` 注释写明的
「不导入具体驱动类型」的设计意图相悖。

不并 #6394:那是 `driver.create` 的 options 门(下标 1),本规则的
self-test 显式钉住它在范围之外。

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

vercel Bot commented Aug 8, 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)
objectstack Ignored Ignored Aug 8, 2026 3:13am

Request Review

@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 8, 2026 — with Claude
@github-actions github-actions Bot added size/l ci/cd dependencies Pull requests that update a dependency file labels Aug 8, 2026
@hotlong
hotlong marked this pull request as ready for review August 8, 2026 03:13
@hotlong
hotlong enabled auto-merge August 8, 2026 03:13
@hotlong
hotlong added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit ca4ef06 Aug 8, 2026
27 of 28 checks passed
@hotlong
hotlong deleted the claude/issue-6399-as-never-guardrail branch August 8, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding][devx] 没有任何门禁阻止 as never 被加回来 —— #6354 一次清掉 10 个死 cast,但清理本身没有护栏

2 participants