Skip to content

Commit 8c7e7e4

Browse files
os-zhuangclaude
andauthored
fix(cli): keep non-self-contained hook handlers out of body-only lowering (#1876) (#1907)
A hook/action handler referencing a module-scope identifier (helper, import, top-level const) was lowered to a metadata-only `body`; that body ships without the definition and throws `ReferenceError` at runtime — build green, app won't boot (a build↔runtime parity gap, #1876). `extractHookBody` now runs a conservative free-identifier analysis (TS AST via the already-present `ts-morph`): free vars = names referenced but bound neither by the function (params/locals) nor by the runtime (generous global allow-list). Any hit refuses extraction, so `lowerCallables` falls back to BUNDLING the real closure (esbuild carries it along) — no ReferenceError, no build break. Biased to never over-report: a miss preserves today's behavior; a false positive only bundles a self-contained handler (size cost, never a build/correctness failure). Tests: 23-case analyzer battery (flagging + false-positive guards), extractor integration (throws on free var, extracts self-contained), and an end-to-end lowerCallables fallback assertion. All example app builds stay green. Note: the other #1876 repro (legacy object/aggregate widgets) is already closed on main by the ADR-0021 single-form cutover — verified. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent dc8b2de commit 8c7e7e4

6 files changed

Lines changed: 438 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): keep non-self-contained hook/action handlers out of body-only lowering (#1876)
6+
7+
A hook/action handler that references a **module-scope identifier** (a helper,
8+
an import, a top-level const) was lowered to a metadata-only `body` by
9+
`objectstack build` — but that body ships without the referenced definition, so
10+
it throws `ReferenceError` at runtime. Build was green; the app didn't boot —
11+
exactly the build↔runtime parity gap #1876 describes.
12+
13+
`extractHookBody` now runs a conservative free-identifier analysis (via the
14+
`ts` AST already available through `ts-morph`): it computes the handler's free
15+
variables — names referenced but bound neither by the function (params/locals)
16+
nor by the JS runtime (a generous global allow-list). When any are found,
17+
extraction is refused, so `lowerCallables` falls back to **bundling** the real
18+
function (esbuild carries the closure along) — no `ReferenceError`, no build
19+
break. The analysis is biased to never over-report: a missed case preserves
20+
today's behavior, and a false positive only causes a self-contained handler to
21+
be bundled instead of inlined (a size cost, never a correctness or build
22+
failure).
23+
24+
Note: the other #1876 repro — legacy `object`/`aggregate` dashboard widgets
25+
passing build but rejected by the runtime — is already closed on `main` by the
26+
ADR-0021 single-form cutover (`DashboardWidgetSchema` now requires
27+
`dataset`/`values`, enforced by the same schema build and runtime both use).
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
5+
6+
/** Helper: stringify a real function so we test the exact `.toString()` path. */
7+
const src = (fn: (...a: any[]) => any) => String(fn);
8+
9+
describe('detectFreeIdentifiers (#1876 — body self-containment)', () => {
10+
describe('flags module-scope references (would ReferenceError at runtime)', () => {
11+
it('a helper call (arrow)', () => {
12+
const r = detectFreeIdentifiers('(ctx) => { ctx.record.slug = slugify(ctx.record.name); }');
13+
expect(r.unparsed).toBe(false);
14+
expect(r.free).toEqual(['slugify']);
15+
});
16+
17+
it('a top-level const (function expression)', () => {
18+
const r = detectFreeIdentifiers('function h(ctx){ return TAX_RATE * ctx.amount; }');
19+
expect(r.free).toEqual(['TAX_RATE']);
20+
});
21+
22+
it('object-method shorthand form', () => {
23+
const r = detectFreeIdentifiers('handler(ctx){ return fmt(ctx.x); }');
24+
expect(r.free).toEqual(['fmt']);
25+
});
26+
27+
it('an implicit-return arrow', () => {
28+
const r = detectFreeIdentifiers('(ctx) => compute(ctx.a, ctx.b)');
29+
expect(r.free).toEqual(['compute']);
30+
});
31+
32+
it('multiple distinct free names, sorted & de-duped', () => {
33+
const r = detectFreeIdentifiers('(ctx) => { a(ctx); b(ctx); a(ctx); return CONST; }');
34+
expect(r.free).toEqual(['CONST', 'a', 'b']);
35+
});
36+
});
37+
38+
describe('does NOT flag self-contained handlers (false-positive guards)', () => {
39+
const selfContained: Array<[string, string]> = [
40+
['member access only', '(ctx) => { ctx.record.slug = ctx.record.name.toLowerCase(); }'],
41+
['local const', '(ctx) => { const s = ctx.record.name; return s.trim(); }'],
42+
['globals (Math/JSON)', '(ctx) => { ctx.record.id = Math.round(JSON.parse(ctx.x).y); }'],
43+
['destructured params', '({ record, api }) => { record.x = record.y; return api; }'],
44+
['locally-declared helper', '(ctx) => { const f = (a) => a * 2; return f(ctx.n); }'],
45+
['object shorthand of a local', '(ctx) => { const a = ctx.a; return { a }; }'],
46+
['object literal keys', '(ctx) => ({ total: ctx.a, count: ctx.b })'],
47+
['for-of loop binding', '(ctx) => { let sum = 0; for (const x of ctx.items) { sum += x; } ctx.sum = sum; }'],
48+
['catch binding', '(ctx) => { try { ctx.run(); } catch (err) { ctx.log = err; } }'],
49+
['param default uses global', '({ x = Math.PI }) => x'],
50+
['returned object method closes over param', '(ctx) => ({ run() { return ctx.x; } })'],
51+
['element access with local key', '(ctx) => { const k = ctx.key; return ctx.data[k]; }'],
52+
['named function expression recursion', 'function fact(n){ return n <= 1 ? 1 : n * fact(n - 1); }'],
53+
['nested destructuring', '({ a: { b } }) => b + 1'],
54+
['rest params', '(...args) => args.length'],
55+
['typeof a local', '(ctx) => { const v = ctx.v; return typeof v; }'],
56+
];
57+
for (const [label, source] of selfContained) {
58+
it(label, () => {
59+
const r = detectFreeIdentifiers(source);
60+
expect(r.unparsed).toBe(false);
61+
expect(r.free).toEqual([]);
62+
});
63+
}
64+
});
65+
66+
describe('real compiled `.toString()` shapes', () => {
67+
it('does not flag a self-contained closure', () => {
68+
const handler = (ctx: any) => {
69+
const name = String(ctx.record.name ?? '').trim();
70+
ctx.record.slug = name.toLowerCase().replace(/\s+/g, '-');
71+
};
72+
expect(detectFreeIdentifiers(src(handler)).free).toEqual([]);
73+
});
74+
});
75+
76+
it('never invents free vars for non-handler junk (conservative — caller won\'t block)', () => {
77+
// Whether or not TS error-recovers a node, the safe outcome is no free vars
78+
// so extraction is never blocked on garbage. (peelToBlockBody rejects such
79+
// input earlier in the real path anyway.)
80+
expect(detectFreeIdentifiers('this is not a function').free).toEqual([]);
81+
expect(detectFreeIdentifiers('').free).toEqual([]);
82+
expect(detectFreeIdentifiers('{ not: valid').free).toEqual([]);
83+
});
84+
});
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Detect FREE identifiers in a hook/action handler — names the body references
5+
* but that are bound neither by the function (params, locals) nor by the JS
6+
* runtime (globals). The canonical case (#1876, build↔runtime parity) is a
7+
* handler that calls a **module-scope helper**:
8+
*
9+
* const slugify = (s) => s.toLowerCase(); // module scope
10+
* defineStack({ hooks: [{ ..., handler: (ctx) => { ctx.record.slug = slugify(ctx.record.name); } }] });
11+
*
12+
* When such a handler is lowered to a metadata-only `body`, the `slugify`
13+
* reference ships without its definition and throws `ReferenceError` at runtime
14+
* — `objectstack build` is green but the app does not boot. By reporting the
15+
* free identifier the caller can keep the handler OUT of the body-only form and
16+
* fall back to BUNDLING it (esbuild bundles the real closure, so `slugify` comes
17+
* along) — no ReferenceError, no build break.
18+
*
19+
* Safety bias: this analysis is deliberately **conservative**. `bindings`
20+
* over-approximates (every name declared ANYWHERE in the function counts as
21+
* bound), and `GLOBALS` is generous. Both bias toward NOT flagging — a missed
22+
* case merely preserves today's behavior, whereas a false positive would only
23+
* ever cause a self-contained handler to be bundled instead of inlined (a
24+
* size/over-caution cost, never a correctness or build failure). We never
25+
* trade that bias for completeness.
26+
*/
27+
28+
// `ts-morph` is already a CLI runtime dependency and re-exports the full
29+
// TypeScript compiler namespace, so we use its `ts` rather than adding a direct
30+
// `typescript` dependency.
31+
import { ts } from 'ts-morph';
32+
33+
/**
34+
* Identifiers the JS runtime provides ambiently. Generous on purpose — listing
35+
* a name here means "assume the runtime has it" → don't flag → don't over-bundle
36+
* the rare false positive. A genuinely-missing global is a different problem
37+
* (sandbox capability), not a module-scope-helper leak.
38+
*/
39+
const GLOBALS: ReadonlySet<string> = new Set([
40+
// Value/namespace globals
41+
'Math', 'JSON', 'Date', 'Object', 'Array', 'String', 'Number', 'Boolean',
42+
'RegExp', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Promise', 'Symbol', 'BigInt',
43+
'Function', 'Reflect', 'Proxy', 'Intl',
44+
'ArrayBuffer', 'SharedArrayBuffer', 'DataView',
45+
'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
46+
'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array', 'BigInt64Array', 'BigUint64Array',
47+
// Error constructors
48+
'Error', 'TypeError', 'RangeError', 'SyntaxError', 'ReferenceError',
49+
'EvalError', 'URIError', 'AggregateError',
50+
// Global functions
51+
'parseInt', 'parseFloat', 'isNaN', 'isFinite',
52+
'encodeURIComponent', 'decodeURIComponent', 'encodeURI', 'decodeURI',
53+
'structuredClone', 'queueMicrotask', 'atob', 'btoa',
54+
'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval',
55+
// Web-ish that the sandbox / Node commonly provide
56+
'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder', 'console',
57+
// Literal-ish globals & implicit bindings
58+
'undefined', 'NaN', 'Infinity', 'globalThis', 'arguments',
59+
]);
60+
61+
export interface FreeIdentifierResult {
62+
/** Sorted, de-duplicated free identifier names (empty when self-contained). */
63+
free: string[];
64+
/** True when the source could not be parsed into a single function node. */
65+
unparsed: boolean;
66+
}
67+
68+
/**
69+
* Parse `rawFunctionSource` (the result of `String(fn)`) into a single
70+
* function-like node. Handlers come in three `.toString()` shapes — arrow,
71+
* function expression/declaration, and object-method shorthand — so we try
72+
* three wraps and take the first that yields exactly one function-like node.
73+
*/
74+
function parseFunction(rawFunctionSource: string): ts.FunctionLikeDeclarationBase | null {
75+
const wraps = [
76+
rawFunctionSource, // function decl / named function expression statement
77+
`(${rawFunctionSource})`, // arrow / anonymous function expression
78+
`({${rawFunctionSource}})`, // object-method shorthand `name(ctx){...}`
79+
];
80+
for (const code of wraps) {
81+
const sf = ts.createSourceFile('__handler__.js', code, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
82+
let found: ts.FunctionLikeDeclarationBase | null = null;
83+
let count = 0;
84+
const visit = (node: ts.Node): void => {
85+
if (
86+
ts.isArrowFunction(node) ||
87+
ts.isFunctionExpression(node) ||
88+
ts.isFunctionDeclaration(node) ||
89+
ts.isMethodDeclaration(node)
90+
) {
91+
count += 1;
92+
if (!found) found = node;
93+
return; // don't descend — nested functions are part of THIS one's body
94+
}
95+
ts.forEachChild(node, visit);
96+
};
97+
ts.forEachChild(sf, visit);
98+
// Exactly one top-level function-like node means the wrap matched cleanly.
99+
if (found && count === 1) return found;
100+
}
101+
return null;
102+
}
103+
104+
/** Collect every binding name declared ANYWHERE within `fn` (over-approx). */
105+
function collectBindings(fn: ts.FunctionLikeDeclarationBase): Set<string> {
106+
const bound = new Set<string>();
107+
108+
const addBindingName = (name: ts.BindingName | ts.PropertyName | undefined): void => {
109+
if (!name) return;
110+
if (ts.isIdentifier(name)) {
111+
bound.add(name.text);
112+
} else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) {
113+
for (const el of name.elements) {
114+
if (ts.isBindingElement(el)) addBindingName(el.name);
115+
}
116+
}
117+
};
118+
119+
const walk = (node: ts.Node): void => {
120+
if (ts.isParameter(node)) {
121+
addBindingName(node.name);
122+
} else if (ts.isVariableDeclaration(node)) {
123+
addBindingName(node.name);
124+
} else if (ts.isFunctionDeclaration(node) && node.name) {
125+
bound.add(node.name.text);
126+
} else if (ts.isClassDeclaration(node) && node.name) {
127+
bound.add(node.name.text);
128+
} else if (
129+
(ts.isFunctionExpression(node) || ts.isClassExpression(node)) &&
130+
node.name
131+
) {
132+
// A named function/class expression binds its own name in its body.
133+
bound.add(node.name.text);
134+
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
135+
addBindingName(node.variableDeclaration.name);
136+
} else if (ts.isBindingElement(node)) {
137+
addBindingName(node.name);
138+
}
139+
ts.forEachChild(node, walk);
140+
};
141+
142+
// The function's own name (named function decl/expr) is in scope within it.
143+
if (
144+
(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn)) &&
145+
fn.name
146+
) {
147+
bound.add(fn.name.text);
148+
}
149+
for (const p of fn.parameters) addBindingName(p.name);
150+
if (fn.body) walk(fn.body);
151+
// Parameter default initializers may declare nothing but reference things —
152+
// covered by the reference pass. Destructuring defaults are bindings:
153+
for (const p of fn.parameters) ts.forEachChild(p, walk);
154+
155+
return bound;
156+
}
157+
158+
/**
159+
* Collect identifiers used in VALUE position (potential references). Excludes
160+
* the false-positive sources: property-access member names, non-shorthand
161+
* object/class member keys, and statement labels. Binding names that slip
162+
* through are harmless — they are subtracted via `bindings` downstream.
163+
*/
164+
function collectReferences(fn: ts.FunctionLikeDeclarationBase): Set<string> {
165+
const refs = new Set<string>();
166+
167+
const walk = (node: ts.Node): void => {
168+
// Skip type annotations entirely (compiled JS rarely has them, but be safe).
169+
if (ts.isTypeNode(node)) return;
170+
171+
if (ts.isPropertyAccessExpression(node)) {
172+
// `a.b` — visit `a` (could be a ref) but NOT `b` (member name).
173+
walk(node.expression);
174+
return;
175+
}
176+
if (ts.isQualifiedName(node)) {
177+
walk(node.left);
178+
return;
179+
}
180+
if (ts.isPropertyAssignment(node)) {
181+
// `{ key: value }` — `key` is not a ref (unless computed). Visit value;
182+
// visit computed key names.
183+
if (ts.isComputedPropertyName(node.name)) walk(node.name.expression);
184+
walk(node.initializer);
185+
return;
186+
}
187+
if (ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isGetAccessor(node) || ts.isSetAccessor(node)) {
188+
if (node.name && ts.isComputedPropertyName(node.name)) walk(node.name.expression);
189+
ts.forEachChild(node, (c) => { if (c !== node.name) walk(c); });
190+
return;
191+
}
192+
if (ts.isLabeledStatement(node)) {
193+
// The label identifier is not a reference; visit the statement body.
194+
walk(node.statement);
195+
return;
196+
}
197+
if (ts.isBreakOrContinueStatement(node)) {
198+
return; // label, if any, is not a value reference
199+
}
200+
if (ts.isShorthandPropertyAssignment(node)) {
201+
// `{ x }` — x IS a value reference.
202+
refs.add(node.name.text);
203+
return;
204+
}
205+
if (ts.isIdentifier(node)) {
206+
refs.add(node.text);
207+
return;
208+
}
209+
ts.forEachChild(node, walk);
210+
};
211+
212+
if (fn.body) walk(fn.body);
213+
// Parameter DEFAULT initializers are evaluated in scope and may reference
214+
// free identifiers; include them (their binding names are excluded above).
215+
for (const p of fn.parameters) {
216+
if (p.initializer) walk(p.initializer);
217+
}
218+
return refs;
219+
}
220+
221+
/**
222+
* Compute the free identifiers of a handler function source.
223+
* Returns `{ free: [], unparsed: true }` when the source can't be parsed — the
224+
* caller treats "unparsed" as "don't block extraction" (conservative).
225+
*/
226+
export function detectFreeIdentifiers(rawFunctionSource: string): FreeIdentifierResult {
227+
const fn = parseFunction(rawFunctionSource);
228+
if (!fn) return { free: [], unparsed: true };
229+
230+
const bound = collectBindings(fn);
231+
const refs = collectReferences(fn);
232+
233+
const free: string[] = [];
234+
for (const name of refs) {
235+
if (bound.has(name)) continue;
236+
if (GLOBALS.has(name)) continue;
237+
free.push(name);
238+
}
239+
free.sort();
240+
return { free, unparsed: false };
241+
}

packages/cli/src/utils/extract-hook-body.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@
2121
* `ctx.crypto.*` access patterns and add the matching capability tokens to
2222
* `body.capabilities` automatically. Authors can still override by setting
2323
* `// @capabilities api.read api.write` as the first line of the function.
24+
*
25+
* Self-containment (#1876): a handler that references a module-scope identifier
26+
* (helper, import, top-level const) cannot be shipped body-only — the reference
27+
* would `ReferenceError` at runtime. {@link detectFreeIdentifiers} finds those;
28+
* extraction throws so the caller falls back to bundling the real closure.
2429
*/
2530

31+
import { detectFreeIdentifiers } from './detect-free-identifiers.js';
32+
2633
const FORBIDDEN_PATTERNS: Array<{ rx: RegExp; reason: string }> = [
2734
{ rx: /\bimport\s*[\(\*\{]/, reason: 'dynamic `import()` and ES imports are not allowed in hook/action bodies — declare a Connector recipe instead' },
2835
{ rx: /\brequire\s*\(/, reason: '`require()` is not allowed in hook/action bodies' },
@@ -81,6 +88,23 @@ export function extractHookBody(fn: (...a: unknown[]) => unknown, originLabel: s
8188
}
8289
}
8390

91+
// #1876 — a handler that references a MODULE-SCOPE identifier (a helper, an
92+
// imported binding, a top-level const) is NOT self-contained: shipping it as
93+
// a metadata-only `body` would throw `ReferenceError` at runtime (the
94+
// reference ships without its definition). Detect those free identifiers and
95+
// throw — the caller catches this and keeps the handler in the BUNDLED form,
96+
// where esbuild carries the real closure along. The whole `fn` source (params
97+
// included) is analyzed so parameters are correctly in scope.
98+
const { free, unparsed } = detectFreeIdentifiers(raw);
99+
if (!unparsed && free.length > 0) {
100+
throw new Error(
101+
`[hook-body-extract] ${originLabel}: handler references identifier(s) not in scope at runtime: ` +
102+
`${free.join(', ')}. Module-scope helpers/imports aren't shipped with a metadata-only body, so ` +
103+
`this handler will be BUNDLED instead (no behavior change). To make it body-only, inline the ` +
104+
`helper(s) into the handler or move the logic behind \`ctx\` (e.g. \`ctx.api\`).`,
105+
);
106+
}
107+
84108
// Infer capabilities from API surface usage.
85109
const inferred = new Set<ExtractedBody['capabilities'][number]>();
86110
for (const { rx, cap } of CAPABILITY_PATTERNS) {

0 commit comments

Comments
 (0)