/** * Pattern Context Validation Transformer * * Validates code within pattern contexts (pattern, .map on cells/opaques) * to catch common reactive programming mistakes. * * Rules: * - Reading from opaques is NOT allowed in: * - pattern body (top-level reactive context) * - map functions bound to opaques/cells (mapWithPattern) * * - Reading from opaques IS allowed in: * - computed() * - action() * - derive() * - lift() * - handler() * - JSX expressions (handled by OpaqueRefJSXTransformer) * * - Function creation is NOT allowed in pattern context (must be at module scope) * - lift() and handler() must be defined at module scope, not inside patterns * * Errors reported: * - Property access used in computation: ERROR (must wrap in computed()) * - Optional chaining (?.): ERROR (not allowed in reactive context) * - Calling .get() on cells: ERROR (must wrap in computed()) * - Function creation in pattern context: ERROR (move to module scope) * - lift()/handler() inside pattern: ERROR (move to module scope) * - .map() on fallback expression (x ?? [] or x || []): ERROR (use direct property access) */ import ts from "typescript"; import { TransformationContext, Transformer } from "../core/mod.ts"; import { createDataFlowAnalyzer, detectCallKind, isInRestrictedReactiveContext, isInsideRestrictedContext, isInsideSafeCallbackWrapper, isStandaloneFunctionDefinition, } from "../ast/mod.ts"; import { isOpaqueRefType } from "./opaque-ref/opaque-ref.ts"; export class PatternContextValidationTransformer extends Transformer { transform(context: TransformationContext): ts.SourceFile { const checker = context.checker; const analyze = createDataFlowAnalyzer(checker); const visit = (node: ts.Node): ts.Node => { // Skip JSX - OpaqueRefJSXTransformer handles those if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) { return ts.visitEachChild(node, visit, context.tsContext); } // Check for function creation in pattern context if ( ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) ) { this.validateFunctionCreation(node, context, checker); // Check for reactive operations in standalone functions if (isStandaloneFunctionDefinition(node)) { this.validateStandaloneFunction(node, context, checker); } } // Check for optional chaining in reactive context // Note: isInRestrictedReactiveContext returns false for JSX expressions // (they are handled by OpaqueRefJSXTransformer), so this won't flag // optional chaining inside JSX like
{user?.name}
if (ts.isPropertyAccessExpression(node) && node.questionDotToken) { if (isInRestrictedReactiveContext(node, checker)) { context.reportDiagnostic({ severity: "error", type: "pattern-context:optional-chaining", message: `Optional chaining '?.' is not allowed in reactive context. ` + `Use ifElse() or wrap in computed() for conditional access.`, node, }); } } // Check for .get() calls and lift/handler placement in reactive context if (ts.isCallExpression(node)) { // Check for lift/handler inside pattern this.validateBuilderPlacement(node, context, checker); // Check for .map() on fallback expressions (x ?? [] or x || []) // Only in restricted context (pattern body) where this pattern causes runtime failures. // Note: We use isInsideRestrictedContext, not isInRestrictedReactiveContext, because // the map-on-fallback pattern fails even inside JSX expressions (which are "safe" for // other validations but still need this check). if (isInsideRestrictedContext(node, checker)) { this.validateMapOnFallbackExpression(node, context, checker); } // Check for .get() calls if ( this.isGetCall(node) && isInRestrictedReactiveContext(node, checker) ) { context.reportDiagnostic({ severity: "error", type: "pattern-context:get-call", message: `Calling .get() on a cell is not allowed in reactive context. ` + `Wrap the computation in computed(() => myCell.get()) instead.`, node, }); } } // Check for property access used in computation (not just pass-through) // This applies to expressions in binary operators, conditionals, etc. if (this.isComputationExpression(node)) { this.validateComputationExpression(node, context, checker, analyze); } return ts.visitEachChild(node, visit, context.tsContext); }; return ts.visitNode(context.sourceFile, visit) as ts.SourceFile; } /** * Checks if a call expression is a .get() call */ private isGetCall(node: ts.CallExpression): boolean { const expr = node.expression; return ( ts.isPropertyAccessExpression(expr) && expr.name.text === "get" && node.arguments.length === 0 ); } /** * Checks if this node is an expression that performs computation * (binary expression, unary expression, conditional, etc.) */ private isComputationExpression(node: ts.Node): boolean { return ( ts.isBinaryExpression(node) || ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node) || ts.isConditionalExpression(node) ); } /** * Validates that a computation expression doesn't improperly use reactive values */ private validateComputationExpression( node: ts.Node, context: TransformationContext, checker: ts.TypeChecker, analyze: ReturnType, ): void { // Skip if not in restricted reactive context if (!isInRestrictedReactiveContext(node, checker)) { return; } // Skip if inside JSX if (this.isInsideJsx(node)) { return; } // Analyze the expression for reactive dependencies const expression = node as ts.Expression; const analysis = analyze(expression); // If this computation contains reactive refs, it should be wrapped in computed() if (analysis.containsOpaqueRef && analysis.requiresRewrite) { // Find the specific property access that's causing the issue const problemAccess = this.findProblematicAccess(node); const accessText = problemAccess ? `'${problemAccess.getText()}'` : "property access"; context.reportDiagnostic({ severity: "error", type: "pattern-context:computation", message: `Property access ${accessText} used in computation is not allowed in reactive context. ` + `Wrap the computation in computed(() => ...) instead.`, node, }); } } /** * Checks if a node is inside a JSX element */ private isInsideJsx(node: ts.Node): boolean { let current: ts.Node | undefined = node.parent; while (current) { if ( ts.isJsxElement(current) || ts.isJsxSelfClosingElement(current) || ts.isJsxExpression(current) ) { return true; } current = current.parent; } return false; } /** * Finds the first property access expression in the computation */ private findProblematicAccess( node: ts.Node, ): ts.PropertyAccessExpression | undefined { let result: ts.PropertyAccessExpression | undefined; const find = (n: ts.Node): void => { if (result) return; if (ts.isPropertyAccessExpression(n)) { result = n; return; } ts.forEachChild(n, find); }; find(node); return result; } /** * Validates that functions are not created directly in pattern context. * Functions inside safe wrappers (computed, action, derive, lift, handler) * and inside JSX expressions are allowed since they get transformed. */ private validateFunctionCreation( node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, context: TransformationContext, checker: ts.TypeChecker, ): void { // Skip if inside JSX (including map callbacks, event handlers) if (this.isInsideJsx(node)) return; // Skip if inside safe wrapper callback (computed, action, derive, lift, handler) if (isInsideSafeCallbackWrapper(node, checker)) return; // Skip if this function IS a callback to a safe wrapper // e.g., computed(() => ...), action(() => ...), derive(() => ...) if (this.isSafeWrapperCallback(node, checker)) return; // Only error if inside restricted context (pattern/render) if (!isInsideRestrictedContext(node, checker)) return; context.reportDiagnostic({ severity: "error", type: "pattern-context:function-creation", message: `Function creation is not allowed in pattern context. ` + `Move this function to module scope and add explicit type parameters. ` + `Note: callbacks inside computed(), action(), and .map() are allowed.`, node, }); } /** * Checks if a function is being passed directly as a callback to a safe wrapper * (computed, action, derive, lift, handler) or to a .map() call on cells/opaques. */ private isSafeWrapperCallback( node: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, checker: ts.TypeChecker, ): boolean { // Function declarations can't be callbacks if (ts.isFunctionDeclaration(node)) return false; const parent = node.parent; if (!parent || !ts.isCallExpression(parent)) return false; // Check if this function is an argument to the call if (!parent.arguments.includes(node)) return false; const callKind = detectCallKind(parent, checker); if (!callKind) return false; // derive is a safe wrapper if (callKind.kind === "derive") return true; // array-map on cells/opaques is transformed, so callbacks are allowed if (callKind.kind === "array-map") return true; // patternTool handles closure capture for its callback if (callKind.kind === "pattern-tool") return true; // Check builder-based safe wrappers (computed, action, lift, handler) // Note: derive is handled separately above (it has its own kind, not "builder") if (callKind.kind === "builder") { const safeBuilders = new Set([ "computed", "action", "lift", "handler", ]); return safeBuilders.has(callKind.builderName); } return false; } /** * Validates that lift() and handler() are at module scope, not inside patterns. * These builders create reusable functions and should be defined outside the pattern body. */ private validateBuilderPlacement( node: ts.CallExpression, context: TransformationContext, checker: ts.TypeChecker, ): void { // Only check direct calls to lift/handler, not calls to functions returned by them // detectCallKind can incorrectly match calls to lift-returned functions if ( !this.isDirectBuilderCall(node, "lift") && !this.isDirectBuilderCall(node, "handler") ) { return; } const builderName = this.isDirectBuilderCall(node, "lift") ? "lift" : "handler"; // Only error if inside restricted context if (!isInsideRestrictedContext(node, checker)) return; // Check if lift() is immediately invoked: lift(fn)(args) // In this case, suggest computed() instead // We verify node.parent.expression === node to ensure lift() is the callee, // not just an argument (e.g., someFunction(lift(fn)) should not match) const isImmediatelyInvoked = ts.isCallExpression(node.parent) && node.parent.expression === node; if (builderName === "lift" && isImmediatelyInvoked) { context.reportDiagnostic({ severity: "error", type: "pattern-context:builder-placement", message: `lift() should not be defined and immediately invoked inside a pattern. ` + `Use computed(() => ...) instead for inline computations.`, node, }); } else { context.reportDiagnostic({ severity: "error", type: "pattern-context:builder-placement", message: `${builderName}() should be defined at module scope, not inside a pattern. ` + `Move this ${builderName}() call outside the pattern and add explicit type parameters. ` + `Note: computed(), action(), and .map() callbacks are allowed inside patterns.`, node, }); } } /** * Checks if a call expression is a direct call to a builder (lift, handler, etc.) * by checking if the callee is literally the builder name. */ private isDirectBuilderCall( node: ts.CallExpression, builderName: string, ): boolean { const callee = node.expression; // Direct call: lift(...) or handler(...) if (ts.isIdentifier(callee) && callee.text === builderName) { return true; } // Property access call: something.lift(...) or something.handler(...) if ( ts.isPropertyAccessExpression(callee) && callee.name.text === builderName ) { return true; } return false; } /** * Validates that .map() is not called on a fallback expression like (x ?? []) or (x || []) * where one side is reactive (OpaqueRef) and the other is not. * * This pattern fails at runtime because the transformer can't properly detect that * the result needs mapWithPattern transformation. */ private validateMapOnFallbackExpression( node: ts.CallExpression, context: TransformationContext, checker: ts.TypeChecker, ): void { if (!ts.isPropertyAccessExpression(node.expression)) return; if (node.expression.name.text !== "map") return; let target: ts.Expression = node.expression.expression; // Unwrap parentheses while (ts.isParenthesizedExpression(target)) { target = target.expression; } // Check if target is (x ?? y) or (x || y) if (!ts.isBinaryExpression(target)) return; const op = target.operatorToken.kind; if ( op !== ts.SyntaxKind.QuestionQuestionToken && op !== ts.SyntaxKind.BarBarToken ) { return; } // Check if left side is OpaqueRef and right side is not const leftType = checker.getTypeAtLocation(target.left); const rightType = checker.getTypeAtLocation(target.right); const leftIsOpaque = isOpaqueRefType(leftType, checker); const rightIsOpaque = isOpaqueRefType(rightType, checker); if (leftIsOpaque && !rightIsOpaque) { context.reportDiagnostic({ severity: "error", type: "pattern-context:map-on-fallback", message: `'.map()' on fallback expression with mixed reactive/non-reactive types is not supported. ` + `Use direct property access: 'x.map(...)' rather than '(x ?? fallback).map(...)'`, node, }); } } /** * Validates that standalone functions don't use reactive operations like * computed(), derive(), or .map() on CellLike types. * * Standalone functions cannot have their closures captured automatically. * Users should either: * - Move the reactive operation out of the standalone function * - Use patternTool() which handles closure capture automatically * * Exception: Functions passed inline to patternTool() are handled by the * patternTool transformer and don't need validation here. * * Limitation: This check is purely syntactic — it only recognizes functions * passed *inline* as the first argument to patternTool(). If a function is * defined separately and then passed to patternTool(), e.g.: * * const myFn = ({ query }) => { return computed(...) }; * const tool = patternTool(myFn); * * ...the validator will still flag myFn, because it can't trace dataflow to * see that it ends up as a patternTool argument. The workaround is to inline * the function into the patternTool() call. */ private validateStandaloneFunction( func: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, context: TransformationContext, checker: ts.TypeChecker, ): void { // Skip if this function is passed to patternTool() if (this.isPatternToolArgument(func, checker)) { return; } // Walk the function body looking for reactive operations const visitBody = (node: ts.Node): void => { // Skip nested function definitions - they have their own scope if ( node !== func && (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) ) { return; } if (ts.isCallExpression(node)) { const callKind = detectCallKind(node, checker); if (callKind) { // Check for computed() calls if ( callKind.kind === "builder" && callKind.builderName === "computed" ) { context.reportDiagnostic({ severity: "error", type: "standalone-function:reactive-operation", message: `computed() is not allowed inside standalone functions. ` + `Standalone functions cannot capture reactive closures. ` + `Move the computed() call to the pattern body, or use patternTool() to enable automatic closure capture.`, node, }); return; } // Check for derive() calls if (callKind.kind === "derive") { context.reportDiagnostic({ severity: "error", type: "standalone-function:reactive-operation", message: `derive() is not allowed inside standalone functions. ` + `Standalone functions cannot capture reactive closures. ` + `Move the derive() call to the pattern body, or use patternTool() to enable automatic closure capture.`, node, }); return; } // Check for .map() on CellLike types if (callKind.kind === "array-map") { // Check if this is a map on a CellLike type (not a plain array) if (ts.isPropertyAccessExpression(node.expression)) { const receiverType = checker.getTypeAtLocation( node.expression.expression, ); if (this.isCellLikeOrOpaqueRefType(receiverType, checker)) { context.reportDiagnostic({ severity: "error", type: "standalone-function:reactive-operation", message: `.map() on reactive types is not allowed inside standalone functions. ` + `Standalone functions cannot capture reactive closures. ` + `Move the .map() call to the pattern body, or use patternTool() to enable automatic closure capture.`, node, }); return; } } } } } ts.forEachChild(node, visitBody); }; if (func.body) { visitBody(func.body); } } /** * Checks if a function is passed directly as an argument to patternTool(). * If so, the patternTool transformer will handle closure capture. */ private isPatternToolArgument( func: ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration, checker: ts.TypeChecker, ): boolean { // Function declarations can't be passed as arguments if (ts.isFunctionDeclaration(func)) return false; const parent = func.parent; if (!parent || !ts.isCallExpression(parent)) return false; // Check if this function is the first argument if (parent.arguments[0] !== func) return false; // Use detectCallKind for consistent call detection const callKind = detectCallKind(parent, checker); return callKind?.kind === "pattern-tool"; } /** * Checks if a type is a CellLike or OpaqueRef type that would require * reactive handling in .map() calls inside standalone functions. * * Includes OpaqueRef/OpaqueRefMethods because standalone helper functions * may accept pattern parameters (typed as OpaqueRef) and call .map() * on them. */ private isCellLikeOrOpaqueRefType( type: ts.Type, checker: ts.TypeChecker, ): boolean { // Check if it's an OpaqueRef type if (isOpaqueRefType(type, checker)) { return true; } // Check the type name for Cell-like types const typeStr = checker.typeToString(type); const cellLikePatterns = [ "Cell<", "OpaqueCell<", "Writable<", "Stream<", "OpaqueRef<", "OpaqueRefMethods<", ]; return cellLikePatterns.some((pattern) => typeStr.includes(pattern)); } }