|
| 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 | +} |
0 commit comments