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 test 59 passed,1382 passed
pnpm --filter @objectstack/lint typecheck 绿
pnpm --filter @objectstack/lint build 绿
消费半径 pnpm --filter @objectstack/cli test 83 passed,825 passed
消费半径 pnpm --filter @objectstack/metadata-protocol test 43 passed,402 passed
examples 三 app os validate A/B 逐行相同(仅计时差)
node scripts/check-nul-bytes.mjs OK (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-dev and 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

vercel Bot commented Aug 6, 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 6, 2026 12:58am

Request Review

@github-actions

github-actions Bot commented Aug 6, 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.

@github-actions github-actions Bot 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 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