Skip to content

fix(scripts): give check-engine-double-contract a scoped-repository arm - #6496

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6327-scoped-repository-branch
Aug 8, 2026
Merged

fix(scripts): give check-engine-double-contract a scoped-repository arm#6496
hotlong merged 1 commit into
mainfrom
claude/issue-6327-scoped-repository-branch

Conversation

@hotlong

@hotlonghotlong commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Fixes#6327

scripts/check-engine-double-contract.mjs 的归属判据一直是二分的:一个字面量要么是 engine double,要么是 driver double。#5945 引入的 IScopedObjectRepository第三种数据访问形状,这道门没有它的那一支 —— 于是该接口的每一个类型符合性见证都被判成 engine double,只能靠手写 EXEMPT 出场。本 PR 加上第三支。

排版说明:本仓的 GitHub 正文消毒器会把引号转义成实体(' / "),连代码块里也不放过。下面的代码块因此刻意不写引号 —— 真实拼写以 diff 为准。

一、立单前提的复核(在 origin/main 上重测,未引用卡片)

三者的写动词并排,逐条对着源文件核过:

契约写动词签名对象名的位置
IDataEngineupdate(objectName, data, options?)第一位
IDataDriverupdate(object, id, data, options?)第一位,主键在第二位
IScopedObjectRepositoryupdate(data, options?)不是参数
  • packages/spec/src/contracts/data-engine.ts:138 / data-driver.ts:179 / contracts/scoped-context.ts:160
  • 该接口成员集 find / findOne / count / insert / update / updateById —— 四个 ENGINE_SIBLINGS,且 insertENGINE_ONLY_MEMBERS 里。所以 isEngineVerbShapeparams.length 不足 2 的分支上拿 insert 当 engine 证据,把见证归到 engine 侧。对扫描能看见的信息而言准确,对那个对象究竟是什么而言错误 —— 立单正文的这句话成立。
  • 「spec 原则上无法路由 assertEngineUpdateDispatch」也成立:谓词在 @objectstack/objectql / @objectstack/metadata-core,两者都依赖 @objectstack/spec,import 会反转依赖。所以此后每个实现或见证都只能再手写一条 EXEMPT。

改门前的门是绿的:OK — 82 pinned, 133 in the DEBT ledger, 4 exempt这不是一道报红的门,是一道把正确代码判成债务的门 —— 代价落在 ledger 的可读性上,而它的 $comment 明写 shrink-only、手工评审。

二、走了哪条方向,以及数据为什么这么说

取方向 1(形状归属支),但两半合取,并且成员那一半是必需的。 判据与 DRIVER_ONLY_MEMBERS 完全同构 —— 新增 REPOSITORY_ONLY_MEMBERS,成员为 updateByIddeleteById

// REPOSITORY_ONLY_MEMBERS = { updateById, deleteById }if(!takesObjectNameFirst(fn)){for(constnofmemberNames)if(REPOSITORY_ONLY_MEMBERS.has(n))returnfalse;}

放在 DRIVER veto 之后、ENGINE_ONLY_MEMBERSinsert 之前,任何 arity 都判 —— 和 driver veto 同一个优先级理由。命中即出扫描范围(像 driver double 那样),不进 ledger。

选名字不是拍脑袋:updateById / deleteById 逐个核对过 —— IDataEngine 没有任何 by-id 形式,IDataDriver 用参数里的 idbulkUpdate / updateMany / bulkDelete / deleteMany,而 packages/objectql/src/engine.ts 里这两个名字的唯一声明处是 class ObjectRepository implements IScopedObjectRepository(7831 / 7846 行)。repository 的 create / upsert / delete / aggregate / execute 故意不收:每一个都同时在 driver 或 engine 上,分不开任何东西。

为什么不取方向 2(按声明类型):测下来它只覆盖一半。250 个已发现 double 里带 : IScopedObjectRepository 标注的只有 1 个;packages/spec/src/data/hook.test.ts:1176 的 repository 是 object(name) 返回的字面量,没有任何标注,方向 2 看不见它 —— 那条 EXEMPT 会永远留在 ledger 里。判据的覆盖面取决于作者当时有没有顺手写标注,这不是判据。

为什么不取方向 3(维持现状):见上面的成本论证,且方向 1 的测量结果足够干净(下一节),没有理由把它记进「deliberately not covered」。

为什么两半必须合取

  • 只要成员、不看参数 —— 那么给任何 fake 挂一个 updateById 就能把它悄悄移出这道门。参数测试挡住这个:update(objectName, data, opts) 拿了 engine 拿对象名的位置,就留在范围内。
  • 只要参数、不要成员 —— 得靠参数名白名单,而本仓的 o 本身歧义:12 个已发现 double 里 o: string 是对象名,而 packages/runtime/src/action-body-identity.test.ts:71o?: any 是 options 包。猜错的方向是把真 engine double 挪出扫描范围,那是这道门唯一无法自报的错误。

takesObjectNameFirst 因此只给正面证据:读不出来或没有第一个参数一律返回 false,绝不返回 true。这个不对称是安全性质本身 —— 它的 true 是「留在范围内」,宽松只会多留一条 ledger;宽松地给 false 才会造成假阴性。

三、判据的分辨力测量(先测量,后落笔)

对全语料 250 个已发现 double(115 delete + 135 update,其中 82 个 pinned) 逐个打分:

候选判据移出扫描范围其中 pinned(会停止被检查的真 engine double)
本 PR:成员 ∧ 第一参数非对象名2 / 2500
仅成员(updateByIddeleteById2 / 2500
仅声明类型标注(方向 2)1 / 2500
仅第一参数(无成员要求)3 / 2500

移动的两个恰好是 #5945 的两个见证,没有一个 pinned double 被影响,没有一个真 engine double 受影响。语料库范围内把 updateById / deleteById 声明为成员的测试文件也只有这两个。

故意没有覆盖到的一个,写进了脚本头packages/runtime/src/action-body-identity.test.ts:71 是一个真的 scoped facade(createContext().object(name) 返回的对象),但它只写了 find / count / insert / update / delete,没有 repository 专属成员,所以留在 ledger 里,条目数不变(该文件两个 verb 各 unguarded: 2,未动)。要看见它就得读参数名,而 o 恰恰在本仓两头都用 —— 用一条 ledger 换假阴性风险不划算,这个取舍已写在脚本头的「deliberately NOT covered」里,而不是留给下一个读者去踩。

四、两条现有 EXEMPT 的去向(前后实测)

⚠️ 没有把它们当「缺陷证据」删掉。先测量它们在新判据下确实出扫描范围,才删:

改动前改动后
delete slice115 doubles / 98 files,49 pinned,66 baseline不变
update slice135 doubles / 119 files,33 pinned,102 baseline133 / 117,33 pinned,100 baseline
汇总82 pinned,133 DEBT,4 exempt82 pinned,133 DEBT,2 exempt
  • 删除(各 1 条,verb=update):packages/spec/src/contracts/scoped-context.test.tspackages/spec/src/data/hook.test.ts。删除是 RECONCILED 逼出来的 —— 改完门后主跑直接报「baseline entry ... which declares no engine double with a update any more」。两条条目自己的 closes 早就预告了这一步:「permanent, unless this gate grows a scoped-repository arm (then this witness becomes out of scope rather than exempt)」
  • 保留packages/spec/src/contracts/data-engine.test.ts 的两条(delete / update,各 unguarded: 5)。它们是 IDataEngine 见证 —— 对象名在第一位、不含任何 repository 专属成员,新判据碰不到它们,实测计数 5 未变。
  • 两个见证的测试内容一字未动(它们是证据),packages/spec/src/contracts/scoped-context.ts 也未动(那是 [spec] HookContext.api 声明为 z.unknown() —— 按 (ctx: HookContext) 标类型的 hook 无法调用 ctx.api.object(…),而文档/技能全在这么教 #5945 的面)。

五、反向验证(先声明方向,再跑)

只回退 veto 本身(保留新自测 fixture、保留已删的两条 baseline),声明在前

预期变红:4 条新断言 + 主跑 2 个 PINNED 错误。预期保持绿:A/B 对照「同一字面量去掉 updateById 仍被发现」与「带 updateById 的 engine double 仍在范围内」,以及全部既有断言。

实测完全一致:

 x self-test: a scoped-repository witness is out of scope for the update slice
x self-test: an UNANNOTATED repository handed back by object(name) is out of scope
x self-test: a scoped repository delete is out of scope when the literal declares deleteById
x self-test: a mixed fixture reports EXACTLY the engine double, not the repository
check-engine-double-contract --self-test: 4 failure(s).
x PINNED [update]: packages/spec/src/contracts/scoped-context.test.ts ... (line 296)
x PINNED [update]: packages/spec/src/data/hook.test.ts ... (line 1176)

两条对照断言均未出现在失败列表里 —— 这正是要点。「不再被报告」在扫描彻底失灵时会空过,所以新增断言全部配了正面对照:

  • A/B 对照用的是同一个字面量加减一个 updateById(带标记 ⇒ 出范围;去掉 ⇒ 仍被发现 1 个)。不是「另找一个对照」,而是同一个对象拿掉证据,这是唯一能区分「veto 生效」和「发现能力死了」的写法。
  • 混合 fixture 里同时放 repository 和 engine double,断言的是精确集合(长度等于 1 且行号等于 11),不是「等于 0」。一个把什么都静音掉的判据能通过前面每一条,唯独过不了这条。

六、门

结果
pnpm lint(ESLint)pass,无输出
pnpm check:engine-double-contract(CI 的原命令,含 --self-testpass — OK — 82 pinned, 133 in the DEBT ledger, 2 exempt
pnpm check:nul-bytespass — 6105 个文件,无裸控制字节
pnpm exec turbo run typecheckpass — 125 successful, 125 total
改动文件控制字节自扫(grep -naP,越过门的盲区)无命中
CI · ESLint(家族门都在这个 job 里跑)success
CI · TypeScript Type Checksuccess
CI · Check Changesetsuccessskip-changeset 在其重读之前落地)

scripts/ 不在任何 tsconfig 的编译范围内,所以 typecheck 对本改动没有判别力 —— 仍然全量跑了一遍,只为证明没有连带回归,不当作本改动的证据。

七、changeset

无。仓库内部门禁工具,无对外交付面,不发布任何包 ⇒ 已打 skip-changeset(读回确认:size/m + skip-changeset)。

engine/driver 二分法少了一支。#5945 引入的 `IScopedObjectRepository` 绑定在
单个对象上,写动词里根本没有对象名(`update(data, options?)`),但它的成员集
含四个 ENGINE_SIBLINGS、且 `insert` 在 ENGINE_ONLY_MEMBERS 里 —— 于是每一个
该接口的类型符合性见证都被归到 engine 侧。对扫描能看见的信息而言准确,对那个
对象究竟是什么而言错误。
而 spec 原则上无法路由 `assertEngineUpdateDispatch`(谓词的两个家都依赖
spec,import 会反转依赖),所以此后每个见证只能手写一条 EXEMPT —— ledger 会
越长越像噪音,而 ledger 的可读性正是这道门的价值。
判据用与 DRIVER_ONLY_MEMBERS 完全同构的证据结构:REPOSITORY_ONLY_MEMBERS
(`updateById` / `deleteById` —— 逐个核对过,IDataEngine、IDataDriver、
ObjectQL 类都没有这两个名字),并与「写动词第一个参数不是对象名」合取。两半
各有职责:成员是「这是 repository」的证据,参数测试防止成员本身变成豁免令牌
(`update(objectName, …)` 的 fake 无论挂多少 by-id 便利方法都留在范围内)。
`takesObjectNameFirst` 只给正面证据,读不出来时返回 false —— 该方向只会多留
一条 ledger,反方向才会把真 engine double 挪出扫描范围。
先测量后落笔:250 个已发现 double(82 个 pinned)里判据只移动 2 个,恰好是
#5945 的两个见证,0 个 pinned 受影响。仅按声明类型(方向 2)只覆盖 1/2 ——
hook.test.ts 的 repository 没有标注。仅按参数(无成员要求)会扫到 3 个,但要
靠参数名白名单,而本仓 `o` 本身歧义(12 个 double 里 `o: string` 是对象名,
action-body-identity 的 `o?: any` 是 options)——那一个无标记的 scoped facade
因此留在 ledger 里,脚本头已写明为「deliberately not covered」。
两条 scoped 见证的 EXEMPT 随之删除(它们自己的 `closes` 就预告了这一点:
"permanent, unless this gate grows a scoped-repository arm")。data-engine.test.ts
的两条 EXEMPT 原样保留 —— 它们是 IDataEngine 见证,对象名在第一位、无
repository 成员。update slice 135→133,delete slice 115 不变,pinned 82 不变。
自测新增 A/B 对照(同一个字面量加减 `updateById`)与一个混合 fixture 的精确
集合断言,因为「不再被报告」在扫描彻底失灵时会空过。
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 2:25am

Request Review

@hotlonghotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 8, 2026 — with Claude
@hotlong
hotlong marked this pull request as ready for review August 8, 2026 02:26
@hotlong
hotlong enabled auto-merge August 8, 2026 02:26
@hotlong
hotlong added this pull request to the merge queueAug 8, 2026
@hotlong
hotlong marked this pull request as draft August 8, 2026 02:37
@hotlong
hotlong marked this pull request as ready for review August 8, 2026 02:42
Merged via the queue into main with commit 3a1d9c7Aug 8, 2026
24 checks passed
@hotlong
hotlong deleted the claude/issue-6327-scoped-repository-branch branch August 8, 2026 02:49
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@hotlong@claude