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

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 个文件。

语法位置处数测试文件非测试
调用实参位55053614
对象字面量属性值93921
变量初值25241
嵌套在另一个断言里16160
返回值862
箭头函数简写体880
赋值右值 / 括号330

调用实参位的 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)。所以受守集合不被当成一张名单来信任:

不变量内容
DISCOVEREDpackages/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 个 helperpackages/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-inself-test + 正式跑,见上
pnpm check:query-options-erasure77 unswept non-test site(s) in 18 file(s), none new;测试面 263 未动
pnpm check:slot-lookup143 unswept site(s) in 32 file(s), none new
pnpm check:nul-bytesscanned 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 typecheck125 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

vercelBot commented Aug 8, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 8, 2026 3:13am

Request Review

@hotlonghotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 8, 2026 — with Claude
@github-actionsgithub-actionsBot 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 queueAug 8, 2026
Merged via the queue into main with commit ca4ef06Aug 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cddependenciesPull requests that update a dependency filesize/lskip-changesetPR 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

@hotlong@claude