You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345) (#4362)
* feat(lint): action body 里落不了地的 ctx.record 写在作者时告警 (#4345)
#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>
* Merge origin/main; document the ctx.record write in the section #4351 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.
---------
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: content/docs/automation/hook-bodies.mdx
+13-2Lines changed: 13 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -123,14 +123,16 @@ Static validation around a hook is asymmetric, and it is worth knowing exactly w
123
123
-**Checked — read side.**`hook.condition` is validated at build time against the target object's fields by the expression validator (`@objectstack/lint`), including array-valued `hook.object` targets. A condition referencing a nonexistent field fails the lint.
124
124
-**Checked — capability side.**`body.capabilities` gates which `ctx` APIs the body may call at all; the sandbox throws on an undeclared call.
125
125
-**Checked — write side, advisory and literal-only.** Since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271), `body.source` is **parsed** (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations. An unknown field raises `hook-body-write-unknown-field` — a **warning** carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on their `ctx.api` writes (`action-body-write-unknown-field`). Both run under `os validate`, `os lint` and `os compile`.
126
+
-**Checked — writes that reach nothing at all.** Since [#4345](https://github.com/objectstack-ai/objectstack/issues/4345), an action body assigning to `ctx.record` raises `action-record-write-discarded`, also a warning. This one is **not** a field-resolution question: an action's `ctx.record` is a snapshot the runtime never writes back, so the assignment is discarded whether or not the field is declared — see [Signature conventions](#signature-conventions) below.
126
127
127
-
Three literal write shapes are recognized, and only these:
128
+
Four literal write shapes are recognized, and only these:
128
129
129
130
| Write shape | Hook body | Action body |
130
131
|---|---|---|
131
132
|`ctx.input.<field> = …` / `ctx.input['<field>'] ⟨op⟩= …` (including `+=`, `??=`, …) | checked | not checked — an action's `ctx.input` is its **params bag**, not a record |
132
133
|`Object.assign(ctx.input, { <field>: … })`| checked | not checked — same surface |
|`ctx.record.<field> = …` / `ctx.record['<field>'] ⟨op⟩= …`| n/a — a hook context has no `ctx.record` (the expression throws) | checked: warns as **discarded**, declared field or not |
134
136
135
137
**A missing warning is not a clean bill of health.** The rule bails *silently* on everything it cannot resolve statically, deliberately preferring a missed finding to a false one — a false positive kills an advisory lint, while a miss just leaves the gap open a little longer:
136
138
@@ -139,7 +141,8 @@ Three literal write shapes are recognized, and only these:
139
141
-`ctx.input` writes in a wildcard (`object: '*'`) hook — there is no single target to resolve against;
140
142
- multi-target hooks where the field exists on *some* target: a body may legitimately branch per object, so only a field missing on **every** named target is flagged;
141
143
- objects declared by another package;
142
-
- aliased input (`const doc = ctx.input; doc.x = 1`) — v1 does no data-flow analysis.
144
+
- aliased input (`const doc = ctx.input; doc.x = 1`) — v1 does no data-flow analysis;
145
+
-`ctx.record` writes in a body that hands `ctx.record` to anything — an argument, an assignment RHS, a spread, a return. Mutating the snapshot and then persisting it (`ctx.record.stage = 'won'; await ctx.api.object('crm_deal').update(ctx.record)`) is a **live** payload, so the whole body's record writes are skipped rather than guessed at. Truthiness and type guards (`ctx.record && ctx.record.id`, `if (!ctx.record) …`) are not escapes — they cannot persist anything.
143
146
144
147
System/audit columns and the flat-input envelope keys (`id`, `options`, `ast`, `data`) are never flagged.
145
148
@@ -170,6 +173,14 @@ Because the checking is advisory and literal-only:
170
173
171
174
Hooks mutate `ctx.input`/`ctx.result`; actions return their output value explicitly.
172
175
176
+
An action's `ctx` is **not** a hook's. `ctx.input` is the action's **params bag** — validated against its declared `params`, not a record. `ctx.record` is the record the dispatcher pre-fetched, and it is **read-only in effect**: the sandbox receives a plain snapshot and the runtime never writes it back, so `ctx.record.<field> = …` is discarded even for a perfectly valid field name. There is exactly one way an action body persists anything:
Mutating the snapshot *as a payload* and then handing it to such a call is fine — that write is live, and the lint leaves it alone.
183
+
173
184
### Engine
174
185
175
186
The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites.
0 commit comments