feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345) - #4362
Merged
Conversation
#4344 deliberately left `ctx.record` alone and said why: an action's `ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that `boundActionHandler` never writes back — the hook path's `applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …` is discarded for DECLARED and undeclared fields alike. Reporting that through the unknown-field rule would have been actively wrong: flagging only the undeclared half implies the declared half persists. It needed its own finding. New rule `action-record-write-discarded`, advisory. It is NOT "flag every ctx.record assignment" — mutating the snapshot to build a payload is legitimate: ctx.record.stage = 'won'; await ctx.api.object('crm_deal').update(ctx.record); // LIVE So the finding requires the write to be provably dead: `ctx.record` never escapes the body as a value. Property reads do not rescue a write; handing the object to anything — argument, assignment RHS, spread, return — does. Aliasing reads as an escape, the safe direction. Truthiness and type tests are NOT escapes, and that is what makes the rule fire on real code. Running it against the showcase surfaced it: `mark_done` opens with `ctx.recordId || (ctx.record && ctx.record.id)` — the idiom action bodies are actually written with — and counting that guard as an escape silenced the finding on the one body in the repo that had a record write. Only the LEFT operand of &&/||/?? is a test; `x || ctx.record` still escapes. One suite member, two rule ids: both findings fall out of one parse of one source on one surface. A second REFERENCE_INTEGRITY_RULES member would parse every action body twice to say two things about one walk, and hand-wiring it into the three CLI commands is the drift that suite exists to end — validateReadonlyFlowWrites is the standing proof, wired into validate and compile but never into lint. Trade-off written down at both ends. The ledger ratchet fired as designed. `record-property-assign` joins the shared HOOK_BODY_WRITE_PATTERNS — the extractor's shape inventory, not any one rule's — and both consumers had to classify it first. Not cosmetic on the hook side: a record write carries no `object`, and validateHookBodyWrites branched on exactly that to mean "a ctx.input write", so the new shape would have been reported as "the hook writes 'stage' to its input". The hook rule now declares its own consumed subset and its exclusion with a reason — a hook sandbox context has no ctx.record at all, so the expression throws at run time rather than silently no-op'ing, and a loud failure is not an advisory rule's business. extractHookBodyWriteSet is the new one-parse entry point (writes + ctxRecordEscapes); extractHookBodyWrites stays a thin projection. Boot path: the action prefilter widens from `api` to `api`-or-`record`, so a body reaching neither still never loads the ~9 MB TypeScript compiler. lazy-deps.test.ts pins it — and its header plus two case names, which still claimed every lazy dep waited on "a react page", now name the trigger each one pins (typescript has also been loaded by the hook-body gate since #4271). spec/runtime: ScriptBodySchema, ActionSchema.body and ScriptContext.record now state that ctx.api.object(...) is the only path that persists anything and that ctx.record is read-only in effect. Doc comments only — all 8 generated artifacts verified unchanged. Whether the runtime should instead refuse or honour a record write stays open on #4345. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…just rewrote #4351/#4355 rewrote content/docs/automation/hook-bodies.mdx to stop claiming the write side was unchecked. That section lands squarely on this branch's subject, so it is updated here rather than left false for a second time: the write-shape table gains the ctx.record row (and is no longer 'three shapes'), the silent-bail list gains the escape rule, and Signature conventions now says outright that an action's ctx.record is read-only in effect and ctx.api.object(...) is the one way a body persists anything.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 114 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
xuyushun441-sys
pushed a commit
that referenced
this pull request
Jul 31, 2026
#4362(已合并)为 #4345 落了 lint 规则 `action-record-write-discarded`,而且 在一处比本分支原来的规则正确:它要求写是**可证明的死写**(`ctx.record` 不逃逸)。 因为这是活的、确实落库的合法写法: ctx.record.stage = 'won'; await ctx.api.object('crm_deal').update(ctx.record); 本分支原来的 lint 规则会把它误报成"被丢弃",而且断言"stored record is unchanged" —— 那是一句错话,不只是噪音。实测确认后: - 删掉本分支的 `validate-action-record-writes.{ts,test.ts}` 及其全部接线, lint 包与 hook-bodies.mdx 整体采用 main 的版本; - **同一个缺陷也在本分支的运行时层**:它对上面那段一样报 dropped。补了逃逸 检测(写之后出现 `ownKeys` 即视为逃逸,proxy 上观察),语义与 #4362 对齐: 整体消费(交给 ctx.api、展开、返回、JSON.stringify)噤声,属性读不算解救, 所以 showcase 的 `ctx.recordId || (ctx.record && ctx.record.id)` 惯用法 照报。 保留的只剩 #4362 明确留作 open question、且 lint 够不着的那一层:运行时在 调用时报告被丢弃的写 —— 覆盖 computed key、别名、整体替换,以及 Studio / API 写入的 metadata(lint 永远看不到)。它只报告,不 refuse 也不 honour,不预设 那个 open question 的答案。 changeset 相应收窄为 @objectstack/runtime 单包。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4345.
背景
#4344 刻意没碰
ctx.record,并写明了理由:action 的ctx.record是一份普通快照(unwrapProxyToPlain(actionCtx?.record)),boundActionHandler从不写回 —— hook 路径的applyMutationsToInput在 action 侧没有对应物 —— 所以ctx.record.x = …对已声明和未声明字段一样被丢弃。把它塞进 unknown-field 规则会是错的:只报未声明的那一半,等于暗示已声明的那一半会落库,正是这一族规则要消灭的假完成。它需要自己的 finding,现在有了。
新规则
action-record-write-discarded(advisory)它不是"看见
ctx.record.<field> = …就报" —— 把快照当 payload 攒起来是合法写法:所以 finding 要求这个写可证死:
ctx.record在整段 body 里从未作为值流出去。属性读(ctx.record.id)不赦免那些写;把对象本身交出去 —— 当参数、当赋值右值、展开、return —— 才会。别名const r = ctx.record也算流出,方向是安全的:代价是漏报,不是误报。真值判断不算流出 —— 这条是拿 showcase 跑出来的
mark_done开头就是这是 action body 真实的写法。第一版把
ctx.record &&这个守卫当成流出,于是仓库里唯一一段带 record 写的 body 被自己噤声了。真值/类型判断读一下引用、产出一个布尔 —— 或者对&&/||/??而言,只在左操作数为假时产出它,而假值是 null/undefined,什么都持久化不了。只有左操作数是判断:x || ctx.record确实会求值成那个对象,仍然算流出。一个 suite 成员,两个 rule id
两个 finding 出自同一次 parse、同一段源码、同一个面。再加一个
REFERENCE_INTEGRITY_RULES成员,就要把每个 action body 解析两遍去说同一次遍历的两件事;而手工挂进三个 CLI 命令,正是这个 suite 要消灭的 drift ——validateReadonlyFlowWrites就是活证据:挂了validate和compile,唯独漏了lint。取舍在 suite 注释和模块头两处都写下来了,不留给下一个人重新发现。台账棘轮第一次真的救了场
record-property-assign进入共享的HOOK_BODY_WRITE_PATTERNS—— 那是抽取器的形状清单,不属于任何一条规则 —— 两个既有消费者必须先分类,它才能落地。这在 hook 侧不是走过场:record 写没有
object,而validateHookBodyWrites正是靠"没有 object"判定"这是ctx.input写"的,新形状会被报成 "the hook writes 'stage' to its input"。现在 hook 规则也声明了自己消费的子集(HOOK_BODY_WRITE_PATTERN_IDS)和带理由的排除项 —— hook 沙箱上下文压根没有ctx.record(buildSandboxContext从不设置),写它是运行时抛错而不是静默失效;响的失败不归 advisory 规则管。extractHookBodyWriteSet是新的单次解析入口(writes +ctxRecordEscapes),extractHookBodyWrites退化成它的薄投影。boot path
action 侧的预过滤从
api放宽到api或record,两者都不沾的 body 依然连 parse 都不做,更不会加载 ~9 MB 的 TypeScript。lazy-deps.test.ts钉住这条。顺带修掉那份文件里已经过期的表述:头注释和两个 dist 探针的用例名还在声称每个惰性依赖都只等"一个 react page",而 typescript 从 #4271 起就也由 hook body gate 加载了。现在每个用例说清自己钉的是哪个触发点。
文档
content/docs/automation/hook-bodies.mdx两个 commit 前刚被 #4351/#4355 重写过(为了不再声称写面没检查),而那一节正落在本 PR 的题目上,所以在同一个 PR 里更新,而不是让它第二次变成假的:写法表加了ctx.record行(也不再是"三种形状"),静默 bail 清单加了流出规则,Signature conventions 直接讲明 action 的ctx.record实质只读、ctx.api.object(...)是 body 持久化的唯一路径。验证
@objectstack/linttest@objectstack/clitest@objectstack/linttypecheck@objectstack/spec check:generatedos validateScriptContext.record、ScriptBodySchema、ActionSchema.body都写明了实质只读。运行时到底该拒绝还是该兑现这个写,仍然开在 #4345 上 —— 本 PR 只让它在作者时可见,不替那个决定下结论。🤖 Generated with Claude Code