feat(devx): 给 @objectstack/verify 结构替身的驱动实参加护栏 (#6399) - #6525
Merged
Conversation
#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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
hotlong
marked this pull request as ready for review
August 8, 2026 03:13
hotlong
enabled auto-merge
August 8, 2026 03:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6399
本单锁的不是「删掉 10 个 cast」——PR #6396 已经做完,并且证明了编译期检查是活的。锁的是这个状态没有被锁住:明天任何人把这 10 个原样加回来、或在新调用点写第 11 个,没有一道门会响。
⛔ 未并 #6394(
domain:drivers,driver.create的 options 门,下标 1)。本 PR 的 self-test 显式钉住那个位置在本规则范围之外。一、先测量,再选型
立单正文列了三条方向且明确未预断。方向由实测裁定,不由谁复述过。
全仓
as never分布(实测)先纠一个计数陷阱:
git grep 'as never'得 1122,其中含has never这类英文散文(h-as never)。加词边界后是 693 行;用 TypeScript AST 数AsExpression节点(跨行调用会多于行数)得 703 处,分布 215 个文件。调用实参位的 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的实现即知,它要求find/findOne/count/aggregate;而本单 10 处全是裸标识符 callee(
checkDateBucketParity(...)),驱动在下标 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钉子)——认真评估过,未采纳。 正文说它「可能是最省事的一支」,但它把一致性钉在真实驱动那一侧:packages/verify里点名一个具体驱动,直接违背read-coercion.ts注释写明的设计意图:替身存在就是为了让树外驱动「without importing a concrete driver type」跑同一份契约。⇒ 落点是方向 2 的窄口径版本(正文括号里那句「或更窄:禁止对已知结构替身参数写任何断言」):今天 10 处调用点全干净,零 baseline——这正是它不该并进上面两道门的原因,那两道门各自要 grandfather 几百处。
二、改了什么
1. 一条专用 ESLint 插件规则
verify-stand-in/no-asserted-driver-argument(eslint.config.mjs)只禁止对
@objectstack/verify结构替身检查的**驱动实参(下标 0)**写类型断言。那个参数类型不是装饰:对这次一致性检查而言,它就是编译期的那一半。no-restricted-syntax选择器——扁平配置不合并同名规则选项,再开一个no-restricted-syntax块会把 slot-lookup 那块的选择器整个替换掉(该文件已就此写过告诫)。any。query-options 规则放行as unknown as X是因为测试可能正当地需要越界的引擎输入;这里参数类型就是被测契约本身,用它给实参重新贴标签等于断言掉这次调用本该证明的东西。只测any会把 10 个历史 cast 原样放回来。const d: any = brokenDriver(); check(d)),用作用域分析,和slot-lookup/no-any-assignment同机制。2. 一道对账门
pnpm check:verify-stand-in(scripts/check-verify-stand-in-erasure.mjs)规则本身锁不住自己。
pnpm lint在三种情况下同样是绿的:树是干净的 / helper 被改名移动、规则匹配不到任何东西 / 新增了第三个替身检查、生来无人守。只有第一种是我们要的,而 lint 分辨不了。 这是本仓已经交过两次学费的死 pin 形状(#4984、#5018)。所以受守集合不被当成一张名单来信任:DISCOVEREDpackages/verify/src里至少发现一个候选。0 不是「没有替身」,是扫描坏了——下面每条都遍历这个集合,会全部空过CLASSIFIEDVERIFY_STAND_IN_CHECKS)或豁免(NOT_A_STAND_IN,带理由)。未归类即红RECONCILEDREACHEDpackages/verify之外的调用点,且打印计数。这是反空过的地板CLEAN归类是人工的、显式的,因为「这个接口是不是结构替身」没有值得信任的语法答案:
CoercibleDriver全是方法,但BucketableDriver还带一个supports数据属性,所以「成员全是方法」在两个里已经错一个;名字规则(/Driver$/)则是下一个替身可以自由改写的约定。在这里猜,就是把死 pin 往上挪了一层。实测输出(与 PR #6396 的测量独立吻合):
8 + 2 = 10,与 PR #6396 数出的 cast 数一一对应。
三、反向验证(先声明,后执行)
五项全部与声明一致,无分歧可报。
as never(date-bucket-parity-conformance.test.ts:90,正是代价最高的那一侧)CLEAN:指名 file:line;干净树两者皆绿90:50 error … verify-stand-in/no-asserted-driver-argument,✖ 1 problem;门1 problem(s)指名:90eslint.config.mjsexit=0静默;check:query-options-erasure全绿、263「at the ceiling / none new」——方向 1 的门确实看不见它packages/verify加第三个替身检查,不归类CLASSIFIED:;lint 绿(规则无从知道,这正是对账那一半存在的理由)1 problem(s)指名checkPagination(x: PageableDriver);lintexit=0SCAN_ROOTS收窄到走不到调用点处REACHED:(每个受守 helper 一条),不是在 0 处站点上绿2 problem(s),两条REACHED:isAsserted改成恒false--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-inpnpm 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 newpnpm check:nul-bytesscanned 6112 tracked text file(s) … no raw ASCII control bytespnpm check:workflow-status-functionslint.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