Skip to content

feat(lint): multi: true 且无 filter 的 delete/update 在 authoring 期告警(#5482) - #5663

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5482-multi-empty-filter-lint
Aug 5, 2026
Merged

feat(lint): multi: true 且无 filter 的 delete/update 在 authoring 期告警(#5482)#5663
os-zhuang merged 2 commits into
mainfrom
claude/issue-5482-multi-empty-filter-lint

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5482

按 2026-08-05 分诊裁决执行方案 1(authoring 期 warning);⛔ 未加 spec refine、未动 #3810 运行期守卫 / crud-nodes.ts / 引擎派发表 / #5651 的 time-relative 规则。

前提核实(PD #6)

先在 origin/main(5b60b36)上实测,四种形状全部零诊断,与 issue 描述一致:

[premise] no filter        → []
[premise] empty filter     → []
[premise] update no filter → []
[premise] nested no filter → []

filtermulti 在两个 config schema 里各自 optional、彼此无约束(packages/spec/src/automation/builtin-node-config.zod.ts),lintFlowPatterns 也没有任何针对破坏性节点 config 的检查。前提成立。

改了什么

packages/lint/src/lint-flow-patterns.ts 新增 flow-multi-write-unfiltered(warning):delete_record / update_recordconfig.multi === true 且 filter 无有效条件时告警,说明这是「按声明清空整个对象」,给出两条出路(补 filter 约束 / 确认整表意图属实——运行期照常允许)。规则 id 沿用 #5496 留的 flow-{descriptor}-{verdict} 家族前缀;barrel 补 packages/lint/src/index.ts 导出。lintFlowPatterns 本来就挂在 AUTHORING_RULES 上,严重度缺省即 warning,所以 os validate / os build / runtime publish gate 三个面自动生效,无需新增注册项。

诊断落在每个 graph 上(不只顶层),所以loop body 内的清扫节点天然命中——这正是定时 janitor 流的标准形状,也是 #5383/#5635 刚打开的那片区域。

「无有效条件」判定表

只判可证的两格,窄是刻意的:一条说「这是整表写」的告警必须说对。

形状 判定 依据
filter 键缺失 告警 执行器代入 {}(resolveNodeFilter(cfg.filter ?? {}, …))
filter: {} 告警 原样下传;driver-memory 的 matcher 开篇即 Object.keys(filter).length === 0 → return true
filter: { owner: '{record.ownr}' } 不判 条件写了;插值抹除是 #3810 运行期守卫的事实,不抢它的活
filter: { status: 'closed' } 不判 有约束(showcase 清扫流即此形)
multi / multi: false 不判 引擎本来就点名拒绝(… requires an ID or options.multi=true)
multi: 'true'(字符串) 不判 schema 是 z.boolean(),parse 阶段就refuse,节点跑不起来
filter 非对象(字符串/数组) 不判 schema 是 z.record(z.string(), z.unknown()),parseNodeConfig 按名拒绝
filter: { $and: [] } 不判(已知缺口) #5322/#5134 恒等元这整表写,但判它需要布尔身份归约,而该归约在三个 driver 各有一份;linter 里再抄第四份正是 engine-delete-dispatch.ts 被抽出来防的事故。已单独立 #5659
filter: { $or: [] } / { $not: {} } 不判(正确) 恒等元为 FALSE,一行都不匹配,告警会是假警

#3810 不重复:两者判不同的事

事实 判定方 时机 结论
作者写过的条件消失了 #3810 filter 守卫 运行期 拒绝执行
从来没写过条件 本规则 authoring 期 告警

crud-nodes.ts 的注释与 crud-filter-guard.test.ts 都明写那条守卫「deliberately keyed on 'a condition the author wrote is gone', not on 'the filter is empty'」,并专门钉住「an intentionally empty filter is still allowed」。所以两者互不覆盖:filter: { owner: '{record.ownr}' } 在本规则处沉默、在那条守卫处被拒;完全没有 filter 的节点在本规则处告警、在那条守卫处——正确地——被放行。诊断文案点名对方,并说明各自判什么。

⚠️ 点名是单向的:本规则的 hint 指向 #3810 守卫,反向那半需要改 crud-nodes.ts 的 refusal 文案,而本单派发明确 ⛔ 不动该文件,故未做。若要补,是一行文案改动,请 PM 定夺(未另立 issue,以免为自家一行遗漏散单)。

验证

先红后绿(方向预先声明:6 条正向用例应因「0 findings」变红,9 条 false-positive guard 断言的是「不报」,规则关掉时会空洞地绿——所以证明力全在正向那 6 条):

# 规则调用点临时注释掉
Tests  6 failed | 9 passed | 79 skipped
AssertionError: expected [] to have a length of 1 but got +0
# 恢复
Tests  15 passed | 79 skipped

用例覆盖:delete/update 各一条、filter: {} 一条(并断言文案区分「no filter key」与「an EMPTY filter」)、#3810 互相点名断言、loop body 嵌套(scope 前缀 flow 'campaign_enrollment' · loop 'loop_leads' body · node 'sweep' (delete_record))、两层嵌套 update、以及 8 组 false-positive guard。

pnpm --filter @objectstack/lint test        → 59 files / 1373 tests passed
pnpm --filter @objectstack/lint typecheck   → tsc --noEmit 无输出
pnpm --filter @objectstack/lint build       → OK
pnpm --filter @objectstack/cli test         → 82 files / 812 tests passed(消费半径)
npx eslint <改动文件> --no-inline-config     → exit 0
node scripts/check-nul-bytes.mjs            → OK(另做 grep -naP 控制字符自查,clean)

showcase 清扫流 A/B(真 before:revert 源码 → 重建 dist → 重跑),三个 example app 逐行对照:

diff BEFORE.txt AFTER.txt
1c1
< [app-showcase]   ✓ Validation passed (971ms)
> [app-showcase]   ✓ Validation passed (914ms)
52c52 / 66c66  (app-crm / app-todo 同,仅耗时数字)

67 条告警两侧完全一致,零新增

并做了正向对照,免得「零新增」是空的:临时删掉 showcase 清扫流的 filter 后重跑 os validate,规则确实穿透到 CLI 面,且校验仍然 PASS(warning 不 gate):

✓ Validation passed (898ms)
⚠ flow 'showcase_inquiry_purge' · node 'purge' (delete_record): declares `multi: true` with no `filter` key
  — this is a WHOLE-OBJECT write, by declaration: every row of 'showcase_inquiry' is deleted on every run. …

filter 放回后该告警消失(0 命中)。

范围外发现

changeset:.changeset/flow-unbounded-bulk-write-lint.md(@objectstack/lint": patch)。


Generated by Claude Code

claude added 2 commits August 5, 2026 22:53
…5482)

`config: { objectName: 'lead', multi: true }` with no `filter` is a
WHOLE-OBJECT write: the executor forwards `where: {}` plus the bulk intent,
the engine classifies it as a legal `multi` call, and it lands on
`deleteMany`/`updateMany` with no predicate. Reachable only since #5393 gave
these nodes a bulk declaration, and silent ever since — the author's only
feedback was the step's `acted` count, after the rows were gone.

`flow-multi-write-unfiltered` says it at authoring time. A warning, not a
gate: the engine's dispatch case-set lists "bulk intent, no predicate" as a
valid call, so an explicit purge is an intent the platform grants — which is
also why this is not a spec refine.

Not a second copy of the #3810 run-time guard: that one refuses a node when a
condition the author WROTE interpolated to nothing, and is deliberately keyed
on "a written condition is gone" rather than "the filter is empty". The two
judge different facts, and the diagnostic names the other one so they are not
mistaken for one check.

Reported at every nesting depth (#5383/#5635), because a loop-body sweep is
the standard janitor shape. Empty combinator arrays are deliberately out of
range: #5322/#5134 already ruled their identities, and deciding them here
would be a fourth hand-written copy of a producer-side reduction.
`update_record` has no extracted dispatch module — only delete's is
case-set-pinned (`engine-delete-dispatch.ts`). The message used delete's
wording for both, which credited update with a case-set that does not exist.
Each node type now carries its own authority: delete cites the case-set,
update cites the `options.multi` branch (whose refusal fires only when the
declaration is absent), and both tests assert their own.
@vercel

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

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

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

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants