Skip to content

Commit 0ecc656

Browse files
os-zhuangclaude
andauthored
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>
1 parent ed77493 commit 0ecc656

13 files changed

Lines changed: 640 additions & 103 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
feat(lint): an action body's discarded `ctx.record` write warns at author time (#4345)
8+
9+
`#4344` deliberately left `ctx.record` alone, and said why: an action's
10+
`ctx.record` is a plain snapshot (`unwrapProxyToPlain(actionCtx?.record)`) that
11+
`boundActionHandler` never writes back — the hook path's
12+
`applyMutationsToInput` has no action-side counterpart — so `ctx.record.x = …`
13+
is discarded for **declared and undeclared fields alike**. Reporting that
14+
through the unknown-field rule would have been actively wrong: flagging only
15+
the undeclared half implies the declared half persists, which is the false
16+
completion this rule family exists to stop manufacturing. It needed its own
17+
finding, and now has one.
18+
19+
**New rule — `action-record-write-discarded` (advisory).**
20+
21+
**It is not "flag every `ctx.record.<field>` assignment"** — that would be a
22+
false-positive machine, because mutating the snapshot to build a payload is a
23+
legitimate idiom:
24+
25+
```js
26+
ctx.record.stage = 'won';
27+
await ctx.api.object('crm_deal').update(ctx.record); // the write is LIVE
28+
```
29+
30+
So the finding requires the write to be **provably dead**: reported only when
31+
`ctx.record` never escapes the body as a value. Property reads
32+
(`ctx.record.id`) do not rescue a write and do not suppress the finding;
33+
handing the object to anything — an argument, an assignment RHS, a spread, a
34+
return — does. Aliasing (`const r = ctx.record`) reads as an escape, which is
35+
the safe direction: it costs a missed finding, never a false one.
36+
37+
Truthiness and type tests are **not** escapes, and that distinction is what
38+
makes the rule fire on real code rather than almost never. Running it against
39+
the showcase app is what surfaced it: `mark_done` opens with
40+
`ctx.recordId || (ctx.record && ctx.record.id)`, the defensive idiom action
41+
bodies are actually written with, and counting that guard as an escape silenced
42+
the finding on the one body in the repo that had a record write. A test reads
43+
the reference and yields a boolean — or, for `&&`/`||`/`??`, yields the left
44+
operand only when it is falsy, which is null or undefined and persists nothing.
45+
Only the LEFT operand is a test: `x || ctx.record` really does evaluate to the
46+
object, and still escapes.
47+
48+
**One suite member, two rule ids.** Both findings fall out of one parse of one
49+
source on one surface, so `validateActionBodyWrites` reports both rather than
50+
`REFERENCE_INTEGRITY_RULES` growing a second member that would parse every
51+
action body again to say two things about the same walk. The alternative —
52+
hand-wiring it into the three CLI commands — is the drift that suite exists to
53+
end, and `validateReadonlyFlowWrites` is the standing proof: wired into
54+
`validate` and `compile`, never into `lint`. The trade-off is written down at
55+
both ends rather than left to be rediscovered.
56+
57+
**The ledger ratchet fired, as designed.** `record-property-assign` joins the
58+
shared `HOOK_BODY_WRITE_PATTERNS` — the extractor's shape inventory, not any
59+
one rule's — and both existing consumers had to classify it before it could
60+
land. That was not cosmetic on the hook side: a `record-property-assign` write
61+
carries no `object`, and `validateHookBodyWrites` branched on exactly that to
62+
mean "a `ctx.input` write", so the new shape would have been reported as *"the
63+
hook writes 'stage' to its input"*. The hook rule now declares its own
64+
consumed subset (`HOOK_BODY_WRITE_PATTERN_IDS`) and its exclusion with a
65+
reason — a hook sandbox context has no `ctx.record` at all
66+
(`buildSandboxContext` never sets it), so the expression throws at run time
67+
rather than silently no-op'ing, and a loud failure is not an advisory rule's
68+
business.
69+
70+
`extractHookBodyWriteSet` is the new one-parse entry point, returning the
71+
writes plus the `ctxRecordEscapes` signal; `extractHookBodyWrites` stays as a
72+
thin projection of it.
73+
74+
**Boot path.** The action gate's prefilter widens from `api` to `api`-or-
75+
`record`, so a body reaching neither still never loads the ~9 MB TypeScript
76+
compiler. `lazy-deps.test.ts` pins it — and its header and two case names,
77+
which still claimed every lazy dep waited on "a react page", now say which
78+
trigger each one pins (typescript has also been loaded by the hook-body gate
79+
since #4271).
80+
81+
`@objectstack/spec` / `@objectstack/runtime`: `ScriptBodySchema`,
82+
`ActionSchema.body` and `ScriptContext.record` now state that
83+
`ctx.api.object(...)` is the only path that persists anything, and that
84+
`ctx.record` is read-only in effect. Doc comments only — no schema or
85+
generated-artifact change. Whether the runtime should instead refuse or honour
86+
a record write stays open on #4345.

content/docs/automation/hook-bodies.mdx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,16 @@ Static validation around a hook is asymmetric, and it is worth knowing exactly w
123123
- **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.
124124
- **Checked — capability side.** `body.capabilities` gates which `ctx` APIs the body may call at all; the sandbox throws on an undeclared call.
125125
- **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.
126127

127-
Three literal write shapes are recognized, and only these:
128+
Four literal write shapes are recognized, and only these:
128129

129130
| Write shape | Hook body | Action body |
130131
|---|---|---|
131132
| `ctx.input.<field> = …` / `ctx.input['<field>'] ⟨op⟩= …` (including `+=`, `??=`, …) | checked | not checked — an action's `ctx.input` is its **params bag**, not a record |
132133
| `Object.assign(ctx.input, { <field>: … })` | checked | not checked — same surface |
133134
| `ctx.api.object('<literal>').insert\|create\|update({ <field>: … })`, `.updateById(id, { <field>: … })` | checked | checked |
135+
| `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 |
134136

135137
**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:
136138

@@ -139,7 +141,8 @@ Three literal write shapes are recognized, and only these:
139141
- `ctx.input` writes in a wildcard (`object: '*'`) hook — there is no single target to resolve against;
140142
- 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;
141143
- 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.
143146

144147
System/audit columns and the flat-input envelope keys (`id`, `options`, `ast`, `data`) are never flagged.
145148

@@ -170,6 +173,14 @@ Because the checking is advisory and literal-only:
170173

171174
Hooks mutate `ctx.input`/`ctx.result`; actions return their output value explicitly.
172175

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:
177+
178+
```js
179+
await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });
180+
```
181+
182+
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+
173184
### Engine
174185

175186
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.

packages/lint/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,14 +261,19 @@ export type {
261261
export {
262262
validateHookBodyWrites,
263263
extractHookBodyWrites,
264+
extractHookBodyWriteSet,
264265
HOOK_BODY_WRITE_PATTERNS,
266+
HOOK_BODY_WRITE_PATTERN_IDS,
267+
HOOK_BODY_WRITE_EXCLUSIONS,
265268
HOOK_BODY_WRITE_UNKNOWN_FIELD,
266269
} from './validate-hook-body-writes.js';
267270
export type {
268271
HookBodyWriteFinding,
269272
HookBodyWriteSeverity,
270273
HookBodyWritePattern,
274+
BodyWritePatternExclusion,
271275
ExtractedHookBodyWrite,
276+
ExtractedHookBodyWriteSet,
272277
} from './validate-hook-body-writes.js';
273278

274279
// The same write-set check on action bodies — same schema, same sandbox, same
@@ -279,8 +284,11 @@ export {
279284
validateActionBodyWrites,
280285
ACTION_BODY_WRITE_PATTERNS,
281286
ACTION_BODY_WRITE_PATTERN_IDS,
287+
ACTION_RECORD_WRITE_PATTERNS,
288+
ACTION_RECORD_WRITE_PATTERN_IDS,
282289
ACTION_BODY_WRITE_EXCLUSIONS,
283290
ACTION_BODY_WRITE_UNKNOWN_FIELD,
291+
ACTION_RECORD_WRITE_DISCARDED,
284292
} from './validate-action-body-writes.js';
285293
export type {
286294
ActionBodyWriteFinding,

packages/lint/src/lazy-deps.test.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// Boot-path contract: importing @objectstack/lint must NOT load its heavy,
4-
// gate-only dependencies. The package sits on the kernel boot path, while
5-
// each dep below serves a gate that only runs when a `kind:'react'` page is
6-
// actually validated — so each must load lazily, on first use:
4+
// gate-only dependencies. The package sits on the kernel boot path, while each
5+
// dep below serves gates that only run when a particular, uncommon piece of
6+
// metadata is actually validated — so each must load lazily, on first use:
77
// - `typescript` (~9 MB, and has been pruned from production images before
8-
// — see validate-react-page-props.ts), loaded by the react-props gate;
8+
// — see validate-react-page-props.ts), loaded by the react-props gate AND
9+
// by the L2 body write-set gates (validate-hook-body-writes.ts since
10+
// #4271, validate-action-body-writes.ts since #4345);
911
// - `sucrase` (~1.5 MB), loaded by the react syntax gate
1012
// (validate-react-pages.ts).
1113
//
14+
// "A react page" was the whole story when this file was written; it is not any
15+
// more, and the cases below say which trigger they are pinning. Keep them
16+
// named that way — a test called "until a react page is validated" that also
17+
// covers hook and action bodies is a test nobody can read against its subject.
18+
//
1219
// Guarded at three levels because vitest inlines static imports through its
1320
// transform (they never hit the native require cache), so an in-worker
1421
// require.cache probe alone CANNOT catch a reintroduced eager import:
1522
// 1. structural — no src file may eagerly `import ... from` a lazy dep
1623
// (only `import type`, which erases at build);
1724
// 2. built dist — child `node` processes import both dist formats and prove
18-
// each dep is absent until a react page is validated (skipped when dist
25+
// each dep is absent until the metadata that needs it is validated (skipped when dist
1926
// has not been built);
2027
// 3. behavioral — each lazy path really loads its dep on demand and the
2128
// gate still produces findings.
@@ -62,8 +69,8 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
6269
});
6370

6471
// Reused by both dist probes: import the dist entry, prove no lazy dep came
65-
// with it, then run each react-page gate and prove exactly its own dep loads
66-
// — and that the gate still produces its finding.
72+
// with it, then run each gate and prove exactly its own dep loads — and that
73+
// the gate still produces its finding.
6774
const reactStack = (source: string) => `{ pages: [{ name: 'r', kind: 'react', source: ${JSON.stringify(source)} }] }`;
6875
const childBody = `
6976
const probe = require('node:module').createRequire(process.cwd() + '/probe.js');
@@ -79,7 +86,7 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
7986
if (loaded('typescript')) fail('the hook-body write gate on an L1-only stack must not load typescript');
8087
mod.validateActionBodyWrites(jsAction('input.x > 0', 'expression'));
8188
mod.validateActionBodyWrites(jsAction('ctx.input.amout = 1;'));
82-
if (loaded('typescript')) fail('the action-body write gate must not load typescript for a body that never touches ctx.api');
89+
if (loaded('typescript')) fail('the action-body write gate must not load typescript for a body that reaches neither ctx.api nor ctx.record');
8390
const syntax = mod.validateReactPages(${reactStack('function Page(){ return <div>oops; }')});
8491
if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation');
8592
if (loaded('typescript')) fail('the syntax gate must not load typescript');
@@ -89,14 +96,16 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
8996
if (!hookWrites.some((f) => f.rule === 'hook-body-write-unknown-field')) fail('hook-body write gate produced no finding');
9097
const actionWrites = mod.validateActionBodyWrites(jsAction("await ctx.api.object('a').update({ amout: 1 });"));
9198
if (!actionWrites.some((f) => f.rule === 'action-body-write-unknown-field')) fail('action-body write gate produced no finding');
99+
const recordWrites = mod.validateActionBodyWrites(jsAction("ctx.record.amount = 1;"));
100+
if (!recordWrites.some((f) => f.rule === 'action-record-write-discarded')) fail('action-body record-write gate produced no finding');
92101
const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')});
93102
if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation');
94103
if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding');
95104
console.log('OK');
96105
};
97106
`;
98107

99-
it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist does not load a lazy dep until a react page is validated', () => {
108+
it.skipIf(!existsSync(join(distDir, 'index.cjs')))('built CJS dist loads no lazy dep at import time, and each only on its own trigger', () => {
100109
const out = execFileSync(
101110
process.execPath,
102111
['-e', `${childBody}; check(require(${JSON.stringify(join(distDir, 'index.cjs'))}));`],
@@ -105,7 +114,7 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
105114
expect(out).toContain('OK');
106115
}, COLD_LOAD_TIMEOUT_MS);
107116

108-
it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist does not load a lazy dep until a react page is validated', () => {
117+
it.skipIf(!existsSync(join(distDir, 'index.js')))('built ESM dist loads no lazy dep at import time, and each only on its own trigger', () => {
109118
const out = execFileSync(
110119
process.execPath,
111120
[
@@ -136,15 +145,16 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
136145
expect(validateHookBodyWrites({ hooks: [hook(undefined)] })).toEqual([]);
137146
expect(validateHookBodyWrites({ hooks: [hook({ language: 'expression', source: 'input.x > 0' })] })).toEqual([]);
138147
expect(validateHookBodyWrites({ hooks: [hook({ language: 'js', source: 'return 1;' })] })).toEqual([]);
139-
// Action bodies narrow it further: the only pattern the action rule carries
140-
// is rooted at `ctx.api`, so even an L2 body that writes params never parses.
148+
// Action bodies narrow it further: both patterns the action rule carries
149+
// are rooted at `ctx.api` or `ctx.record`, so an L2 body that only writes
150+
// params never parses at all.
141151
const action = (body: unknown) => ({ name: 'act', label: 'Act', objectName: 'a', body });
142152
expect(validateActionBodyWrites({ actions: [action(undefined)] })).toEqual([]);
143153
expect(validateActionBodyWrites({ actions: [action({ language: 'expression', source: 'input.x > 0' })] })).toEqual([]);
144154
expect(
145155
validateActionBodyWrites({
146156
objects: [{ name: 'a', fields: { amount: {} } }],
147-
actions: [action({ language: 'js', source: 'ctx.input.amout = 1; ctx.record.nope = 2;' })],
157+
actions: [action({ language: 'js', source: 'ctx.input.amout = 1; return { ok: true };' })],
148158
}),
149159
).toEqual([]);
150160
for (const dep of LAZY_DEPS) {
@@ -177,6 +187,13 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
177187
expect(actionWrites.some((f) => f.rule === 'action-body-write-unknown-field' && f.severity === 'warning')).toBe(
178188
true,
179189
);
190+
const recordWrites = validateActionBodyWrites({
191+
objects: [{ name: 'a', fields: { amount: {} } }],
192+
actions: [action({ language: 'js', source: 'ctx.record.amount = 1;' })],
193+
});
194+
expect(recordWrites.some((f) => f.rule === 'action-record-write-discarded' && f.severity === 'warning')).toBe(
195+
true,
196+
);
180197

181198
const props = validateReactPageProps({
182199
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }],

0 commit comments

Comments
 (0)