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

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 自己的图再下钻),即连「引用哪个节点」都没变。

另外 userLessTriggerKind 与 flow.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.mdx 的 contract_expiration_check,它的证据节点是顶层 get_record,改动前后各 1 条、完全一致(是既存的语料缺陷,已另立 #5692,不在本 PR 范围)。

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

把 examples/app-todo 的 overdue_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/main 后 check: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.
@os-zhuang
os-zhuang marked this pull request as ready for review August 6, 2026 01:02
@os-zhuang
os-zhuang enabled auto-merge August 6, 2026 01:02
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit eb26126 Aug 6, 2026
24 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5633-runas-loop-descent branch August 6, 2026 01:20
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