Skip to content

fix(lint): flow-runas-unscoped 跨区域搜索它的数据节点证据 (#5633) - #5695

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-5633-runas-loop-descent
Aug 6, 2026
Merged

fix(lint): flow-runas-unscoped 跨区域搜索它的数据节点证据 (#5633)#5695
os-zhuang merged 3 commits into
mainfrom
claude/issue-5633-runas-loop-descent

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#5633

这是什么

flow-runas-unscoped 是 flow lint 规则族里唯一的 build 门禁成员(severity: 'error')。#5383/PR #5635 给整个规则族接上了 ADR-0031 区域下钻,唯独把这条规则留在只读顶层 nodes——因为收紧一条门禁规则有它自己的爆炸半径,值得单独一单。本 PR 就是那一单。

规则的 dataNode 搜索是「这个 flow 到底有没有做数据操作」的证据;而 loop body 里的数据节点,和顶层的一样 unscoped——运行时 resolveRunAsIdentity 拒绝嵌套写的理由和拒绝顶层写的理由完全相同(#3760),嵌套深度不是它会去查的属性。所以原来漏掉的形状是「build 过、运行必拒」,而这恰恰是这条规则当初升到 error 要防的形状。

更糟的是,漏掉的不是边角,而是定时 flow 的默认形状:查一批、循环、逐条写——写节点几乎总在循环体里。

实测(同一个 flow,只挪动节点位置):

update_record 在顶层 -> 1 finding [error]
update_record 在 loop body -> 0 findings (本 PR 后:1 finding [error])

怎么修的

dataNode 搜索改走 collectFlowGraphs(flow),覆盖任意深度的每个区域;finding 本身保持 flow 级,和以前完全一样:一条/flow,where = flow 'x' · runAs。这一点是设计核心,两个高度分得很清楚:

  • 判定是 flow 级的runAs 是 flow 属性、trigger 是 start 节点的属性,所以「这个 flow 是否 unscoped」每个 flow 只有一个答案——不按区域报,也不按数据节点报。
  • 证据不是 flow 级的。「有没有碰数据」是关于整个 flow 的问题,loop body 和顶层一样属于这个 flow。

区域名字放在 message 里而不是 where 里:where 说哪条声明写错了,message 说去哪儿找证明它的那个节点。

flow 'nightly_sweep' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
schedule run has no trigger user — so its data node 'touch' (update_record), in loop
'loop_rows' body, has no identity to scope to and will be REFUSED at run time.

顶层行为逐字节不变

两条都有测试钉住:

  1. 证据在顶层时,message 逐字节和以前一致(不带区域从句)——现有作者看到的文案没有动过一个字节;
  2. 一个 flow 两个高度都有数据节点时,引用的仍是顶层那个节点(collectFlowGraphs 先产出 flow 自己的图再下钻),即连「引用哪个节点」都没变。

另外 userLessTriggerKindflow.runAs 的 flow 级判定完全没动。

反向验证:先红后绿(方向如预期)

先写测试、后写实现,红的方向和预期一致——嵌套用例产出 0 findings、断言 1 条:

FAIL #5633 ... > flags a loop-body delete_record the same way
AssertionError: expected [] to deeply equal [ 'flow-runas-unscoped' ]
- [ "flow-runas-unscoped" ]
+ []
FAIL #5633 ... > descends two levels — a loop inside a loop
AssertionError: expected [] to have a length of 1 but got +0
Test Files 1 failed | 58 passed (59)
Tests 4 failed | 1374 passed | 4 skipped (1382)

4 条红的正好是 4 条嵌套用例。同一次运行里,顶层孪生用例(逐字节断言 message)、runAs:'system' 反例、非 user-less trigger 反例、无数据节点反例全部已绿——即非回归面在改动前就绿,改动后仍绿。实现后:1382 passed

保险丝:examples 三 app + docs fixtures 的 A/B 均零新增

按 PM 裁定的硬性保险丝逐项实测,真 before(revert 规则源文件 → 重建 packages/lint dist → 重跑,已 grep 确认 dist 里没有新函数):

os validate 三个 app,before/after 逐行 diff,只差 load-time 计时:

--- app-showcase --- 10c10 < Load time: 716ms > Load time: 663ms
--- app-crm --- 10c10 < Load time: 470ms > Load time: 307ms
--- app-todo --- 10c10 < Load time: 285ms > Load time: 220ms

三个都 ✓ Validation passed、exit 0、error 计数 0——零新增 error,零新增 warning

content/docs/** + skills/** 的 flow fixtures 同样 A/B:唯一命中是 flows.mdxcontract_expiration_check,它的证据节点是顶层get_record,改动前后各 1 条、完全一致(是既存的语料缺陷,已另立 #5692,不在本 PR 范围)。

正向对照:error 确实穿透 CLI 面

examples/app-todooverdue_escalation 临时改成「去掉 runAs、唯一数据节点挪进 loop body」,os validate 真的拒绝(exit 1),文案点名区域:

 ✗ Author-time rules failed (1 issue)
• flow 'overdue_escalation' · runAs: schedule-triggered flow runs as `runAs:'user'`, but a
schedule run has no trigger user — so its data node 'update_priority' (update_record), in
loop 'loop_overdue' body, has no identity to scope to and will be REFUSED at run time.
rule: flow-runas-unscoped at flow 'overdue_escalation' · runAs

之后 git checkout -- 还原,重跑确认该 app 回到 ✓ Validation passed;git status 干净。

验证清单

结果
pnpm --filter @objectstack/lint test59 passed,1382 passed
pnpm --filter @objectstack/lint typecheck绿
pnpm --filter @objectstack/lint build绿
消费半径 pnpm --filter @objectstack/cli test83 passed,825 passed
消费半径 pnpm --filter @objectstack/metadata-protocol test43 passed,402 passed
examples 三 app os validate A/B逐行相同(仅计时差)
node scripts/check-nul-bytes.mjsOK (scanned 5609 tracked text file(s))
npx eslint 改动文件零输出
合入 origin/maincheck:generated✓ All 10 generated artifacts are up to date

规则的消费半径已按调用者枚举扫过:cli 的 validate/lint/compile、metadata-protocol 的 runtime authoring gate、以及三处 parity 测试的 fixtures——没有 fixture 拼写了本 PR 收紧掉的形状(用「user-less trigger + 区域」双条件全仓 grep 过,命中的只有 spec/engine 的 schema 与执行测试,它们不跑这条规则)。

changeset

真 changeset("@objectstack/lint": patch),正文明确写了这是一条 build-gating 规则的覆盖面收紧、爆炸半径、以及「如果你的 build 新红了怎么办」(加 runAs: 'system')——release 汇编从它取材。未碰 content/docs/releases/

顺手记录的范围外发现(未在本 PR 修)


🤖 Generated with Claude Code

https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE


Generated by Claude Code

os-devand others added 2 commits August 6, 2026 00:32
…e evidence (#5633)
#5383 gave the flow anti-pattern family a per-region walk and deliberately left
this one rule reading the flow's top-level `nodes` only, because it is the
family's only build-GATING member and widening it has its own blast radius.
Its data-node search is the rule's evidence that the flow performs a data
operation at all, and a data node inside a `loop` body is exactly as unscoped as
one at the top level. So a scheduled flow that queried a set, looped it and wrote
per item — the standard shape, where the write is almost always nested — passed
the build clean and was then refused at run time (#3760). Passing the build and
then being unable to run is precisely what promoting this rule to `error` was for.
The search now runs across `collectFlowGraphs(flow)` at any depth while the
finding stays flow-level: one per flow, `where` = `flow 'x' · runAs`, because
`runAs` is a flow property and the region only supplies the evidence. The region
is named in the message instead, so the author can find the node.
Top-level behaviour is unchanged, bit for bit: the message for a top-level
evidence node is byte-identical (no region clause), and a flow with data nodes at
both altitudes still cites the top-level one, since `collectFlowGraphs` yields
the flow's own graph before it descends. Both pinned by tests.
Verified: `os validate` over app-showcase / app-crm / app-todo is line-for-line
identical before and after; the docs/skills flow fixtures A/B is identical too.
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 6, 2026 12:58am

Request Review

@github-actions

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.

@github-actionsgithub-actionsBot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 6, 2026
…pin it (#5633)
The new fixtures bound the loop item with `itemVar`, which `LoopConfigSchema` —
a `strictObject` — reports as an `unrecognized_key` (the declared key is
`iteratorVariable`; `itemVariable` exists only as a diagnostic alias). Nothing
here would have gone red for it: region collection reads `config.body`, which is
unaffected, so the rule was being proven against a `loop` an author cannot
actually write — the #4966 trap one container down.
Re-spelled to `iteratorVariable`, added the `label` that `FlowNodeSchema`
requires on the body nodes, and pinned the container against the schema in both
directions: the fixture parses green, and the near-miss spelling really is
rejected so the pin has teeth. Full `safeParse` green rather than merely "no
unrecognized keys", because this rule judges a VALUE verdict (`runAs` against the
trigger kind) whose evidence must be a node reachable inside an authorable
container.
Pre-existing fixtures spelling the same key (the #5383 `loopBodyFlow` helper and
the #5482 nested cases) are left alone and filed separately — they are not
exposed by this change.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude