Skip to content

Commit c1d44f7

Browse files
os-zhuangclaude
andauthored
feat(lint,spec): L2 hook body 写不存在字段从 accepted gap 变为作者时 lint 告警 (#4271) (#4305)
* feat(lint,spec): L2 hook-body writes to undeclared fields warn at author time (#4271) An L2 (language:'js') hook body writing a field the target object never declares runs clean in the sandbox, reports success, and the unknown column never lands — the #4001 silent-no-op failure mode at the runtime-expression layer. New advisory rule `hook-body-write-unknown-field` parses the body (never executes it), resolves the literal writes declared in the HOOK_BODY_WRITE_PATTERNS ledger against the target object's declared + system fields, and warns with a did-you-mean. Wired via REFERENCE_INTEGRITY_RULES (validate/lint/compile at once); TypeScript parser loads lazily off the kernel boot path; every ledger entry is reconciliation-tested against the extractor. Statically unknowable writes bail silently — zero false positives over completeness. The hook-body.zod.ts "accepted gap" note now points at the lint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(lint): spell the dedupe separator as the backslash-u0000 escape, not a raw NUL byte The raw byte trips check:nul-bytes (a NUL makes grep treat the file as binary, dropping it out of every grep-based lint); the escape sequence is byte-identical at runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f38cdc commit c1d44f7

8 files changed

Lines changed: 840 additions & 11 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/spec": patch
4+
---
5+
6+
feat(lint): L2 hook-body writes to undeclared fields warn at author time (#4271)
7+
8+
An L2 (`language:'js'`) hook body that writes a field the target object never
9+
declares — `ctx.input.amout = 0`, `ctx.api.object('deal').update({ stag: … })`
10+
— runs clean in the QuickJS sandbox, reports success, and the unknown column
11+
simply never lands in the stored record. No diagnostic anywhere: the #4001
12+
"silent no-op manufactures false completion" failure mode at the
13+
runtime-expression layer. The read side (`hook.condition`) and the capability
14+
surface were already statically checked; the write side was the one blind face,
15+
and `hook-body.zod.ts` carried it as an **accepted gap**.
16+
17+
**New rule — `hook-body-write-unknown-field` (advisory).** `@objectstack/lint`
18+
now parses each L2 body (TypeScript parser; parsed, never executed, never
19+
type-checked) and resolves its literal writes against the target object's
20+
declared + system fields. An unknown field warns with a did-you-mean. Wired
21+
into `REFERENCE_INTEGRITY_RULES`, so `os validate`, `os lint` and `os compile`
22+
all report it; it never blocks a build.
23+
24+
The recognized write shapes are declared as data — `HOOK_BODY_WRITE_PATTERNS`,
25+
each entry carrying a canonical example that a reconciliation test round-trips
26+
through the real extractor, so a pattern cannot be declared-but-unverified
27+
(#3528's death). v1 ships three:
28+
29+
- `ctx.input.<field> = …` / `ctx.input['<field>'] ⟨op⟩= …` → the hook's own
30+
target object(s); flat-input envelope keys (`id`/`options`/`ast`/`data`) are
31+
never treated as record fields.
32+
- `Object.assign(ctx.input, { <field>: … })` → same target.
33+
- `ctx.api.object('<object>').insert|create|update({…})` / `.updateById(id, {…})`
34+
→ the named object, at the **real** `ObjectRepository` payload positions
35+
(`update(data)` — the payload is argument 0, not `update(id, data)`).
36+
37+
Everything statically unknowable is skipped silently, favouring missed findings
38+
over false ones: computed keys, spreads, non-literal payloads, dynamic object
39+
names, wildcard-target (`object:'*'`) input writes, cross-package targets,
40+
aliased input (`const doc = ctx.input`), and multi-target hooks where the field
41+
exists on *some* target (the body may branch per object — only an
42+
everywhere-miss warns).
43+
44+
The lint stays off the kernel boot path: the TypeScript compiler loads lazily,
45+
only when a hook actually carries a JS body (same contract as the react-page
46+
gates, guarded by `lazy-deps.test.ts`).
47+
48+
`@objectstack/spec`: the `ScriptBodySchema` header's "write-set opacity —
49+
accepted static-analysis gap" note now points at the lint instead, and spells
50+
out what remains opaque so the warning's absence is not read as proof of
51+
correctness.

packages/lint/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,19 @@ export type {
258258
AiAgentAuthoringSeverity,
259259
} from './validate-ai-agent-authoring.js';
260260

261+
export {
262+
validateHookBodyWrites,
263+
extractHookBodyWrites,
264+
HOOK_BODY_WRITE_PATTERNS,
265+
HOOK_BODY_WRITE_UNKNOWN_FIELD,
266+
} from './validate-hook-body-writes.js';
267+
export type {
268+
HookBodyWriteFinding,
269+
HookBodyWriteSeverity,
270+
HookBodyWritePattern,
271+
ExtractedHookBodyWrite,
272+
} from './validate-hook-body-writes.js';
273+
261274
// One entry point for the reference-resolution rules above (#3583 §5 D5).
262275
// Adding a rule to `REFERENCE_INTEGRITY_RULES` runs it on `validate`, `lint`
263276
// and `compile` at once — the CLI call sites do not change.

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,16 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
7373
for (const dep of ${JSON.stringify(LAZY_DEPS)}) {
7474
if (loaded(dep)) fail(dep + ' was loaded eagerly, at import time');
7575
}
76+
const jsHook = (source, language) => ({ objects: [{ name: 'a', fields: { amount: {} } }], hooks: [{ name: 'h', object: 'a', events: ['beforeInsert'], body: { language: language ?? 'js', source } }] });
77+
mod.validateHookBodyWrites(jsHook('input.x > 0', 'expression'));
78+
if (loaded('typescript')) fail('the hook-body write gate on an L1-only stack must not load typescript');
7679
const syntax = mod.validateReactPages(${reactStack('function Page(){ return <div>oops; }')});
7780
if (!loaded('sucrase')) fail('sucrase was not loaded by a react-page syntax validation');
7881
if (loaded('typescript')) fail('the syntax gate must not load typescript');
7982
if (!syntax.some((f) => f.rule === 'react-page-syntax')) fail('syntax gate produced no finding');
83+
const hookWrites = mod.validateHookBodyWrites(jsHook('ctx.input.amout = 1;'));
84+
if (!loaded('typescript')) fail('typescript was not loaded by an L2 hook-body write validation');
85+
if (!hookWrites.some((f) => f.rule === 'hook-body-write-unknown-field')) fail('hook-body write gate produced no finding');
8086
const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')});
8187
if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation');
8288
if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding');
@@ -111,14 +117,20 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
111117

112118
it('loads each dep lazily in-process and the gates still work', async () => {
113119
const req = createRequire(import.meta.url);
114-
const { validateReactPages, validateReactPageProps } = await import('./index.js');
120+
const { validateReactPages, validateReactPageProps, validateHookBodyWrites } = await import('./index.js');
115121

116122
// Stacks without a react-source page never touch either dep.
117123
expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
118124
expect(validateReactPageProps({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
119125
expect(validateReactPageProps({ pages: [{ name: 'r', kind: 'react', source: ' ' }] })).toEqual([]);
126+
// Nor do stacks whose hooks carry no L2 JS body — including a JS body that
127+
// never mentions `ctx`/`Object` (the prefilter skips the parse entirely).
128+
const hook = (body: unknown) => ({ name: 'h', object: 'a', events: ['beforeInsert'], body });
129+
expect(validateHookBodyWrites({ hooks: [hook(undefined)] })).toEqual([]);
130+
expect(validateHookBodyWrites({ hooks: [hook({ language: 'expression', source: 'input.x > 0' })] })).toEqual([]);
131+
expect(validateHookBodyWrites({ hooks: [hook({ language: 'js', source: 'return 1;' })] })).toEqual([]);
120132
for (const dep of LAZY_DEPS) {
121-
expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source validation`).toBe(false);
133+
expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source or L2-body validation`).toBe(false);
122134
}
123135

124136
// The first react page with source pays the cost of exactly its own gate's
@@ -130,6 +142,15 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
130142
expect(depLoaded(req.cache, 'typescript'), 'the syntax gate must not load typescript').toBe(false);
131143
expect(syntax.some((f) => f.rule === 'react-page-syntax' && f.severity === 'error')).toBe(true);
132144

145+
// The first hook with an L2 JS body pays the typescript load — and the
146+
// write-set gate works (#4271).
147+
const hookWrites = validateHookBodyWrites({
148+
objects: [{ name: 'a', fields: { amount: {} } }],
149+
hooks: [hook({ language: 'js', source: 'ctx.input.amout = 1;' })],
150+
});
151+
expect(depLoaded(req.cache, 'typescript')).toBe(true);
152+
expect(hookWrites.some((f) => f.rule === 'hook-body-write-unknown-field' && f.severity === 'warning')).toBe(true);
153+
133154
const props = validateReactPageProps({
134155
pages: [{ name: 'r', kind: 'react', source: 'function Page(){ return <ObjectForm mode="edit" />; }' }],
135156
});

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('reference-integrity suite — membership', () => {
2626
'validateAiSurfaceAffinity',
2727
'validateAiToolReferences',
2828
'validateAiAgentAuthoring',
29+
'validateHookBodyWrites',
2930
]);
3031
});
3132

@@ -123,6 +124,16 @@ describe('reference-integrity suite — every member actually runs', () => {
123124
// validateAiToolReferences: a tool name nothing declares, registers, or
124125
// materialises (the HotCRM fictional-tool class).
125126
skills: [{ name: 'metadata_authoring', surface: 'build', tools: ['forecast_revenue'] }],
127+
hooks: [
128+
// validateHookBodyWrites: the L2 body writes a field crm_lead does not
129+
// declare — runs clean in the sandbox, never lands in the record (#4271).
130+
{
131+
name: 'score_lead',
132+
object: 'crm_lead',
133+
events: ['beforeInsert'],
134+
body: { language: 'js', source: "ctx.input.lead_score = 100;" },
135+
},
136+
],
126137
flows: [
127138
{
128139
name: 'lead_followup',
@@ -159,6 +170,7 @@ describe('reference-integrity suite — every member actually runs', () => {
159170
expect(rules).toContain('ai-skill-surface-mismatch');
160171
expect(rules).toContain('ai-skill-tool-unresolved');
161172
expect(rules).toContain('agent-authoring-withdrawn');
173+
expect(rules).toContain('hook-body-write-unknown-field');
162174
});
163175

164176
it('carries a gating flow-template finding through the suite (#3810)', () => {
@@ -180,9 +192,9 @@ describe('reference-integrity suite — every member actually runs', () => {
180192
expect(typeof f.message).toBe('string');
181193
expect(typeof f.hint).toBe('string');
182194
}
183-
// Object references run first, agent-authoring last.
195+
// Object references run first, hook-body writes last.
184196
expect(findings[0].rule).toBe('object-reference-unknown');
185-
expect(findings[findings.length - 1].rule).toBe('agent-authoring-withdrawn');
197+
expect(findings[findings.length - 1].rule).toBe('hook-body-write-unknown-field');
186198
});
187199

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

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
6565
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';
68+
import { validateHookBodyWrites } from './validate-hook-body-writes.js';
6869

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

@@ -111,6 +112,12 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
111112
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
112113
{ name: 'validateAiToolReferences', run: validateAiToolReferences },
113114
{ name: 'validateAiAgentAuthoring', run: validateAiAgentAuthoring },
115+
// Field names WRITTEN by an L2 hook body (`ctx.input.x = …`,
116+
// `ctx.api.object('y').update({ x })`), resolved against the target object's
117+
// declared fields — the write-side counterpart of validateFlowTemplatePaths'
118+
// read-side membership (#4271). Lazy: only a hook that actually carries a
119+
// `language:'js'` body loads the TypeScript parser.
120+
{ name: 'validateHookBodyWrites', run: validateHookBodyWrites },
114121
];
115122

116123
/**

0 commit comments

Comments
 (0)