Skip to content

Commit 6a67d7a

Browse files
os-zhuangclaude
andauthored
feat(lint,spec): L2 action body 写不存在字段从盲区变为作者时 lint 告警 (#4271) (#4344)
The write-set lint #4305 gave L2 hook bodies now covers the other surface that carries one. An action body is the same artefact — same `HookBodySchema`, same `HookBodySchema.safeParse` in `actionBodyRunnerFactory`, same QuickJS sandbox — so it fails the same way: `ctx.api.object('crm_deal').update({ stag: 'won' })` succeeds, returns success to the caller, and the unknown column never lands. New rule `action-body-write-unknown-field`, advisory, wired into REFERENCE_INTEGRITY_RULES so `os validate` / `os lint` / `os compile` all report it. Both places the runtime reads actions from are walked (top-level `actions` and `objects[].actions`); a defineStack-merged action, which lives in both, is reported once at its authored path. Only the `ctx.api` write family carries over. An action's `ctx.input` is its PARAMS bag, not a record, so resolving those names against object fields would flag every correctly-named parameter. `ctx.record` is not a write surface either: the runner hands the body a plain snapshot and never writes it back, so `ctx.record.x = …` is discarded for declared and undeclared fields alike — a different defect, and flagging only its undeclared half would imply the declared half persists. So the rule ships a declared PARTITION of the shared HOOK_BODY_WRITE_PATTERNS — ACTION_BODY_WRITE_PATTERN_IDS plus ACTION_BODY_WRITE_EXCLUSIONS, each exclusion carrying its reason — tested to cover the shared ledger exactly, so a fourth pattern landing on the hook side fails this rule's test until someone classifies it. Every applicable pattern is proved end-to-end through the full validator; every exclusion is proved extractable-but-inapplicable. One extractor, one field index, one system-column set, shared rather than copied. The dedupe is by value (bound object + name + body source), not by object identity the way collectBundleActions can afford: the suite runs on the parsed stack and parsing rebuilds every node, so a merged action's two copies arrive distinct-but-equal. An identity check passed the unit fixture and reported the showcase app's one warning twice — caught by running `os validate` on it. Lands one notch tighter than the hook side on the boot path: the only applicable pattern is rooted at `ctx.api`, so an action body that never mentions it does not parse at all, let alone load the ~9 MB TypeScript compiler. Guarded by lazy-deps.test.ts. spec: ScriptBodySchema and ActionSchema.body point at the action-side rule and spell out that ctx.input (params) and ctx.record (a discarded snapshot) are not record-write surfaces. Doc comments only — all 8 generated artifacts verified unchanged. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent c5dd9c6 commit 6a67d7a

10 files changed

Lines changed: 776 additions & 6 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(lint): L2 action-body writes to undeclared fields warn at author time (#4271)
7+
8+
The write-set lint that #4305 gave L2 hook bodies now covers the other surface
9+
that carries one. An action body is the same artefact: the same
10+
`HookBodySchema` union, parsed by the same `HookBodySchema.safeParse` in
11+
`actionBodyRunnerFactory`, run in the same QuickJS sandbox. So it fails the
12+
same way — `ctx.api.object('crm_deal').update({ stag: 'won' })` inside an
13+
action succeeds, returns success to the caller, and the unknown column simply
14+
never lands. Half the surface was still blind.
15+
16+
**New rule — `action-body-write-unknown-field` (advisory).** Wired into
17+
`REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile` all
18+
report it; it never blocks a build. Both places the runtime reads actions from
19+
are walked — top-level `actions` and `objects[].actions` — and a
20+
`defineStack`-merged action, which lives in both, is reported once at its
21+
authored path. That dedupe is by VALUE (bound object + name + body source), not
22+
by object identity the way `collectBundleActions` can afford: the suite runs on
23+
the schema-PARSED stack, and parsing rebuilds every node, so the two copies
24+
arrive as distinct objects that are merely equal. An identity check passes a
25+
shared-reference unit fixture and then reports the showcase app's one warning
26+
twice — which is exactly what it did before the end-to-end run caught it.
27+
28+
**Only the `ctx.api` write family carries over, and that is the point.** An
29+
action's `ctx.input` is its PARAMS bag (`input: unwrapProxyToPlain(actionCtx
30+
?.params)`), not a record, so resolving those names against object fields would
31+
flag every correctly-named parameter — a pure false-positive machine, and a
32+
false positive kills an advisory lint. `ctx.record` is not a write surface
33+
either: the runner hands the body a plain snapshot and never writes it back, so
34+
`ctx.record.x = …` is discarded for *declared* and undeclared fields alike —
35+
a different defect from "the unknown column vanishes", and flagging only its
36+
undeclared half would imply the declared half persists.
37+
38+
So the rule ships a declared **partition** of the shared
39+
`HOOK_BODY_WRITE_PATTERNS` rather than a second ledger:
40+
`ACTION_BODY_WRITE_PATTERN_IDS` (today: `api-crud-literal`) and
41+
`ACTION_BODY_WRITE_EXCLUSIONS` (`input-property-assign`,
42+
`input-object-assign`), each exclusion carrying its reason. The two halves are
43+
tested to cover the shared ledger exactly, so a fourth pattern landing on the
44+
hook side fails this rule's test until someone classifies it — silence is not a
45+
decision. Every applicable pattern is additionally proved end-to-end through
46+
the full validator (prefilter, pattern filter and field check included), and
47+
every exclusion is proved to be about applicability rather than an
48+
unextractable shape: the shared extractor still sees it, and this rule still
49+
reports nothing for it.
50+
51+
One extractor, one field index, one implicit-field set, shared with the hook
52+
rule rather than copied. The action rule is the same check on the other body
53+
surface, so a second copy of `IMPLICIT_FIELDS` would drift exactly the way the
54+
five hand-copied system-field lists #4330 collapsed did.
55+
56+
The lint stays off the kernel boot path, and lands one notch tighter than the
57+
hook side: the only applicable pattern is rooted at `ctx.api`, so an action
58+
body that never mentions it does not even parse, let alone load the ~9 MB
59+
TypeScript compiler. Guarded by `lazy-deps.test.ts`.
60+
61+
`@objectstack/spec`: `ScriptBodySchema` and `ActionSchema.body` now point at
62+
the action-side rule and spell out that `ctx.input` (params) and `ctx.record`
63+
(a discarded snapshot) are not record-write surfaces — doc comments only, no
64+
schema or generated-artifact change.

packages/lint/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,23 @@ export type {
271271
ExtractedHookBodyWrite,
272272
} from './validate-hook-body-writes.js';
273273

274+
// The same write-set check on action bodies — same schema, same sandbox, same
275+
// silent no-op. Its ledger is a declared partition of HOOK_BODY_WRITE_PATTERNS
276+
// (only the `ctx.api` family survives the context change), so the two rules
277+
// share one extractor rather than growing two.
278+
export {
279+
validateActionBodyWrites,
280+
ACTION_BODY_WRITE_PATTERNS,
281+
ACTION_BODY_WRITE_PATTERN_IDS,
282+
ACTION_BODY_WRITE_EXCLUSIONS,
283+
ACTION_BODY_WRITE_UNKNOWN_FIELD,
284+
} from './validate-action-body-writes.js';
285+
export type {
286+
ActionBodyWriteFinding,
287+
ActionBodyWriteSeverity,
288+
ActionBodyWriteExclusion,
289+
} from './validate-action-body-writes.js';
290+
274291
// One entry point for the reference-resolution rules above (#3583 §5 D5).
275292
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
276293
// and `compile` at once — the CLI call sites do not change.

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,21 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
7474
if (loaded(dep)) fail(dep + ' was loaded eagerly, at import time');
7575
}
7676
const jsHook = (source, language) => ({ objects: [{ name: 'a', fields: { amount: {} } }], hooks: [{ name: 'h', object: 'a', events: ['beforeInsert'], body: { language: language ?? 'js', source } }] });
77+
const jsAction = (source, language) => ({ objects: [{ name: 'a', fields: { amount: {} } }], actions: [{ name: 'act', label: 'Act', objectName: 'a', body: { language: language ?? 'js', source } }] });
7778
mod.validateHookBodyWrites(jsHook('input.x > 0', 'expression'));
7879
if (loaded('typescript')) fail('the hook-body write gate on an L1-only stack must not load typescript');
80+
mod.validateActionBodyWrites(jsAction('input.x > 0', 'expression'));
81+
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');
7983
const syntax = mod.validateReactPages(${reactStack('function Page(){ return <div>oops; }')});
8084
if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation');
8185
if (loaded('typescript')) fail('the syntax gate must not load typescript');
8286
if (!syntax.some((f) => f.rule === 'react-page-syntax')) fail('syntax gate produced no finding');
8387
const hookWrites = mod.validateHookBodyWrites(jsHook('ctx.input.amout = 1;'));
8488
if (!loaded('typescript')) fail('typescript was not loaded by an L2 hook-body write validation');
8589
if (!hookWrites.some((f) => f.rule === 'hook-body-write-unknown-field')) fail('hook-body write gate produced no finding');
90+
const actionWrites = mod.validateActionBodyWrites(jsAction("await ctx.api.object('a').update({ amout: 1 });"));
91+
if (!actionWrites.some((f) => f.rule === 'action-body-write-unknown-field')) fail('action-body write gate produced no finding');
8692
const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')});
8793
if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation');
8894
if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding');
@@ -117,7 +123,8 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
117123

118124
it('loads each dep lazily in-process and the gates still work', async () => {
119125
const req = createRequire(import.meta.url);
120-
const { validateReactPages, validateReactPageProps, validateHookBodyWrites } = await import('./index.js');
126+
const { validateReactPages, validateReactPageProps, validateHookBodyWrites, validateActionBodyWrites } =
127+
await import('./index.js');
121128

122129
// Stacks without a react-source page never touch either dep.
123130
expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
@@ -129,6 +136,17 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
129136
expect(validateHookBodyWrites({ hooks: [hook(undefined)] })).toEqual([]);
130137
expect(validateHookBodyWrites({ hooks: [hook({ language: 'expression', source: 'input.x > 0' })] })).toEqual([]);
131138
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.
141+
const action = (body: unknown) => ({ name: 'act', label: 'Act', objectName: 'a', body });
142+
expect(validateActionBodyWrites({ actions: [action(undefined)] })).toEqual([]);
143+
expect(validateActionBodyWrites({ actions: [action({ language: 'expression', source: 'input.x > 0' })] })).toEqual([]);
144+
expect(
145+
validateActionBodyWrites({
146+
objects: [{ name: 'a', fields: { amount: {} } }],
147+
actions: [action({ language: 'js', source: 'ctx.input.amout = 1; ctx.record.nope = 2;' })],
148+
}),
149+
).toEqual([]);
132150
for (const dep of LAZY_DEPS) {
133151
expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source or L2-body validation`).toBe(false);
134152
}
@@ -151,6 +169,15 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
151169
expect(depLoaded(req.cache, 'typescript')).toBe(true);
152170
expect(hookWrites.some((f) => f.rule === 'hook-body-write-unknown-field' && f.severity === 'warning')).toBe(true);
153171

172+
// …and the action gate works on the same terms (#4271 follow-up).
173+
const actionWrites = validateActionBodyWrites({
174+
objects: [{ name: 'a', fields: { amount: {} } }],
175+
actions: [action({ language: 'js', source: "await ctx.api.object('a').update({ amout: 1 });" })],
176+
});
177+
expect(actionWrites.some((f) => f.rule === 'action-body-write-unknown-field' && f.severity === 'warning')).toBe(
178+
true,
179+
);
180+
154181
const props = validateReactPageProps({
155182
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }],
156183
});

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('reference-integrity suite — membership', () => {
2727
'validateAiToolReferences',
2828
'validateAiAgentAuthoring',
2929
'validateHookBodyWrites',
30+
'validateActionBodyWrites',
3031
]);
3132
});
3233

@@ -60,6 +61,18 @@ describe('reference-integrity suite — every member actually runs', () => {
6061
actions: [
6162
// validateObjectReferences: a param pointing at an object nothing declares.
6263
{ name: 'assign', label: 'Assign', params: [{ name: 'owner', reference: 'user' }] },
64+
// validateActionBodyWrites: the L2 body persists a field crm_lead does
65+
// not declare — the action returns success and the column never lands
66+
// (#4271, the action half of the hook rule below).
67+
{
68+
name: 'score_now',
69+
label: 'Score Now',
70+
objectName: 'crm_lead',
71+
body: {
72+
language: 'js',
73+
source: "await ctx.api.object('crm_lead').update({ lead_score: 100 });",
74+
},
75+
},
6376
],
6477
views: [
6578
{
@@ -171,6 +184,7 @@ describe('reference-integrity suite — every member actually runs', () => {
171184
expect(rules).toContain('ai-skill-tool-unresolved');
172185
expect(rules).toContain('agent-authoring-withdrawn');
173186
expect(rules).toContain('hook-body-write-unknown-field');
187+
expect(rules).toContain('action-body-write-unknown-field');
174188
});
175189

176190
it('carries a gating flow-template finding through the suite (#3810)', () => {
@@ -192,9 +206,9 @@ describe('reference-integrity suite — every member actually runs', () => {
192206
expect(typeof f.message).toBe('string');
193207
expect(typeof f.hint).toBe('string');
194208
}
195-
// Object references run first, hook-body writes last.
209+
// Object references run first, action-body writes last.
196210
expect(findings[0].rule).toBe('object-reference-unknown');
197-
expect(findings[findings.length - 1].rule).toBe('hook-body-write-unknown-field');
211+
expect(findings[findings.length - 1].rule).toBe('action-body-write-unknown-field');
198212
});
199213

200214
it('returns nothing for an empty stack', () => {

packages/lint/src/reference-integrity-suite.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
6666
import { validateAiToolReferences } from './validate-ai-tool-references.js';
6767
import { validateAiAgentAuthoring } from './validate-ai-agent-authoring.js';
6868
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
69+
import { validateActionBodyWrites } from './validate-action-body-writes.js';
6970

7071
export type ReferenceIntegritySeverity = 'error' | 'warning';
7172

@@ -118,6 +119,11 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
118119
// read-side membership (#4271). Lazy: only a hook that actually carries a
119120
// `language:'js'` body loads the TypeScript parser.
120121
{ name: 'validateHookBodyWrites', run: validateHookBodyWrites },
122+
// The same check on the other surface that carries a `HookBodySchema` body:
123+
// action bodies, run by the same sandbox. Only the `ctx.api` write family
124+
// carries over — an action's `ctx.input` is its params bag, not a record
125+
// (see that module's ledger). Lazy on the same terms.
126+
{ name: 'validateActionBodyWrites', run: validateActionBodyWrites },
121127
];
122128

123129
/**

0 commit comments

Comments
 (0)