Skip to content

feat(lint): 判红「声明了但运行期只会跳过」的 sharing-rule condition (#4698) - #4985

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4698-declared-never-read-lint
Aug 3, 2026
Merged

feat(lint): 判红「声明了但运行期只会跳过」的 sharing-rule condition (#4698)#4985
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4698-declared-never-read-lint

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4698

范围已由 PM 收窄:只做 packages/lint 内可判定的部分。tenant-scoped unique-index 那条落在 objectql/driver-sql,不在本 PR(核实结论见下)。

判据:为什么是这一面,而不是「未读键」通用检查

议题给的不变式很好 —— a key that nothing reads should not validate clean —— 难的不是这句话,是谓词。「这个键被读了吗」只有在消费面的决策过程拿得到手时才是 lint 能回答的问题;多数时候拿不到:仓内 grep 找不到 reader 不构成没有消费者的证据(#4604 登记里那条方法学),消费者还可能住在另一个包、另一个仓,或者一个还没人装的插件里(#4914 的 hot-reload 案例)。

sharing rule 的 condition判据精确的那一例,这也是它入选、邻居被推迟的全部理由:

stack 声明的 sharingRules[].condition 的唯一运行期消费者是 bootstrapDeclaredSharingRules(plugin-sharing),而它对这个键做的唯一一件事compileCelToFilter(condition, { variables: {} })。降不下来的 condition 不是被降级、不是被部分应用、也不是被延后 —— 整条规则被 SKIP(ADR-0049:绝不当作 match-all 种下去),永远进不了 sys_sharing_rule,一条 sys_record_share 都不会物化,现场只有启动日志里一行 WARN。

所以这条规则不建模消费者、不猜、不 grep。它调用消费者自己的决策过程,同一个包(@objectstack/formula)、同一个输入、同一组 options。判定与 seeder 逐位一致 —— 没有会漂移的启发式,也没有「误报但不是真跳过」的可能。

两条 rule id

id reason 形状 修法
sharing-rule-unlowerable-condition unsupported 函数调用(has(...) / size(...))、算术、三元、跨对象路径 改写进可下推子集
sharing-rule-runtime-variable-condition unresolved-variable 读了 current_user.* 换机制(RLS),不是换拼法

分成两个 id 而不是一个 id 两条文案:两者成因不同、修法不同,而 allowlist 和 --json 消费者是按 id 认的。

第一条是议题实测的那一例:作者照着对象校验规则(那里是解释执行)正确的写法写 has(record.x),到了这里(编译执行)就成了静默失效的安全元数据。第二条是 criteria sharing rule 被物化的必然结果 —— seeder 编译出一份静态 criteria_json,evaluator 据此写 sys_record_share,编译时根本不存在「当前用户」。

parse-error 刻意不报:CEL 语法是 validateStackExpressions 的地盘,它已经在 gate 同一个字段,而且它的报错是专门写语法的,更好。一个字段,一句抱怨。

为什么是 error

SharingRuleSchema 自己的 docblock 立了这个 flag:

The whole authorable surface is enforced — nothing here validates and then silently does nothing (ADR-0078).

在这条 PR 之前,这句话对这个 shape 的每一部分都成立,唯独对承载作者意图的那一个字段不成立。按 lint-flow-patterns.ts 顶部写明的严重度标准 —— 没有任何一种读法下元数据按它写的那样行为,就 gate —— 这是 error。它 fail closed(收件人少看见,而不是多看见),那是它能一直活着的原因,不是它可以接受的原因。

双向证明

改后判红,并点名声明位置(path: sharingRules[0].conditionwhere: sharing rule "x" on object "y"),报文说清楚启动时会发生什么(SKIPS the rule at boot / never written to sys_sharing_rule),而不只是「unsupported」。

真实被读的必须保持绿(误伤比漏报更糟)—— 覆盖比较运算、in&&/||/!== null、字符串方法,以及examples/app-showcaseapp-crm 原样抄来的每一条 shipped condition

判据即消费者的判据,不是它的模型 —— 一个共享语料上双向断言 lint 干净 ⟺ compileCelToFilter({ variables: {} }) 成功:

const seederWouldSeed = compileCelToFilter(source, { variables: {} }).ok;
const lintIsClean = validateSharingRuleEnforceability(ruleWith(source)).length === 0;
expect({ source, lintIsClean }).toEqual({ source, lintIsClean: seederWouldSeed });

「改前判绿」这一半做成机械的,不是散文。议题的抱怨正是这个 stack 能过整条工具链,所以只证明新规则判红不够,还得证明此前没有任何东西判过它。测试里跑一遍完整的 authoring registry,要求除本规则外每一条都保持沉默:

✓ no OTHER author-time rule sees this — which is exactly why the gate was missing

这句断言会一直成立下去:哪天有别的规则长到覆盖这个 shape,这条测试就会红,逼人决定归谁管,而不是让 stack 悄悄多出一条重复诊断。

上线前实测:现存代码有没有被判红

没有。isPushdownableCel / compileCelToFilter 扫过 examples/packages/ 里每一条 sharing-rule condition 和每一条 RLS using / check 字面量(85 条),全部可下推。新 gate 不会让任何今天能工作的东西变红。

三个实例在当前 main 上的复核

# 实例 现状 归属
1 decision 节点 config.condition(单数) 已被修掉 —— #4414flow-inert-node-condition 已经在判它,schemaless-node-config.zod.ts 的 strict 拒收也带了逐键处方 无需动作
2 tenant-scoped 对象上的表级 { fields: ['name'], unique: true } 仍然活着 —— lintUniqueDeclarations 只在两种声明同时出现时触发,只写表级形式的对象拿不到任何诊断 不在本 PR。落点在 driver-sqlnormalizeDeclaredIndex;而且租户性是 kernel 在注册时注入的、不是作者写的,该规则自己的注释就说明了 lint 判不了 —— 归 engine 车道,请 PM 移交立单
3 sharing-rule condition 里的 has(...) 仍然活着 本 PR

刻意不做的三件事(已立单,没有猜)

  • 不重实现 isMatchAllCriteria seeder 的第二道闸(降下来但什么都不约束的 filter)住在 plugin-sharing,而 @objectstack/lint 不依赖 runtime。抄一份就是把 rule-criteria.ts 存在的意义 —— 「什么都不约束」的唯一定义 —— fork 掉。而且这道门根本到不了:编译器能降的每一个 AST 都产出具体字段谓词。这个论断在测试里对着编译器 pin,不是写在注释里。
  • 不判 RLS using / checkADR-0056 D4 的 RLS authoring gate 从未接线:isSupportedRlsExpression 没有任何非测试消费者 #4983 同一类、同一个编译器,ADR-0056 D4 还明确要求这道 gate —— 但 RLS 的决策过程 isSupportedRlsExpression 要先经 sqlPredicateToCel 桥接遗留 SQL-ish 子集,两个都住在 plugin-security。在 lint 里判它只有两条路:import 一个 runtime(禁止),或者抄一份桥接(fork 判据 —— 正是本文件开篇拒绝做的事)。顺带一提:isSupportedRlsExpression 除了自己的单测和一行一致性矩阵描述之外没有任何消费者 —— 它自己就是一个 declared-but-never-read 实例,而它恰恰是为了修这个缺陷类写的。
  • 不碰 flow / hook 的 condition 那些是被 CEL 引擎解释执行的,不降成 filter,整个语言都在范围内,「不可下推」在那里什么都不意味着。把这条谓词复用过去会否掉能正常工作的元数据 —— 误伤方向,比缺口更糟。

顺手发现,已立单不修(Prime Directive #10)

验证

pnpm --filter @objectstack/lint test   → Test Files 55 passed · Tests 1007 passed
pnpm --filter @objectstack/lint typecheck → tsc --noEmit,干净
eslint(4 个改动文件)                  → 干净
pnpm --filter @objectstack/cli test    → Test Files 67 passed · Tests 588 passed

(CLI 那轮第一次跑出 31 个 collection 失败,是新 worktree 里工作区依赖没构建导致的假红 —— Failed to resolve entry for package "@objectstack/types";pnpm --filter '@objectstack/cli^...' build 之后全绿。)

改动限于 packages/lint(+ 对 @objectstack/formula / @objectstack/spec 的只读消费),外加一个 changeset。已从合入 #4951#4973 之后的最新 main 开分支,并在推送前 merge 了一次 origin/main(带进了 #4972validate-expressions 的改动,同包重叠,故重跑了整套 lint 测试)。


Generated by Claude Code

claude added 2 commits August 3, 2026 18:38
…4698)

#4698 asks for a "declared but never read" check. The general form is not a
lint question: "is this key read?" is only decidable from authored metadata
when the consumer's decision procedure is in hand, and a repo-wide grep for a
reader is famously not evidence of absence (#4604, #4914).

A sharing rule's `condition` is the case where it IS decidable. Its one runtime
consumer, `bootstrapDeclaredSharingRules`, does exactly one thing with the key:
`compileCelToFilter(condition, { variables: {} })`. A condition that does not
lower is not degraded or partially applied — the rule is SKIPPED, never reaches
`sys_sharing_rule`, and grants nothing, with one boot WARN as the only trace.

So the new rule does not model the consumer. It calls the consumer's own
compiler, from the same package, on the same input, with the same options, and
is pinned in both directions against a shared corpus. Two ids, because the two
authoring mistakes have different fixes:

  - `sharing-rule-unlowerable-condition` — outside the pushdown subset
    (`has(...)`, `size(...)`, arithmetic, ternary, cross-object path). This is
    the issue's measured instance: `has()` is right in an object validation,
    which is interpreted, and wrong here, where the condition is compiled.
  - `sharing-rule-runtime-variable-condition` — reads `current_user.*`. Criteria
    rules are materialised, so there is no current user at compile time; the fix
    is RLS, a different mechanism, not a respelling.

Both `error`, on all three commands, for the ADR-0078 reason SharingRuleSchema's
own docblock states: the whole authorable surface is enforced, and this was the
one field where that sentence was not true. CEL syntax stays with
`expression-invalid` — one field, one complaint.

Measured before shipping: every sharing-rule condition and RLS predicate
declared anywhere in this repo lowers cleanly, so nothing that works today goes
red. The "before" half of the proof is in the test rather than in prose — the
full authoring registry runs over the offending stack and every OTHER rule must
stay silent, which keeps holding as rules are added.

Deliberately out of scope, filed instead of guessed:
  - RLS `using`/`check` (#4983) — same class, and ADR-0056 D4 explicitly asks
    for the gate, but its decision procedure needs `sqlPredicateToCel`, which
    lives in a runtime `@objectstack/lint` must not import. Hoisting the bridge
    into `@objectstack/formula` comes first; copying it would fork the predicate.
  - `validateOrgAxisRedLines` reads sharing-rule keys the spec rejects (#4984).
  - The tenant-scoped declared unique index (issue instance 2) is a driver-sql /
    objectql fix; tenancy is kernel-injected, not authored, so lint cannot judge
    it — the existing rule says so in its own comment.

Co-Authored-By: Claude Fable 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 6:40pm

Request Review

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

github-actions Bot commented Aug 3, 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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

validate/lint have no check for "declared but never read" metadata — three instances found in one app in a day

2 participants