Skip to content

feat(objectql): 把假引擎钉在 ObjectQL.delete 的真实契约上 (#4550) - #4948

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4550-test-double-contract-gate
Aug 3, 2026
Merged

feat(objectql): 把假引擎钉在 ObjectQL.delete 的真实契约上 (#4550)#4948
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4550-test-double-contract-gate

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4550

先核实议题里的四个实例

议题正文的转述与仓库实情有出入,逐条核实如下(两个不在本仓,一个根本没有替身):

实例 替身在哪 真实实现在哪 替身宽松在哪一点 让什么缺陷过了 CI 本次纳入
#4434 packages/plugins/plugin-sharing/src/sharing-rule.test.tsmakeEngine().delete packages/objectql/src/engine.ts ObjectQL.delete()reject 分支) 接受任意 where 谓词;真实 dispatch 只接受标量 where.idoptions.multi,否则抛 Delete requires an ID or options.multi=true deleteRule 用谓词删 sys_record_shareDELETE /sharing/rules/:idOrName每一条规则、两种寻址形式都 500,而 deleteRule drops rule + all its grants 一直断言成功
#4441 packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts 的夹具 平台 FK/引用完整性校验 判据是一句注释// FK enforcement is off in this harness)+ 写入不存在的 id lookup 接受不存在的引用 id
objectui#3129 ObjectTimeline.test.tsx./renderer 换成只渲染 item.title 的桩件 objectui 真实 renderer 断言目标(分桶结果)只存在于被换掉的那一层 timeline 不读 startDateField
objectui#3134 没有替身 —— 议题表格该列即为 不是「替身过宽」,是「根本没有对照测试 设计器不解析已有翻译

两处实情与议题正文不一致,照实记录:

  • 四个里有两个在 objectui,本容器里没有该仓的 checkout,本仓的闸门在结构上够不到它们;
  • objectui#3134 不是本类问题。它是「缺测试」,不是「替身比真实实现宽松」。把它算进同一条闸门的覆盖面,正是那种「覆盖面模糊 = 等于没有闸门」的写法。

所以本仓内真正属于这一族、且判据可机械判定的,只有 #4434 一个。#4441 是同族但判据是散文。

分片依据

只做一条:假 ObjectQL 引擎的 delete,必须钉在真实 dispatch 契约上。

选它的理由是判据硬。ObjectQL.delete(object, options) 是一个全函数,从 options 映射到三个判决之一:

  • where.id标量string/number/bigint,非 null)→ by-id,走 driver.delete
  • 否则 options.multi 为真 → multi,走 driver.deleteMany
  • 否则 →

于是「这个替身是不是更宽松」有一个 yes/no 的答案,不需要读懂测试的意图。#4441 的判据是「注释里写了关掉某条平台约束」—— 删掉注释就绕过,也发现不了没写注释的那些;那需要一条声明式欠债台账,不是扫描器,另立单更合适。

其余三个方法(find 的过滤语义、update 的孪生 dispatch、未知选项拒绝)是同一族,但每个都得先在生产侧抽出自己的谓词。delete 之所以现在就能做,是因为 #4434 已经把这笔钱付过了。

做法:一个谓词,两端共用

packages/objectql/src/engine-delete-dispatch.ts 导出 resolveEngineDeleteDispatch / assertEngineDeleteDispatch / scalarDeleteId / ENGINE_DELETE_DISPATCH_CASESObjectQL.delete 自己读它,替身也调它:

async delete(object: string, options?: any) {
  assertEngineDeleteDispatch(options);   // 真服务器拒绝什么,它就拒绝什么
  
}

#4434 的修复是把守卫手抄进那一个假引擎。手抄出来的是契约的第二份副本,任一侧被编辑就会漂移 —— 而且手抄最常丢的正是标量那一半:where: { id: { $in: [...] } } 看着像 id,其实是多行谓词,真实引擎在没有 multi拒绝它,而 if (!opts?.where?.id && !opts?.multi)放行。共用生产侧的函数把这一类整体消掉:调用了那个决定的替身,不可能比那个决定更宽松。与 #4455「扫描与校验器必须用同一个谓词回答问题」是同一形状。

engine-delete-dispatch.test.ts 不是拿谓词对着旁边写的期望表跑,而是用录制 driver 驱动真实引擎逐条跑 ENGINE_DELETE_DISPATCH_CASES,断言真实引擎的观测行为等于谓词的判决。谓词一旦与 engine.ts 漂移,红在这里。

闸门

scripts/check-engine-double-contract.mjs,接进 lint.yml 的 ESLint job(纯静态 AST,不需要 build),按仓内 10 个 check:* 的惯例 --self-test && 真实检查

三条不变式:

范围切分是机械的,不是靠命名:假 driver 的 delete(object, id, options) 第二参是主键,是另一条契约,必须不被扫进来 —— 仓内 126 个 delete 方法里 39 个是 driver 替身,全部正确排除。

双向证明

1. 历史坏状态上判红

#4434 修复前的假引擎原样取回:

$ git show ba5ff2f28^:packages/plugins/plugin-sharing/src/sharing-rule.test.ts \
    > packages/plugins/plugin-sharing/src/sharing-rule.test.ts
$ node scripts/check-engine-double-contract.mjs

engine doubles: 39 in 39 test file(s) — 8 pinned to ObjectQL.delete's dispatch predicate, 31 in the shrink-only baseline.

  x PINNED: packages/plugins/plugin-sharing/src/sharing-rule.test.ts declares 1 engine double(s) whose delete() does not route through assertEngineDeleteDispatch (line 46). A fake looser than ObjectQL.delete is how #4434 shipped a dead REST route with its suite green. …

check-engine-double-contract: 1 problem(s).
exit=1

2. 钉住之后,缺陷本身判红

上面只证明了「缺了那一行会被点名」。更强的一步:保持替身钉住,把 #4434 修复前的 sharing-rule-service.ts 取回(deleteRule 用谓词删 sys_record_share),跑既有的那条测试:

$ git show ba5ff2f28^:packages/plugins/plugin-sharing/src/sharing-rule-service.ts \
    > packages/plugins/plugin-sharing/src/sharing-rule-service.ts
$ pnpm --filter @objectstack/plugin-sharing exec vitest run src/sharing-rule.test.ts

 × deleteRule drops rule + all its grants 41ms
 × deleteRule issues only engine-legal deletes — by NAME (#4434) 4ms
 × deleteRule issues only engine-legal deletes — by ID (#4434) 1ms
 × deleteRule withdraws grants through the sharing service, not a bulk delete (#4434) 2ms

 FAIL  src/sharing-rule.test.ts > SharingRuleService > deleteRule drops rule + all its grants
Error: Delete requires an ID or options.multi=true
 ❯ SharingRuleService.deleteRule src/sharing-rule-service.ts:249:23

 Test Files  1 failed (1)
      Tests  8 failed | 43 passed (51)
exit=1

抛出的正是当年真服务器返回 500 时的那条错误。当年若有这条闸门,#4434 进不来。

3. 当前 main 判绿,且不误伤

$ node scripts/check-engine-double-contract.mjs --self-test
OK  self-test: separates engine doubles from driver doubles, accepts only the producer's predicate
    (direct or one helper deep), rejects unused imports, hand-mirrored guards and look-alikes, and
    proves discovery reaches the real tree.

$ node scripts/check-engine-double-contract.mjs
engine doubles: 39 in 39 test file(s) — 9 pinned to ObjectQL.delete's dispatch predicate, 30 in the shrink-only baseline.

  pinned  packages/objectql/src/layered-overlay-integration.test.ts
  pinned  packages/objectql/src/protocol-publish-rollback.test.ts
  pinned  packages/objectql/src/protocol-save-meta-repo-path.test.ts
  pinned  packages/objectql/src/protocol-view-identity-overlay.test.ts
  pinned  packages/objectql/src/sys-metadata-repository.test.ts
  pinned  packages/plugins/plugin-sharing/src/boot-backfill.test.ts
  pinned  packages/plugins/plugin-sharing/src/sharing-rule-provenance.test.ts
  pinned  packages/plugins/plugin-sharing/src/sharing-rule.test.ts
  pinned  packages/plugins/plugin-sharing/src/sharing-service.test.ts

  EXEMPT  packages/spec/src/contracts/data-engine.test.ts
          Not a stand-in that code under test drives — it is a TYPE-CONFORMANCE witness …

check-engine-double-contract: OK — 9 pinned, 29 in the DEBT ledger, 1 exempt.

没有为了绕过而放宽判据。 9 个改成钉住的替身,其所在包的测试全绿(见下),没有一个是靠松开判据换来的。

未纳入范围的部分

  • 另外 30 个假引擎scripts/engine-double-contract.baseline.json(只减不增,双向对账)。多数所在包不依赖 @objectstack/objectql,钉住需要加 devDependency + 改 lockfile,是一次独立可评审的动作;plugin-approvals(6) 与 runtime(1) 已有该依赖,是一行改动,只是不在本片里(未实测的 suite 若翻红,应当有自己的 PR)。其中 3 个(plugin-security、service-automation、service-queue)已经手抄了守卫 —— 那正是本闸门要消掉的第二份副本,台账里写明了。
  • 1 个 EXEMPTpackages/spec/src/contracts/data-engine.test.tsIDataEngine类型可实现性见证,只断言 typeof engine.delete === 'function',没有被测代码去驱动它;而且原理上钉不了 —— objectql 依赖 spec,反向 import 会倒置依赖。(顺带:给全部 39 个替身临时装上守卫后,@objectstack/spec 295/295 个测试文件全绿,所以这条是 EXEMPT 而非 DEBT。)
  • data: a lookup accepts an id that does not exist in the referenced object — including the RBAC permission-set link tables #4441 / objectui#3129 / objectui#3134 三个实例见上表,判据不同源或不在本仓,建议 PM 另立单。

台账不声称的东西

基线条目记录的是「该替身结构上比契约宽松」。它声称这份宽松当下无害 —— 逐文件证明那件事需要装上守卫跑每个 suite,本 PR 只对 @objectstack/spec 整包做过(295/295 全绿)。其余是未证之状态,这也正是它们记为 DEBT 而非 EXEMPT 的原因。基线的 $comment 里原样写着这一点。

验证

pnpm --filter @objectstack/objectql --filter @objectstack/plugin-sharing test
  packages/objectql          Test Files  111 passed (111)   Tests  1757 passed (1757)
  packages/plugins/plugin-sharing  Test Files  11 passed (11)    Tests   243 passed (243)

pnpm --filter @objectstack/objectql --filter @objectstack/plugin-sharing typecheck
  packages/objectql typecheck: Done
  packages/plugins/plugin-sharing typecheck: Done

npx eslint scripts/check-engine-double-contract.mjs packages/objectql/src/engine-delete-dispatch.{ts,test.ts} \
  packages/plugins/plugin-sharing/src/sharing-rule.test.ts
  (无输出)

⚠️ 触碰了共享热点文件

本 PR 改了package.jsonscripts 段末尾)与 .github/workflows/lint.yml(ESLint job 末尾) —— 本仓最容易冲突的两处。两处都是在既有列表末尾追加一行/一个 step,没有改动任何既有条目,所以与并行 PR 冲突时解法是取并集,保留对方的改动,不要二选一。(今日已因 package.json 相邻行发生过一次合并冲突。)

不接进 lint.yml 的话没人会跑它 —— 那恰好是 #4868 那一族的成因,所以这次接入是必须的,不是可选的。

变更文件

  • 新增 packages/objectql/src/engine-delete-dispatch.tsengine-delete-dispatch.test.ts
  • packages/objectql/src/engine.tsdelete() 改读共享谓词)、index.ts(导出)
  • 钉住 9 个假引擎(objectql ×5、plugin-sharing ×4)
  • 新增 scripts/check-engine-double-contract.mjsscripts/engine-double-contract.baseline.json
  • package.json.github/workflows/lint.yml
  • 新增 changeset(@objectstack/objectql: minor —— 新增公开导出)

未触碰 content/docs/releases/


Generated by Claude Code

…ct (#4550)

#4550 records a failure mode with four instances: a test double LOOSER than
the implementation it replaces converts a green suite into no suite at all —
silently, and on exactly the paths a double was introduced for, which are the
paths that were hard to test, which are usually where the contract is densest.

This takes ONE slice of it, the one whose criterion is mechanically decidable:
the ObjectQL engine's delete dispatch. `ObjectQL.delete(object, options)` is a
total function from an options bag to three verdicts — `by-id` (scalar
`where.id`), `multi` (`options.multi`), or a throw — so "is this double looser?"
has a yes/no answer that does not require reading the test's intent.

The producer's decision now lives in one place. `engine-delete-dispatch.ts`
exports `resolveEngineDeleteDispatch` / `assertEngineDeleteDispatch` /
`scalarDeleteId` / `ENGINE_DELETE_DISPATCH_CASES`, `ObjectQL.delete` itself
reads it, and a fake engine calls it instead of mirroring it. #4434's fix
mirrored the guard by hand into one fake; a mirror is a second copy of the
contract and drifts the moment either side is edited — and the scalar test is
the half a mirror drops (`where: { id: { $in: [...] } }` looks like an id and
is a multi-row predicate). A double that imports the decision cannot be looser
than the decision.

`scripts/check-engine-double-contract.mjs` (wired into lint.yml's ESLint job,
`--self-test` first per the repo's 10 other gates) finds all 39 fake ObjectQL
engines by AST — separating them from the 39 DRIVER doubles, whose `delete`
takes a scalar id and is a different contract — and requires the pinned call.
9 are converted here (objectql's five sys_metadata fakes, plugin-sharing's
four, including the two that hand-mirrored); the other 30 sit in a measured,
shrink-only baseline that reconciles in both directions.

Proof it discriminates, not just that it is green:

  1. `git show ba5ff2f^:…/sharing-rule.test.ts` — the pre-#4434 fake —
     restored: the gate goes red naming that file and line.
  2. With the fake pinned and the pre-#4434 `deleteRule` restored, the
     PRE-EXISTING test `deleteRule drops rule + all its grants` fails with
     `Delete requires an ID or options.multi=true` — the same error the running
     server answered 500 with. Had this gate existed, #4434 could not have
     shipped.

Verified: objectql 111 files / 1757 tests, plugin-sharing 11 / 243, both
typecheck clean, ESLint clean. @objectstack/spec ran 295/295 files green with
the dispatch guard installed in every engine double, which is why its
type-conformance witness is EXEMPT rather than DEBT.

Refs #4550, #4434

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

vercel Bot commented Aug 3, 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 3, 2026 5:02pm

Request Review

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

13 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/plugins/index.mdx (via @objectstack/objectql)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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 documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

测试替身比真实实现宽松:四个缺陷因此带着绿灯发布——需要一条把替身钉在真实契约上的闸门

2 participants