Jelajahi Sumber

Improve CSP build (#4671)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* support globals

* cleanup and add vitest to CI

* fix test

* remove old test
Caleb Porzio 3 minggu lalu
induk
melakukan
9025fcfb72

+ 1 - 0
.github/workflows/run-tests.yml

@@ -11,3 +11,4 @@ jobs:
       - run: npm install
       - run: npm run build
       - run: npm run test
+      - run: npm run vitest

File diff ditekan karena terlalu besar
+ 768 - 107
package-lock.json


+ 3 - 2
package.json

@@ -12,14 +12,15 @@
         "dot-json": "^1.2.2",
         "esbuild": "~0.16.17",
         "jest": "^26.6.3",
-        "sortablejs": "^1.15.2"
+        "sortablejs": "^1.15.2",
+        "vitest": "^3.2.4"
     },
     "scripts": {
         "build": "node ./scripts/build.js",
         "watch": "node ./scripts/build.js --watch",
         "test": "cypress run --quiet",
         "cypress": "cypress open",
-        "jest": "jest test",
+        "vitest": "vitest run",
         "update-docs": "node ./scripts/update-docs.js",
         "release": "node ./scripts/release.js"
     }

+ 1 - 1
packages/alpinejs/src/evaluator.js

@@ -2,7 +2,7 @@ import { closestDataStack, mergeProxies } from './scope'
 import { injectMagics } from './magics'
 import { tryCatch, handleError } from './utils/error'
 
-let shouldAutoEvaluateFunctions = true
+export let shouldAutoEvaluateFunctions = true
 
 export function dontAutoEvaluateFunctions(callback) {
     let cache = shouldAutoEvaluateFunctions

+ 17 - 24
packages/csp/src/evaluator.js

@@ -1,6 +1,7 @@
-import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
+import { generateEvaluatorFromFunction, shouldAutoEvaluateFunctions } from 'alpinejs/src/evaluator'
 import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
 import { tryCatch } from 'alpinejs/src/utils/error'
+import { generateRuntimeFunction } from './parser'
 import { injectMagics } from 'alpinejs/src/magics'
 
 export function cspEvaluator(el, expression) {
@@ -28,30 +29,22 @@ function generateEvaluator(el, expression, dataStack) {
     return (receiver = () => {}, { scope = {}, params = [] } = {}) => {
         let completeScope = mergeProxies([scope, ...dataStack])
 
-        let evaluatedExpression = expression.split('.').reduce(
-            (currentScope, currentExpression) => {
-                if (currentScope[currentExpression] === undefined) {
-                    throwExpressionError(el, expression)
-                }
+        let evaluate = generateRuntimeFunction(expression)
 
-                return currentScope[currentExpression]
-            },
-            completeScope,
-        );
+        let returnValue = evaluate(completeScope)
 
-        runIfTypeOfFunction(receiver, evaluatedExpression, completeScope, params)
-    }
-}
-
-function throwExpressionError(el, expression) {
-    console.warn(
-`Alpine Error: Alpine is unable to interpret the following expression using the CSP-friendly build:
+        if (shouldAutoEvaluateFunctions && typeof returnValue === 'function') {
+            let nextReturnValue = returnValue.apply(returnValue, params)
 
-"${expression}"
-
-Read more about the Alpine's CSP-friendly build restrictions here: https://alpinejs.dev/advanced/csp
-
-`,
-el
-    )
+            if (nextReturnValue instanceof Promise) {
+                nextReturnValue.then(i =>  receiver(i))
+            } else {
+                receiver(nextReturnValue)
+            }
+        } else if (typeof returnValue === 'object' && returnValue instanceof Promise) {
+            returnValue.then(i => receiver(i))
+        } else {
+            receiver(returnValue)
+        }
+    }
 }

+ 863 - 0
packages/csp/src/parser.js

@@ -0,0 +1,863 @@
+class Token {
+    constructor(type, value, start, end) {
+        this.type = type;
+        this.value = value;
+        this.start = start;
+        this.end = end;
+    }
+}
+
+class Tokenizer {
+    constructor(input) {
+        this.input = input;
+        this.position = 0;
+        this.tokens = [];
+    }
+
+    tokenize() {
+        while (this.position < this.input.length) {
+            this.skipWhitespace();
+            if (this.position >= this.input.length) break;
+
+            const char = this.input[this.position];
+
+            if (this.isDigit(char)) {
+                this.readNumber();
+            } else if (this.isAlpha(char) || char === '_' || char === '$') {
+                this.readIdentifierOrKeyword();
+            } else if (char === '"' || char === "'") {
+                this.readString();
+            } else if (char === '/' && this.peek() === '/') {
+                this.skipLineComment();
+            } else {
+                this.readOperatorOrPunctuation();
+            }
+        }
+
+        this.tokens.push(new Token('EOF', null, this.position, this.position));
+        return this.tokens;
+    }
+
+    skipWhitespace() {
+        while (this.position < this.input.length && /\s/.test(this.input[this.position])) {
+            this.position++;
+        }
+    }
+
+    skipLineComment() {
+        while (this.position < this.input.length && this.input[this.position] !== '\n') {
+            this.position++;
+        }
+    }
+
+    isDigit(char) {
+        return /[0-9]/.test(char);
+    }
+
+    isAlpha(char) {
+        return /[a-zA-Z]/.test(char);
+    }
+
+    isAlphaNumeric(char) {
+        return /[a-zA-Z0-9_$]/.test(char);
+    }
+
+    peek(offset = 1) {
+        return this.input[this.position + offset] || '';
+    }
+
+    readNumber() {
+        const start = this.position;
+        let hasDecimal = false;
+
+        while (this.position < this.input.length) {
+            const char = this.input[this.position];
+            if (this.isDigit(char)) {
+                this.position++;
+            } else if (char === '.' && !hasDecimal) {
+                hasDecimal = true;
+                this.position++;
+            } else {
+                break;
+            }
+        }
+
+        const value = this.input.slice(start, this.position);
+        this.tokens.push(new Token('NUMBER', parseFloat(value), start, this.position));
+    }
+
+    readIdentifierOrKeyword() {
+        const start = this.position;
+
+        while (this.position < this.input.length && this.isAlphaNumeric(this.input[this.position])) {
+            this.position++;
+        }
+
+        const value = this.input.slice(start, this.position);
+        const keywords = ['true', 'false', 'null', 'undefined', 'new', 'typeof', 'void', 'delete', 'in', 'instanceof'];
+
+        if (keywords.includes(value)) {
+            if (value === 'true' || value === 'false') {
+                this.tokens.push(new Token('BOOLEAN', value === 'true', start, this.position));
+            } else if (value === 'null') {
+                this.tokens.push(new Token('NULL', null, start, this.position));
+            } else if (value === 'undefined') {
+                this.tokens.push(new Token('UNDEFINED', undefined, start, this.position));
+            } else {
+                this.tokens.push(new Token('KEYWORD', value, start, this.position));
+            }
+        } else {
+            this.tokens.push(new Token('IDENTIFIER', value, start, this.position));
+        }
+    }
+
+    readString() {
+        const start = this.position;
+        const quote = this.input[this.position];
+        this.position++; // Skip opening quote
+
+        let value = '';
+        let escaped = false;
+
+        while (this.position < this.input.length) {
+            const char = this.input[this.position];
+
+            if (escaped) {
+                switch (char) {
+                    case 'n': value += '\n'; break;
+                    case 't': value += '\t'; break;
+                    case 'r': value += '\r'; break;
+                    case '\\': value += '\\'; break;
+                    case quote: value += quote; break;
+                    default: value += char;
+                }
+                escaped = false;
+            } else if (char === '\\') {
+                escaped = true;
+            } else if (char === quote) {
+                this.position++; // Skip closing quote
+                this.tokens.push(new Token('STRING', value, start, this.position));
+                return;
+            } else {
+                value += char;
+            }
+
+            this.position++;
+        }
+
+        throw new Error(`Unterminated string starting at position ${start}`);
+    }
+
+    readOperatorOrPunctuation() {
+        const start = this.position;
+        const char = this.input[this.position];
+        const next = this.peek();
+        const nextNext = this.peek(2);
+
+        // Three-character operators
+        if (char === '=' && next === '=' && nextNext === '=') {
+            this.position += 3;
+            this.tokens.push(new Token('OPERATOR', '===', start, this.position));
+        } else if (char === '!' && next === '=' && nextNext === '=') {
+            this.position += 3;
+            this.tokens.push(new Token('OPERATOR', '!==', start, this.position));
+        }
+        // Two-character operators
+        else if (char === '=' && next === '=') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '==', start, this.position));
+        } else if (char === '!' && next === '=') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '!=', start, this.position));
+        } else if (char === '<' && next === '=') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '<=', start, this.position));
+        } else if (char === '>' && next === '=') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '>=', start, this.position));
+        } else if (char === '&' && next === '&') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '&&', start, this.position));
+        } else if (char === '|' && next === '|') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '||', start, this.position));
+        } else if (char === '+' && next === '+') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '++', start, this.position));
+        } else if (char === '-' && next === '-') {
+            this.position += 2;
+            this.tokens.push(new Token('OPERATOR', '--', start, this.position));
+        }
+        // Single-character operators and punctuation
+        else {
+            this.position++;
+            const type = '()[]{},.;:?'.includes(char) ? 'PUNCTUATION' : 'OPERATOR';
+            this.tokens.push(new Token(type, char, start, this.position));
+        }
+    }
+}
+
+class Parser {
+    constructor(tokens) {
+        this.tokens = tokens;
+        this.position = 0;
+    }
+
+    parse() {
+        if (this.isAtEnd()) {
+            throw new Error('Empty expression');
+        }
+        const expr = this.parseExpression();
+
+        // Allow optional trailing semicolon
+        this.match('PUNCTUATION', ';');
+
+        if (!this.isAtEnd()) {
+            throw new Error(`Unexpected token: ${this.current().value}`);
+        }
+        return expr;
+    }
+
+    parseExpression() {
+        return this.parseAssignment();
+    }
+
+    parseAssignment() {
+        const expr = this.parseTernary();
+
+        if (this.match('OPERATOR', '=')) {
+            const value = this.parseAssignment();
+            if (expr.type === 'Identifier' || expr.type === 'MemberExpression') {
+                return {
+                    type: 'AssignmentExpression',
+                    left: expr,
+                    operator: '=',
+                    right: value
+                };
+            }
+            throw new Error('Invalid assignment target');
+        }
+
+        return expr;
+    }
+
+    parseTernary() {
+        const expr = this.parseLogicalOr();
+
+        if (this.match('PUNCTUATION', '?')) {
+            const consequent = this.parseExpression();
+            this.consume('PUNCTUATION', ':');
+            const alternate = this.parseExpression();
+            return {
+                type: 'ConditionalExpression',
+                test: expr,
+                consequent,
+                alternate
+            };
+        }
+
+        return expr;
+    }
+
+    parseLogicalOr() {
+        let expr = this.parseLogicalAnd();
+
+        while (this.match('OPERATOR', '||')) {
+            const operator = this.previous().value;
+            const right = this.parseLogicalAnd();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseLogicalAnd() {
+        let expr = this.parseEquality();
+
+        while (this.match('OPERATOR', '&&')) {
+            const operator = this.previous().value;
+            const right = this.parseEquality();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseEquality() {
+        let expr = this.parseRelational();
+
+        while (this.match('OPERATOR', '==', '!=', '===', '!==')) {
+            const operator = this.previous().value;
+            const right = this.parseRelational();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseRelational() {
+        let expr = this.parseAdditive();
+
+        while (this.match('OPERATOR', '<', '>', '<=', '>=')) {
+            const operator = this.previous().value;
+            const right = this.parseAdditive();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseAdditive() {
+        let expr = this.parseMultiplicative();
+
+        while (this.match('OPERATOR', '+', '-')) {
+            const operator = this.previous().value;
+            const right = this.parseMultiplicative();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseMultiplicative() {
+        let expr = this.parseUnary();
+
+        while (this.match('OPERATOR', '*', '/', '%')) {
+            const operator = this.previous().value;
+            const right = this.parseUnary();
+            expr = {
+                type: 'BinaryExpression',
+                operator,
+                left: expr,
+                right
+            };
+        }
+
+        return expr;
+    }
+
+    parseUnary() {
+        // Handle prefix increment/decrement
+        if (this.match('OPERATOR', '++', '--')) {
+            const operator = this.previous().value;
+            const argument = this.parseUnary();
+            return {
+                type: 'UpdateExpression',
+                operator,
+                argument,
+                prefix: true
+            };
+        }
+
+        // Handle other unary operators
+        if (this.match('OPERATOR', '!', '-', '+')) {
+            const operator = this.previous().value;
+            const argument = this.parseUnary();
+            return {
+                type: 'UnaryExpression',
+                operator,
+                argument,
+                prefix: true
+            };
+        }
+
+        return this.parsePostfix();
+    }
+
+    parsePostfix() {
+        let expr = this.parseMember();
+
+        // Handle postfix increment/decrement
+        if (this.match('OPERATOR', '++', '--')) {
+            const operator = this.previous().value;
+            return {
+                type: 'UpdateExpression',
+                operator,
+                argument: expr,
+                prefix: false
+            };
+        }
+
+        return expr;
+    }
+
+    parseMember() {
+        let expr = this.parsePrimary();
+
+        while (true) {
+            if (this.match('PUNCTUATION', '.')) {
+                const property = this.consume('IDENTIFIER');
+                expr = {
+                    type: 'MemberExpression',
+                    object: expr,
+                    property: { type: 'Identifier', name: property.value },
+                    computed: false
+                };
+            } else if (this.match('PUNCTUATION', '[')) {
+                const property = this.parseExpression();
+                this.consume('PUNCTUATION', ']');
+                expr = {
+                    type: 'MemberExpression',
+                    object: expr,
+                    property,
+                    computed: true
+                };
+            } else if (this.match('PUNCTUATION', '(')) {
+                const args = this.parseArguments();
+                expr = {
+                    type: 'CallExpression',
+                    callee: expr,
+                    arguments: args
+                };
+            } else {
+                break;
+            }
+        }
+
+        return expr;
+    }
+
+    parseArguments() {
+        const args = [];
+
+        if (!this.check('PUNCTUATION', ')')) {
+            do {
+                args.push(this.parseExpression());
+            } while (this.match('PUNCTUATION', ','));
+        }
+
+        this.consume('PUNCTUATION', ')');
+        return args;
+    }
+
+    parsePrimary() {
+        // Numbers
+        if (this.match('NUMBER')) {
+            return { type: 'Literal', value: this.previous().value };
+        }
+
+        // Strings
+        if (this.match('STRING')) {
+            return { type: 'Literal', value: this.previous().value };
+        }
+
+        // Booleans
+        if (this.match('BOOLEAN')) {
+            return { type: 'Literal', value: this.previous().value };
+        }
+
+        // Null
+        if (this.match('NULL')) {
+            return { type: 'Literal', value: null };
+        }
+
+        // Undefined
+        if (this.match('UNDEFINED')) {
+            return { type: 'Literal', value: undefined };
+        }
+
+        // Identifiers
+        if (this.match('IDENTIFIER')) {
+            return { type: 'Identifier', name: this.previous().value };
+        }
+
+        // Grouping expressions
+        if (this.match('PUNCTUATION', '(')) {
+            const expr = this.parseExpression();
+            this.consume('PUNCTUATION', ')');
+            return expr;
+        }
+
+        // Array literals
+        if (this.match('PUNCTUATION', '[')) {
+            return this.parseArrayLiteral();
+        }
+
+        // Object literals
+        if (this.match('PUNCTUATION', '{')) {
+            return this.parseObjectLiteral();
+        }
+
+        throw new Error(`Unexpected token: ${this.current().type} "${this.current().value}"`);
+    }
+
+    parseArrayLiteral() {
+        const elements = [];
+
+        while (!this.check('PUNCTUATION', ']') && !this.isAtEnd()) {
+            elements.push(this.parseExpression());
+            if (this.match('PUNCTUATION', ',')) {
+                // Handle trailing comma
+                if (this.check('PUNCTUATION', ']')) {
+                    break;
+                }
+            } else {
+                break;
+            }
+        }
+
+        this.consume('PUNCTUATION', ']');
+        return {
+            type: 'ArrayExpression',
+            elements
+        };
+    }
+
+    parseObjectLiteral() {
+        const properties = [];
+
+        while (!this.check('PUNCTUATION', '}') && !this.isAtEnd()) {
+            let key;
+            let computed = false;
+
+            if (this.match('STRING')) {
+                key = { type: 'Literal', value: this.previous().value };
+            } else if (this.match('IDENTIFIER')) {
+                const name = this.previous().value;
+                key = { type: 'Identifier', name };
+            } else if (this.match('PUNCTUATION', '[')) {
+                key = this.parseExpression();
+                computed = true;
+                this.consume('PUNCTUATION', ']');
+            } else {
+                throw new Error('Expected property key');
+            }
+
+            this.consume('PUNCTUATION', ':');
+            const value = this.parseExpression();
+
+            properties.push({
+                type: 'Property',
+                key,
+                value,
+                computed,
+                shorthand: false
+            });
+
+            if (this.match('PUNCTUATION', ',')) {
+                // Handle trailing comma
+                if (this.check('PUNCTUATION', '}')) {
+                    break;
+                }
+            } else {
+                break;
+            }
+        }
+
+        this.consume('PUNCTUATION', '}');
+        return {
+            type: 'ObjectExpression',
+            properties
+        };
+    }
+
+    match(...args) {
+        for (let i = 0; i < args.length; i++) {
+            const arg = args[i];
+            if (i === 0 && args.length > 1) {
+                // First arg is type when multiple args provided
+                const type = arg;
+                for (let j = 1; j < args.length; j++) {
+                    if (this.check(type, args[j])) {
+                        this.advance();
+                        return true;
+                    }
+                }
+                return false;
+            } else if (args.length === 1) {
+                // Single arg is just type
+                if (this.checkType(arg)) {
+                    this.advance();
+                    return true;
+                }
+                return false;
+            }
+        }
+        return false;
+    }
+
+    check(type, value) {
+        if (this.isAtEnd()) return false;
+        if (value !== undefined) {
+            return this.current().type === type && this.current().value === value;
+        }
+        return this.current().type === type;
+    }
+
+    checkType(type) {
+        if (this.isAtEnd()) return false;
+        return this.current().type === type;
+    }
+
+    advance() {
+        if (!this.isAtEnd()) this.position++;
+        return this.previous();
+    }
+
+    isAtEnd() {
+        return this.current().type === 'EOF';
+    }
+
+    current() {
+        return this.tokens[this.position];
+    }
+
+    previous() {
+        return this.tokens[this.position - 1];
+    }
+
+    consume(type, value) {
+        if (value !== undefined) {
+            if (this.check(type, value)) return this.advance();
+            throw new Error(`Expected ${type} "${value}" but got ${this.current().type} "${this.current().value}"`);
+        }
+        if (this.check(type)) return this.advance();
+        throw new Error(`Expected ${type} but got ${this.current().type} "${this.current().value}"`);
+    }
+}
+
+class Evaluator {
+    evaluate(node, scope = {}, context = null) {
+        switch (node.type) {
+            case 'Literal':
+                return node.value;
+
+            case 'Identifier':
+                if (node.name in scope) {
+                    const value = scope[node.name];
+                    // If it's a function and we're accessing it directly (not calling it),
+                    // bind it to scope to preserve 'this' context for later calls
+                    if (typeof value === 'function') {
+                        return value.bind(scope);
+                    }
+                    return value;
+                }
+                
+                // Fallback to globals - let CSP catch dangerous ones at runtime
+                if (typeof globalThis[node.name] !== 'undefined') {
+                    const value = globalThis[node.name];
+                    if (typeof value === 'function') {
+                        return value.bind(globalThis);
+                    }
+                    return value;
+                }
+                
+                throw new Error(`Undefined variable: ${node.name}`);
+
+            case 'MemberExpression':
+                const object = this.evaluate(node.object, scope, context);
+                if (object == null) {
+                    throw new Error('Cannot read property of null or undefined');
+                }
+
+                let memberValue;
+                if (node.computed) {
+                    const property = this.evaluate(node.property, scope, context);
+                    memberValue = object[property];
+                } else {
+                    memberValue = object[node.property.name];
+                }
+
+                // If the accessed value is a function, bind it to its object to preserve 'this' context
+                if (typeof memberValue === 'function') {
+                    return memberValue.bind(object);
+                }
+
+                return memberValue;
+
+            case 'CallExpression':
+                const callee = this.evaluate(node.callee, scope, context);
+                if (typeof callee !== 'function') {
+                    throw new Error('Value is not a function');
+                }
+
+                const args = node.arguments.map(arg => this.evaluate(arg, scope, context));
+
+                // Determine the correct 'this' context
+                let thisContext = context;
+                if (node.callee.type === 'MemberExpression') {
+                    thisContext = this.evaluate(node.callee.object, scope, context);
+                } else if (node.callee.type === 'Identifier' && context !== null) {
+                    // For direct function calls, use provided context if available
+                    // Check scope first, then globals
+                    let originalFunction = scope[node.callee.name];
+                    if (!originalFunction) {
+                        originalFunction = globalThis[node.callee.name];
+                    }
+                    if (typeof originalFunction === 'function') {
+                        return originalFunction.apply(context, args);
+                    }
+                } else if (node.callee.type === 'MemberExpression' && context !== null) {
+                    // For member expression calls with explicit context, 
+                    // get the original function and apply the explicit context
+                    const obj = this.evaluate(node.callee.object, scope, context);
+                    let originalFunction;
+                    if (node.callee.computed) {
+                        const prop = this.evaluate(node.callee.property, scope, context);
+                        originalFunction = obj[prop];
+                    } else {
+                        originalFunction = obj[node.callee.property.name];
+                    }
+                    if (typeof originalFunction === 'function') {
+                        return originalFunction.apply(context, args);
+                    }
+                }
+
+                return callee.apply(thisContext, args);
+
+            case 'UnaryExpression':
+                const argument = this.evaluate(node.argument, scope, context);
+                switch (node.operator) {
+                    case '!': return !argument;
+                    case '-': return -argument;
+                    case '+': return +argument;
+                    default:
+                        throw new Error(`Unknown unary operator: ${node.operator}`);
+                }
+
+            case 'UpdateExpression':
+                if (node.argument.type === 'Identifier') {
+                    const name = node.argument.name;
+                    if (!(name in scope)) {
+                        throw new Error(`Undefined variable: ${name}`);
+                    }
+
+                    const oldValue = scope[name];
+                    if (node.operator === '++') {
+                        scope[name] = oldValue + 1;
+                    } else if (node.operator === '--') {
+                        scope[name] = oldValue - 1;
+                    }
+
+                    return node.prefix ? scope[name] : oldValue;
+                } else if (node.argument.type === 'MemberExpression') {
+                    const obj = this.evaluate(node.argument.object, scope, context);
+                    const prop = node.argument.computed
+                        ? this.evaluate(node.argument.property, scope, context)
+                        : node.argument.property.name;
+
+                    const oldValue = obj[prop];
+                    if (node.operator === '++') {
+                        obj[prop] = oldValue + 1;
+                    } else if (node.operator === '--') {
+                        obj[prop] = oldValue - 1;
+                    }
+
+                    return node.prefix ? obj[prop] : oldValue;
+                }
+                throw new Error('Invalid update expression target');
+
+            case 'BinaryExpression':
+                const left = this.evaluate(node.left, scope, context);
+                const right = this.evaluate(node.right, scope, context);
+
+                switch (node.operator) {
+                    case '+': return left + right;
+                    case '-': return left - right;
+                    case '*': return left * right;
+                    case '/': return left / right;
+                    case '%': return left % right;
+                    case '==': return left == right;
+                    case '!=': return left != right;
+                    case '===': return left === right;
+                    case '!==': return left !== right;
+                    case '<': return left < right;
+                    case '>': return left > right;
+                    case '<=': return left <= right;
+                    case '>=': return left >= right;
+                    case '&&': return left && right;
+                    case '||': return left || right;
+                    default:
+                        throw new Error(`Unknown binary operator: ${node.operator}`);
+                }
+
+            case 'ConditionalExpression':
+                const test = this.evaluate(node.test, scope, context);
+                return test
+                    ? this.evaluate(node.consequent, scope, context)
+                    : this.evaluate(node.alternate, scope, context);
+
+            case 'AssignmentExpression':
+                const value = this.evaluate(node.right, scope, context);
+
+                if (node.left.type === 'Identifier') {
+                    scope[node.left.name] = value;
+                    return value;
+                } else if (node.left.type === 'MemberExpression') {
+                    const obj = this.evaluate(node.left.object, scope, context);
+                    if (node.left.computed) {
+                        const prop = this.evaluate(node.left.property, scope, context);
+                        obj[prop] = value;
+                    } else {
+                        obj[node.left.property.name] = value;
+                    }
+                    return value;
+                }
+                throw new Error('Invalid assignment target');
+
+            case 'ArrayExpression':
+                return node.elements.map(el => this.evaluate(el, scope, context));
+
+            case 'ObjectExpression':
+                const result = {};
+                for (const prop of node.properties) {
+                    const key = prop.computed
+                        ? this.evaluate(prop.key, scope, context)
+                        : prop.key.type === 'Identifier'
+                            ? prop.key.name
+                            : this.evaluate(prop.key, scope, context);
+                    const value = this.evaluate(prop.value, scope, context);
+                    result[key] = value;
+                }
+                return result;
+
+            default:
+                throw new Error(`Unknown node type: ${node.type}`);
+        }
+    }
+}
+
+export function generateRuntimeFunction(expression) {
+    try {
+        const tokenizer = new Tokenizer(expression);
+        const tokens = tokenizer.tokenize();
+        const parser = new Parser(tokens);
+        const ast = parser.parse();
+        const evaluator = new Evaluator();
+
+        return function(scope = {}, context = null) {
+            // Use the scope directly - mutations are expected for assignments
+            return evaluator.evaluate(ast, scope, context);
+        };
+    } catch (error) {
+        throw new Error(`CSP Parser Error: ${error.message}`);
+    }
+}
+
+// Also export the individual components for testing
+export { Tokenizer, Parser, Evaluator };

+ 101 - 60
packages/docs/src/en/advanced/csp.md

@@ -5,11 +5,11 @@ title: CSP
 
 # CSP (Content-Security Policy) Build
 
-In order for Alpine to be able to execute plain strings from HTML attributes as JavaScript expressions, for example `x-on:click="console.log()"`, it needs to rely on utilities that violate the "unsafe-eval" [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that some applications may enforce for security purposes.
+In order for Alpine to execute JavaScript expressions from HTML attributes like `x-on:click="console.log()"`, it needs to use utilities that violate the "unsafe-eval" [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that some applications enforce for security purposes.
 
 > Under the hood, Alpine doesn't actually use eval() itself because it's slow and problematic. Instead it uses Function declarations, which are much better, but still violate "unsafe-eval".
 
-In order to accommodate environments where this CSP is necessary, Alpine offer's an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
+Alpine offers an alternate build that doesn't violate "unsafe-eval" and supports most of Alpine's inline expression syntax.
 
 <a name="installation"></a>
 ## Installation
@@ -46,98 +46,139 @@ Alpine.start()
 <a name="basic-example"></a>
 ## Basic Example
 
-To provide a glimpse of how using the CSP build might feel, here is a copy-pastable HTML file with a working counter component using a common CSP setup:
+Here's a working counter component using Alpine's CSP build. Notice how most expressions work exactly like regular Alpine:
 
 ```alpine
 <html>
     <head>
         <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-a23gbfz9e'">
-
         <script defer nonce="a23gbfz9e" src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.x.x/dist/cdn.min.js"></script>
     </head>
-
     <body>
-        <div x-data="counter">
-            <button x-on:click="increment"></button>
+        <div x-data="{ count: 0, message: 'Hello' }">
+            <button x-on:click="count++">Increment</button>
+            <button x-on:click="count = 0">Reset</button>
 
             <span x-text="count"></span>
+            <span x-text="message + ' World'"></span>
+            <span x-show="count > 5">Count is greater than 5!</span>
         </div>
-
-        <script nonce="a23gbfz9e">
-            document.addEventListener('alpine:init', () => {
-                Alpine.data('counter', () => {
-                    return {
-                        count: 1,
-
-                        increment() {
-                            this.count++;
-                        },
-                    }
-                })
-            })
-        </script>
     </body>
 </html>
 ```
 
-<a name="api-restrictions"></a>
-## API Restrictions
-
-Since Alpine can no longer interpret strings as plain JavaScript, it has to parse and construct JavaScript functions from them manually.
+<a name="whats-supported"></a>
+## What's Supported
 
-Due to this limitation, you must use `Alpine.data` to register your `x-data` objects, and must reference properties and methods from it by key only.
-
-For example, an inline component like this will not work.
+The CSP build supports most JavaScript expressions you'd want to use in Alpine:
 
+### Object and Array Literals
 ```alpine
-<!-- Bad -->
-<div x-data="{ count: 1 }">
-    <button @click="count++">Increment</button>
+<!-- ✅ These work -->
+<div x-data="{ user: { name: 'John', age: 30 }, items: [1, 2, 3] }">
+    <span x-text="user.name"></span>
+    <span x-text="items[0]"></span>
+</div>
+```
 
-    <span x-text="count"></span>
+### Basic Operations
+```alpine
+<!-- ✅ These work -->
+<div x-data="{ count: 5, name: 'Alpine' }">
+    <span x-text="count + 10"></span>
+    <span x-text="count > 3"></span>
+    <span x-text="count === 5 ? 'Yes' : 'No'"></span>
+    <span x-text="'Hello ' + name"></span>
+    <div x-show="!loading && count > 0"></div>
 </div>
 ```
 
-However, breaking out the expressions into external APIs, the following is valid with the CSP build:
+### Assignments and Updates
+```alpine
+<!-- ✅ These work -->
+<div x-data="{ count: 0, user: { name: '' } }">
+    <button x-on:click="count++">Increment</button>
+    <button x-on:click="count = 0">Reset</button>
+    <input x-model="user.name">
+</div>
+```
 
+### Method Calls
 ```alpine
-<!-- Good -->
-<div x-data="counter">
-    <button @click="increment">Increment</button>
+<!-- ✅ These work -->
+<div x-data="{ items: ['a', 'b'], getMessage: () => 'Hello' }">
+    <span x-text="getMessage()"></span>
+    <button x-on:click="items.push('c')">Add Item</button>
+</div>
+```
 
-    <span x-text="count"></span>
+### Global Variables and Functions
+```alpine
+<!-- ✅ These work -->
+<div x-data="{ count: 42 }">
+    <button x-on:click="console.log('Count is:', count)">Log Count</button>
+    <span x-text="Math.max(count, 100)"></span>
+    <span x-text="parseInt('123') + count"></span>
+    <span x-text="JSON.stringify({ value: count })"></span>
 </div>
 ```
 
-```js
-Alpine.data('counter', () => ({
-    count: 1,
+<a name="whats-not-supported"></a>
+## What's Not Supported
+
+Some advanced JavaScript features aren't supported:
 
-    increment() {
-        this.count++
-    },
-}))
+```alpine
+<!-- ❌ These don't work -->
+<div x-data>
+    <!-- Arrow functions -->
+    <button x-on:click="() => console.log('hi')">Bad</button>
+
+    <!-- Destructuring -->
+    <div x-text="{ name } = user">Bad</div>
+
+    <!-- Template literals -->
+    <div x-text="`Hello ${name}`">Bad</div>
+
+    <!-- Spread operator -->
+    <div x-data="{ ...defaults }">Bad</div>
+</div>
 ```
 
-The CSP build supports accessing nested properties (property accessors) using the dot notation.
+<a name="when-to-extract-logic"></a>
+## When to Extract Logic
+
+While the CSP build supports simple inline expressions, you'll want to extract complex logic into dedicated functions or Alpine.data() components for better organization:
 
 ```alpine
-<!-- This works too -->
-<div x-data="counter">
-    <button @click="foo.increment">Increment</button>
+<!-- Instead of this -->
+<div x-data="{ users: [] }" x-show="users.filter(u => u.active && u.role === 'admin').length > 0">
+```
 
-    <span x-text="foo.count"></span>
-</div>
+```alpine
+<!-- Do this -->
+<div x-data="userManager" x-show="hasActiveAdmins">
+
+<script nonce="...">
+    Alpine.data('userManager', () => ({
+        users: [],
+
+        get hasActiveAdmins() {
+            return this.users.filter(u => u.active && u.role === 'admin').length > 0
+        }
+    }))
+</script>
 ```
 
-```js
-Alpine.data('counter', () => ({
-    foo: {
-        count: 1,
-
-        increment() {
-            this.count++
-        },
-    },
-}))
+This approach makes your code more readable, testable, and maintainable, especially for complex applications.
+
+<a name="csp-headers"></a>
+## CSP Headers
+
+Here's an example CSP header that works with Alpine's CSP build:
+
+```
+Content-Security-Policy: default-src 'self'; script-src 'nonce-[random]' 'strict-dynamic';
 ```
+
+The key is removing `'unsafe-eval'` from your `script-src` directive while still allowing your nonce-based scripts to run.

+ 1 - 1
tests/cypress/integration/plugins/csp-compatibility.spec.js

@@ -33,7 +33,7 @@ test.csp('Supports nested properties',
         Alpine.data('test', () => ({
             foo: {
                 bar: 'baz',
-                change() { this.foo.bar = 'qux' },
+                change() { this.bar = 'qux' },
             }
         }))
     `],

+ 0 - 45
tests/jest/mask.spec.js

@@ -1,45 +0,0 @@
-let { stripDown, formatMoney } = require('../../packages/mask/dist/module.cjs');
-
-test('strip-down functionality', async () => {
-    expect(stripDown('(***) ***-****', '7162256108')).toEqual('7162256108')
-    expect(stripDown('(999) 999-9999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('999) 999-9999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('999 999-9999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('999999-9999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('9999999999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('9999999999', '7162256108')).toEqual('7162256108')
-    expect(stripDown('(999) 999-9999', '716 2256108')).toEqual('7162256108')
-    expect(stripDown('(999) 999-9999', '(716) 2256108')).toEqual('7162256108')
-    expect(stripDown('(999) 999-9999', '(716) 2-25--6108')).toEqual('7162256108')
-})
-
-test('formatMoney functionality', async () => {
-    // Default arguments implicit and explicit
-    expect(formatMoney('123456')).toEqual('123,456');
-    expect(formatMoney('9900900')).toEqual('9,900,900');
-    expect(formatMoney('5600.40')).toEqual('5,600.40');
-    expect(formatMoney('123456', '.')).toEqual('123,456');
-    expect(formatMoney('9900900', '.')).toEqual('9,900,900');
-    expect(formatMoney('5600.40', '.')).toEqual('5,600.40');
-    expect(formatMoney('123456', '.', ',')).toEqual('123,456');
-    expect(formatMoney('9900900', '.', ',')).toEqual('9,900,900');
-    expect(formatMoney('5600.40', '.', ',')).toEqual('5,600.40');
-
-    // Switch decimal separator
-    expect(formatMoney('123456', ',')).toEqual('123.456');
-    expect(formatMoney('9900900', ',')).toEqual('9.900.900');
-    expect(formatMoney('5600.40', ',')).toEqual('5.600,40');
-    expect(formatMoney('123456', '/')).toEqual('123.456');
-    expect(formatMoney('9900900', '/')).toEqual('9.900.900');
-    expect(formatMoney('5600.40', '/')).toEqual('5.600/40');
-
-    // Switch thousands separator
-    expect(formatMoney('123456', '.', ' ')).toEqual('123 456');
-    expect(formatMoney('9900900', '.', ' ')).toEqual('9 900 900');
-    expect(formatMoney('5600.40', '.', ' ')).toEqual('5 600.40');
-
-    // Switch decimal and thousands separator
-    expect(formatMoney('123456', '#', ' ')).toEqual('123 456');
-    expect(formatMoney('9900900', '#', ' ')).toEqual('9 900 900');
-    expect(formatMoney('5600.40', '#', ' ')).toEqual('5 600#40');
-});

+ 0 - 10
tests/jest/morph/createElement.js

@@ -1,10 +0,0 @@
-
-function createElement(htmlOrTemplate) {
-    if (typeof htmlOrTemplate === 'string') {
-        return document.createRange().createContextualFragment(htmlOrTemplate).firstElementChild
-    }
-
-    return htmlOrTemplate.content.firstElementChild.cloneNode(true)
-}
-
-module.exports = createElement

+ 0 - 41
tests/jest/morph/external.spec.js

@@ -1,41 +0,0 @@
-let { morph } = require('@alpinejs/morph')
-let createElement = require('./createElement.js')
-
-test('text content', () => assertPatch(
-    `<div>foo</div>`,
-    `<div>bar</div>`
-))
-
-test('change tag', () => assertPatch(
-    `<div><div>foo</div></div>`,
-    `<div><span>foo</span></div>`
-))
-
-test('add child', () => assertPatch(
-    `<div>foo</div>`,
-    `<div>foo <h1>baz</h1></div>`
-))
-
-test('remove child', () => assertPatch(
-    `<div>foo <h1>baz</h1></div>`,
-    `<div>foo</div>`
-))
-
-test('add attribute', () => assertPatch(
-    `<div>foo</div>`,
-    `<div foo="bar">foo</div>`
-))
-
-test('remove attribute', () => assertPatch(
-    `<div foo="bar">foo</div>`,
-    `<div>foo</div>`
-))
-
-test('change attribute', () => assertPatch(
-    `<div foo="bar">foo</div>`,
-    `<div foo="baz">foo</div>`
-))
-
-async function assertPatch(before, after) {
-    expect((await morph(createElement(before), after)).outerHTML).toEqual(after)
-}

+ 0 - 191
tests/jest/morph/hooks.spec.js

@@ -1,191 +0,0 @@
-let { morph } = require('@alpinejs/morph')
-let createElement = require('./createElement.js')
-
-test('can use custom key name', async () => {
-    let dom = createElement('<ul><li wire:key="2">bar</li></ul>')
-
-    dom.querySelector('li').is_me = true
-
-    await morph(dom, '<ul><li wire:key="1">foo</li><li wire:key="2">bar</li></ul>', {
-        key(el) { return el.getAttribute('wire:key') }
-    })
-
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-})
-
-test('can prevent update', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    await morph(dom, '<div><span>bar</span></div>', {
-        updating(from, to, childrenOnly, prevent) {
-            if (from.textContent === 'foo') {
-                prevent()
-            }
-        }
-    })
-
-    expect(dom.querySelector('span').textContent).toEqual('foo')
-})
-
-test('can prevent update, but still update children', async () => {
-    let dom = createElement('<div><span foo="bar">foo</span></div>')
-
-    await morph(dom, '<div><span foo="baz">bar</span></div>', {
-        updating(from, to, childrenOnly, prevent) {
-            if (from.textContent === 'foo') {
-                childrenOnly()
-            }
-        }
-    })
-
-    expect(dom.querySelector('span').textContent).toEqual('bar')
-    expect(dom.querySelector('span').getAttribute('foo')).toEqual('bar')
-})
-
-test('changing tag doesnt trigger an update (add and remove instead)', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    let updateHookCalledTimes = 0
-
-    await morph(dom, '<div><h1>foo</h1></div>', {
-        updating(from, to, prevent) {
-            updateHookCalledTimes++
-        }
-    })
-
-    expect(updateHookCalledTimes).toEqual(1)
-})
-
-test('can impact update', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    await morph(dom, '<div><span>bar</span></div>', {
-        updated(from, to) {
-            if (from.textContent === 'bar') {
-                from.textContent = 'baz'
-            }
-        }
-    })
-
-    expect(dom.querySelector('span').textContent).toEqual('baz')
-})
-
-test('updating and updated are sequential when element has child updates ', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    let updatings = []
-    let updateds = []
-
-    await morph(dom, '<div><span>bar</span></div>', {
-        updating(from, to) {
-            updatings.push(from.nodeName.toLowerCase())
-        },
-
-        updated(from, to) {
-            updateds.push(from.nodeName.toLowerCase())
-        }
-    })
-
-    expect(updatings).toEqual(['div', 'span', '#text'])
-    expect(updateds).toEqual(['div', 'span', '#text'])
-})
-
-test('can prevent removal', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    await morph(dom, '<div></div>', {
-        removing(from, prevent) {
-            prevent()
-        }
-    })
-
-    expect(dom.querySelector('span')).toBeTruthy()
-})
-
-test('can impact removal', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    let textContent
-
-    await morph(dom, '<div></div>', {
-        removed(from) {
-            textContent = from.textContent
-        }
-    })
-
-    expect(textContent).toEqual('foo')
-})
-
-test('can prevent removal for tag replacement', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    await morph(dom, '<div><h1>foo</h1></div>', {
-        removing(from, prevent) {
-            prevent()
-        }
-    })
-
-    expect(dom.querySelector('span')).toBeTruthy()
-})
-
-test('can impact removal for tag replacement', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    let textContent
-
-    await morph(dom, '<div><h1>foo</h1></div>', {
-        removed(from) {
-            textContent = from.textContent
-        }
-    })
-
-    expect(textContent).toEqual('foo')
-})
-
-test('can prevent addition', async () => {
-    let dom = createElement('<div></div>')
-
-    await morph(dom, '<div><span>foo</span></div>', {
-        adding(to, prevent) {
-            prevent()
-        }
-    })
-
-    expect(dom.querySelector('span')).toBeFalsy()
-})
-
-test('can impact addition', async () => {
-    let dom = createElement('<div></div>')
-
-    await morph(dom, '<div><span>foo</span></div>', {
-        added(to) {
-            to.textContent = 'bar'
-        }
-    })
-
-    expect(dom.querySelector('span').textContent).toEqual('bar')
-})
-
-test('can prevent addition for tag replacement', async () => {
-    let dom = createElement('<div><h1>foo</h1></div>')
-
-    await morph(dom, '<div><span>foo</span></div>', {
-        adding(to, prevent) {
-            prevent()
-        }
-    })
-
-    expect(dom.querySelector('span')).toBeFalsy()
-})
-
-test('can impact addition for tag replacement', async () => {
-    let dom = createElement('<div><h1>foo</h1></div>')
-
-    await morph(dom, '<div><span>foo</span></div>', {
-        added(to) {
-            to.textContent = 'bar'
-        }
-    })
-
-    expect(dom.querySelector('span').textContent).toEqual('bar')
-})

+ 0 - 119
tests/jest/morph/internal.spec.js

@@ -1,119 +0,0 @@
-let { morph } = require('@alpinejs/morph')
-let createElement = require('./createElement.js')
-
-test('changed element is the same element', async () => {
-    let dom = createElement('<div><span>foo</span></div>')
-
-    dom.querySelector('span').is_me = true
-
-    await morph(dom, '<div><span>bar</span></div>')
-
-    expect(dom.querySelector('span').is_me).toBeTruthy()
-})
-
-test('non-keyed elements are replaced instead of moved', async () => {
-    let dom = createElement('<ul><li>bar</li></ul>')
-
-    dom.querySelector('li').is_me = true
-
-    await morph(dom, '<ul><li>foo</li><li>bar</li></ul>')
-
-    expect(dom.querySelector('li:nth-of-type(1)').is_me).toBeTruthy()
-})
-
-test('keyed elements are moved instead of replaced', async () => {
-    let dom = createElement('<ul><li key="2">bar</li></ul>')
-
-    dom.querySelector('li').is_me = true
-
-    await morph(dom, '<ul><li key="1">foo</li><li key="2">bar</li></ul>')
-
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-})
-
-test('elements inserted into a list are properly tracked using lookahead inside updating hook instead of keys', async () => {
-    let dom = createElement('<ul><li>bar</li></ul>')
-
-    dom.querySelector('li').is_me = true
-
-    await morph(dom, '<ul><li>foo</li><li>bar</li></ul>', {
-        lookahead: true,
-    })
-
-    expect(dom.outerHTML).toEqual('<ul><li>foo</li><li>bar</li></ul>')
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-})
-
-test('lookahead still works if comparison elements have keys', async () => {
-    let dom = createElement(`<ul>
-<li key="bar">bar</li>
-<li>hey</li>
-</ul>`)
-
-    dom.querySelector('li:nth-of-type(1)').is_me = true
-    dom.querySelector('li:nth-of-type(2)').is_me = true
-
-    await morph(dom, `<ul>
-<li key="foo">foo</li>
-<li key="bar">bar</li>
-<li>hey</li>
-</ul>`, {
-        lookahead: true,
-    })
-
-    expect(dom.querySelector('li:nth-of-type(1)').is_me).toBeFalsy()
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-    expect(dom.querySelector('li:nth-of-type(3)').is_me).toBeTruthy()
-    expect(dom.querySelectorAll('li').length).toEqual(3)
-})
-
-test('baz', async () => {
-    let dom = createElement(`<ul>
-<li key="bar">bar</li>
-
-<li>hey</li>
-</ul>`)
-
-    dom.querySelector('li:nth-of-type(1)').is_me = true
-    dom.querySelector('li:nth-of-type(2)').is_me = true
-
-    await morph(dom, `<ul>
-<li>foo</li>
-
-<li key="bar">bar</li>
-
-<li>hey</li>
-</ul>`, {
-        lookahead: true,
-    })
-
-    expect(dom.querySelector('li:nth-of-type(1)').is_me).toBeFalsy()
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-    expect(dom.querySelector('li:nth-of-type(3)').is_me).toBeTruthy()
-    expect(dom.querySelectorAll('li').length).toEqual(3)
-})
-
-test('blah blah blah no lookahead', async () => {
-    let dom = createElement(`<ul>
-<li key="bar">bar</li>
-<li>hey</li>
-</ul>`)
-
-    dom.querySelector('li:nth-of-type(1)').is_me = true
-
-    await morph(dom, `<ul>
-<li key="foo">foo</li>
-<li key="bar">bar</li>
-<li>hey</li>
-</ul>`)
-
-    expect(dom.querySelector('li:nth-of-type(1)').is_me).toBeFalsy()
-    expect(dom.querySelector('li:nth-of-type(2)').is_me).toBeTruthy()
-    expect(dom.querySelector('li:nth-of-type(3)').is_me).toBeFalsy()
-    expect(dom.querySelectorAll('li').length).toEqual(3)
-})
-
-//
-
-
-// @todo: add test to make sure added nodes are cloned from the "to" tree

+ 658 - 0
tests/vitest/csp-parser.spec.js

@@ -0,0 +1,658 @@
+import { describe, it, expect } from 'vitest';
+import { generateRuntimeFunction } from '../../packages/csp/src/parser.js';
+
+describe('CSP Parser', () => {
+    describe('Literals', () => {
+        it('should parse numbers', () => {
+            expect(generateRuntimeFunction('42')()).toBe(42);
+            expect(generateRuntimeFunction('-3.14')()).toBe(-3.14);
+            expect(generateRuntimeFunction('0')()).toBe(0);
+        });
+
+        it('should parse strings', () => {
+            expect(generateRuntimeFunction('"hello"')()).toBe('hello');
+            expect(generateRuntimeFunction("'world'")()).toBe('world');
+            expect(generateRuntimeFunction('"escaped \\"quotes\\""')()).toBe('escaped "quotes"');
+            expect(generateRuntimeFunction("'mixed \"quotes\"'")()).toBe('mixed "quotes"');
+        });
+
+        it('should parse booleans', () => {
+            expect(generateRuntimeFunction('true')()).toBe(true);
+            expect(generateRuntimeFunction('false')()).toBe(false);
+        });
+
+        it('should parse null and undefined', () => {
+            expect(generateRuntimeFunction('null')()).toBe(null);
+            expect(generateRuntimeFunction('undefined')()).toBe(undefined);
+        });
+    });
+
+    describe('Variable Access', () => {
+        it('should access simple variables', () => {
+            const scope = { foo: 'bar', count: 5 };
+            expect(generateRuntimeFunction('foo')(scope)).toBe('bar');
+            expect(generateRuntimeFunction('count')(scope)).toBe(5);
+        });
+
+        it('should throw on undefined variables', () => {
+            expect(() => generateRuntimeFunction('nonExistent')()).toThrow('Undefined variable');
+        });
+
+        it('should access global variables', () => {
+            expect(generateRuntimeFunction('console')()).toBe(console);
+            expect(generateRuntimeFunction('Math')()).toBe(Math);
+            expect(generateRuntimeFunction('JSON')()).toBe(JSON);
+        });
+
+        it('should prefer scope over globals', () => {
+            const scope = { console: 'local console' };
+            expect(generateRuntimeFunction('console')(scope)).toBe('local console');
+        });
+    });
+
+    describe('Property Access', () => {
+        it('should access properties with dot notation', () => {
+            const scope = {
+                user: { name: 'John', age: 30 },
+                nested: { deep: { value: 'found' } }
+            };
+            expect(generateRuntimeFunction('user.name')(scope)).toBe('John');
+            expect(generateRuntimeFunction('user.age')(scope)).toBe(30);
+            expect(generateRuntimeFunction('nested.deep.value')(scope)).toBe('found');
+        });
+
+        it('should access properties with bracket notation', () => {
+            const scope = {
+                obj: { foo: 'bar', 'with-dash': 'works' },
+                key: 'foo'
+            };
+            expect(generateRuntimeFunction('obj["foo"]')(scope)).toBe('bar');
+            expect(generateRuntimeFunction('obj["with-dash"]')(scope)).toBe('works');
+            expect(generateRuntimeFunction('obj[key]')(scope)).toBe('bar');
+        });
+
+        it('should handle computed property access', () => {
+            const scope = {
+                arr: [1, 2, 3],
+                index: 1
+            };
+            expect(generateRuntimeFunction('arr[index]')(scope)).toBe(2);
+            expect(generateRuntimeFunction('arr[0]')(scope)).toBe(1);
+        });
+
+        it('should throw on null/undefined property access', () => {
+            const scope = { nullValue: null };
+            expect(() => generateRuntimeFunction('nullValue.prop')(scope)).toThrow('Cannot read property');
+        });
+    });
+
+    describe('Function Calls', () => {
+        it('should call functions without arguments', () => {
+            const scope = {
+                getValue: () => 42,
+                getText: function() { return 'hello'; }
+            };
+            expect(generateRuntimeFunction('getValue()')(scope)).toBe(42);
+            expect(generateRuntimeFunction('getText()')(scope)).toBe('hello');
+        });
+
+        it('should call functions with arguments', () => {
+            const scope = {
+                add: (a, b) => a + b,
+                greet: (name) => `Hello, ${name}!`
+            };
+            expect(generateRuntimeFunction('add(2, 3)')(scope)).toBe(5);
+            expect(generateRuntimeFunction('greet("World")')(scope)).toBe('Hello, World!');
+        });
+
+        it('should call methods on objects', () => {
+            const scope = {
+                obj: {
+                    value: 10,
+                    getValue: function() { return this.value; },
+                    add: function(n) { return this.value + n; }
+                }
+            };
+            expect(generateRuntimeFunction('obj.getValue()')(scope)).toBe(10);
+            expect(generateRuntimeFunction('obj.add(5)')(scope)).toBe(15);
+        });
+
+        it('should preserve this context in method calls', () => {
+            const scope = {
+                counter: {
+                    count: 0,
+                    increment: function() { this.count++; return this.count; }
+                }
+            };
+            expect(generateRuntimeFunction('counter.increment()')(scope)).toBe(1);
+            expect(scope.counter.count).toBe(1);
+        });
+
+        it('should call nested methods', () => {
+            const scope = {
+                api: {
+                    users: {
+                        get: (id) => ({ id, name: 'User' + id })
+                    }
+                }
+            };
+            expect(generateRuntimeFunction('api.users.get(1)')(scope)).toEqual({ id: 1, name: 'User1' });
+        });
+
+        it('should call global functions', () => {
+            expect(generateRuntimeFunction('parseInt("42")')()).toBe(42);
+            expect(generateRuntimeFunction('Math.max(1, 2, 3)')()).toBe(3);
+            expect(generateRuntimeFunction('JSON.stringify({a: 1})')()).toBe('{"a":1}');
+        });
+
+        it('should call methods with scope', () => {
+            const scope = {
+                foo: {
+                    bar: 'baz',
+                    change() { this.bar = 'qux' }
+                }
+            };
+
+            let fn = generateRuntimeFunction('foo.change')(scope)
+
+            fn.apply(fn, [])
+
+            expect(scope.foo.bar).toEqual('qux');
+        });
+    });
+
+    describe('Array Literals', () => {
+        it('should parse empty arrays', () => {
+            expect(generateRuntimeFunction('[]')()).toEqual([]);
+        });
+
+        it('should parse arrays with literals', () => {
+            expect(generateRuntimeFunction('[1, 2, 3]')()).toEqual([1, 2, 3]);
+            expect(generateRuntimeFunction('["a", "b", "c"]')()).toEqual(['a', 'b', 'c']);
+            expect(generateRuntimeFunction('[true, false, null]')()).toEqual([true, false, null]);
+        });
+
+        it('should parse arrays with variables', () => {
+            const scope = { a: 1, b: 2, c: 3 };
+            expect(generateRuntimeFunction('[a, b, c]')(scope)).toEqual([1, 2, 3]);
+        });
+
+        it('should parse nested arrays', () => {
+            expect(generateRuntimeFunction('[[1, 2], [3, 4]]')()).toEqual([[1, 2], [3, 4]]);
+        });
+    });
+
+    describe('Object Literals', () => {
+        it('should parse empty objects', () => {
+            expect(generateRuntimeFunction('{}')()).toEqual({});
+        });
+
+        it('should parse objects with simple properties', () => {
+            expect(generateRuntimeFunction('{ foo: "bar", count: 42 }')()).toEqual({ foo: 'bar', count: 42 });
+        });
+
+        it('should parse objects with string keys', () => {
+            expect(generateRuntimeFunction('{ "foo-bar": 1, "with space": 2 }')()).toEqual({
+                'foo-bar': 1,
+                'with space': 2
+            });
+        });
+
+        it('should parse objects with variable values', () => {
+            const scope = { value: 'test', num: 100 };
+            expect(generateRuntimeFunction('{ prop: value, count: num }')(scope)).toEqual({
+                prop: 'test',
+                count: 100
+            });
+        });
+
+        it('should parse nested objects', () => {
+            expect(generateRuntimeFunction('{ outer: { inner: "value" } }')()).toEqual({
+                outer: { inner: 'value' }
+            });
+        });
+    });
+
+    describe('Arithmetic Operators', () => {
+        it('should handle addition', () => {
+            expect(generateRuntimeFunction('2 + 3')()).toBe(5);
+            expect(generateRuntimeFunction('10.5 + 0.5')()).toBe(11);
+        });
+
+        it('should handle subtraction', () => {
+            expect(generateRuntimeFunction('10 - 3')()).toBe(7);
+            expect(generateRuntimeFunction('5.5 - 0.5')()).toBe(5);
+        });
+
+        it('should handle multiplication', () => {
+            expect(generateRuntimeFunction('4 * 5')()).toBe(20);
+            expect(generateRuntimeFunction('2.5 * 2')()).toBe(5);
+        });
+
+        it('should handle division', () => {
+            expect(generateRuntimeFunction('10 / 2')()).toBe(5);
+            expect(generateRuntimeFunction('7 / 2')()).toBe(3.5);
+        });
+
+        it('should handle modulo', () => {
+            expect(generateRuntimeFunction('10 % 3')()).toBe(1);
+            expect(generateRuntimeFunction('8 % 2')()).toBe(0);
+        });
+
+        it('should handle string concatenation', () => {
+            expect(generateRuntimeFunction('"Hello" + " " + "World"')()).toBe('Hello World');
+            const scope = { name: 'John' };
+            expect(generateRuntimeFunction('"Hello, " + name')(scope)).toBe('Hello, John');
+        });
+
+        it('should respect operator precedence', () => {
+            expect(generateRuntimeFunction('2 + 3 * 4')()).toBe(14);
+            expect(generateRuntimeFunction('(2 + 3) * 4')()).toBe(20);
+            expect(generateRuntimeFunction('10 - 2 * 3')()).toBe(4);
+        });
+    });
+
+    describe('Comparison Operators', () => {
+        it('should handle equality', () => {
+            expect(generateRuntimeFunction('5 == 5')()).toBe(true);
+            expect(generateRuntimeFunction('5 == "5"')()).toBe(true);
+            expect(generateRuntimeFunction('5 === 5')()).toBe(true);
+            expect(generateRuntimeFunction('5 === "5"')()).toBe(false);
+        });
+
+        it('should handle inequality', () => {
+            expect(generateRuntimeFunction('5 != 3')()).toBe(true);
+            expect(generateRuntimeFunction('5 != 5')()).toBe(false);
+            expect(generateRuntimeFunction('5 !== "5"')()).toBe(true);
+            expect(generateRuntimeFunction('5 !== 5')()).toBe(false);
+        });
+
+        it('should handle relational operators', () => {
+            expect(generateRuntimeFunction('5 > 3')()).toBe(true);
+            expect(generateRuntimeFunction('3 > 5')()).toBe(false);
+            expect(generateRuntimeFunction('5 >= 5')()).toBe(true);
+            expect(generateRuntimeFunction('3 < 5')()).toBe(true);
+            expect(generateRuntimeFunction('5 <= 5')()).toBe(true);
+        });
+    });
+
+    describe('Logical Operators', () => {
+        it('should handle logical AND', () => {
+            expect(generateRuntimeFunction('true && true')()).toBe(true);
+            expect(generateRuntimeFunction('true && false')()).toBe(false);
+            expect(generateRuntimeFunction('5 > 3 && 2 < 4')()).toBe(true);
+        });
+
+        it('should handle logical OR', () => {
+            expect(generateRuntimeFunction('true || false')()).toBe(true);
+            expect(generateRuntimeFunction('false || false')()).toBe(false);
+            expect(generateRuntimeFunction('5 > 10 || 2 < 4')()).toBe(true);
+        });
+
+        it('should handle logical NOT', () => {
+            expect(generateRuntimeFunction('!true')()).toBe(false);
+            expect(generateRuntimeFunction('!false')()).toBe(true);
+            expect(generateRuntimeFunction('!(5 > 3)')()).toBe(false);
+        });
+
+        it('should handle complex logical expressions', () => {
+            const scope = { a: true, b: false, c: true };
+            expect(generateRuntimeFunction('a && (b || c)')(scope)).toBe(true);
+            expect(generateRuntimeFunction('!a || (b && c)')(scope)).toBe(false);
+        });
+    });
+
+    describe('Unary Operators', () => {
+        it('should handle unary minus', () => {
+            expect(generateRuntimeFunction('-5')()).toBe(-5);
+            expect(generateRuntimeFunction('-(2 + 3)')()).toBe(-5);
+            const scope = { value: 10 };
+            expect(generateRuntimeFunction('-value')(scope)).toBe(-10);
+        });
+
+        it('should handle unary plus', () => {
+            expect(generateRuntimeFunction('+5')()).toBe(5);
+            expect(generateRuntimeFunction('+"5"')()).toBe(5);
+            expect(generateRuntimeFunction('+true')()).toBe(1);
+        });
+    });
+
+    describe('Conditional (Ternary) Operator', () => {
+        it('should handle simple ternary', () => {
+            expect(generateRuntimeFunction('true ? 1 : 2')()).toBe(1);
+            expect(generateRuntimeFunction('false ? 1 : 2')()).toBe(2);
+        });
+
+        it('should handle ternary with expressions', () => {
+            const scope = { age: 20 };
+            expect(generateRuntimeFunction('age >= 18 ? "adult" : "minor"')(scope)).toBe('adult');
+            scope.age = 15;
+            expect(generateRuntimeFunction('age >= 18 ? "adult" : "minor"')(scope)).toBe('minor');
+        });
+
+        it('should handle nested ternary', () => {
+            const scope = { score: 85 };
+            expect(generateRuntimeFunction('score >= 90 ? "A" : score >= 80 ? "B" : "C"')(scope)).toBe('B');
+        });
+    });
+
+    describe('Assignment Operators', () => {
+        it('should handle simple assignment', () => {
+            const scope = { x: 0 };
+            expect(generateRuntimeFunction('x = 5')(scope)).toBe(5);
+            expect(scope.x).toBe(5);
+        });
+
+        it('should handle property assignment', () => {
+            const scope = { obj: { prop: 0 } };
+            expect(generateRuntimeFunction('obj.prop = 10')(scope)).toBe(10);
+            expect(scope.obj.prop).toBe(10);
+        });
+
+        it('should handle computed property assignment', () => {
+            const scope = {
+                obj: { foo: 0 },
+                key: 'foo'
+            };
+            expect(generateRuntimeFunction('obj[key] = 20')(scope)).toBe(20);
+            expect(scope.obj.foo).toBe(20);
+        });
+
+        it('should handle chained assignment', () => {
+            const scope = { a: 0, b: 0 };
+            expect(generateRuntimeFunction('a = b = 5')(scope)).toBe(5);
+            expect(scope.a).toBe(5);
+            expect(scope.b).toBe(5);
+        });
+    });
+
+    describe('Increment/Decrement Operators', () => {
+        it('should handle prefix increment', () => {
+            const scope = { x: 5 };
+            expect(generateRuntimeFunction('++x')(scope)).toBe(6);
+            expect(scope.x).toBe(6);
+        });
+
+        it('should handle postfix increment', () => {
+            const scope = { x: 5 };
+            expect(generateRuntimeFunction('x++')(scope)).toBe(5);
+            expect(scope.x).toBe(6);
+        });
+
+        it('should handle prefix decrement', () => {
+            const scope = { x: 5 };
+            expect(generateRuntimeFunction('--x')(scope)).toBe(4);
+            expect(scope.x).toBe(4);
+        });
+
+        it('should handle postfix decrement', () => {
+            const scope = { x: 5 };
+            expect(generateRuntimeFunction('x--')(scope)).toBe(5);
+            expect(scope.x).toBe(4);
+        });
+
+        it('should handle increment on properties', () => {
+            const scope = { obj: { count: 10 } };
+            expect(generateRuntimeFunction('obj.count++')(scope)).toBe(10);
+            expect(scope.obj.count).toBe(11);
+            expect(generateRuntimeFunction('++obj.count')(scope)).toBe(12);
+            expect(scope.obj.count).toBe(12);
+        });
+    });
+
+    describe('Complex Expressions', () => {
+        it('should handle mixed operators with correct precedence', () => {
+            expect(generateRuntimeFunction('2 + 3 * 4 - 1')()).toBe(13);
+            expect(generateRuntimeFunction('(2 + 3) * (4 - 1)')()).toBe(15);
+            expect(generateRuntimeFunction('10 / 2 + 3 * 2')()).toBe(11);
+        });
+
+        it('should handle complex conditions', () => {
+            const scope = {
+                user: { role: 'admin', active: true },
+                permissions: ['read', 'write', 'delete']
+            };
+            expect(generateRuntimeFunction('user.role === "admin" && user.active')(scope)).toBe(true);
+            expect(generateRuntimeFunction('user.role === "user" || user.active')(scope)).toBe(true);
+        });
+
+        it('should handle method calls with complex arguments', () => {
+            const scope = {
+                math: {
+                    add: (a, b) => a + b,
+                    multiply: (a, b) => a * b
+                },
+                x: 2,
+                y: 3
+            };
+            expect(generateRuntimeFunction('math.add(x * 2, y + 1)')(scope)).toBe(8);
+            expect(generateRuntimeFunction('math.multiply(math.add(x, y), 2)')(scope)).toBe(10);
+        });
+    });
+
+    describe('Context (this) Handling', () => {
+        it('should use provided context', () => {
+            const context = { value: 42 };
+            const scope = {
+                getValue: function() { return this.value; }
+            };
+            expect(generateRuntimeFunction('getValue()')(scope, context)).toBe(42);
+        });
+
+        it('should preserve method context over provided context', () => {
+            const context = { value: 99 };
+            const scope = {
+                obj: {
+                    value: 42,
+                    getValue: function() { return this.value; }
+                }
+            };
+            expect(generateRuntimeFunction('obj.getValue()')(scope, context)).toBe(42);
+        });
+    });
+
+    describe('Error Handling', () => {
+        it('should provide helpful parse errors', () => {
+            expect(() => generateRuntimeFunction('5 +')).toThrow('CSP Parser Error');
+            expect(() => generateRuntimeFunction('{ foo: }')).toThrow('CSP Parser Error');
+            expect(() => generateRuntimeFunction('"unclosed string')).toThrow('Unterminated string');
+        });
+
+        it('should provide helpful runtime errors', () => {
+            expect(() => generateRuntimeFunction('nonExistent')()).toThrow('Undefined variable');
+            expect(() => generateRuntimeFunction('5()')()).toThrow('not a function');
+            expect(() => generateRuntimeFunction('null.prop')()).toThrow('Cannot read property');
+        });
+    });
+
+    describe('Edge Cases', () => {
+        it('should handle empty input gracefully', () => {
+            expect(() => generateRuntimeFunction('')).toThrow('CSP Parser Error');
+        });
+
+        it('should handle whitespace', () => {
+            expect(generateRuntimeFunction('  5  ')()).toBe(5);
+            expect(generateRuntimeFunction('2   +   3')()).toBe(5);
+            expect(generateRuntimeFunction('  true   ?   1   :   2  ')()).toBe(1);
+        });
+
+        it('should handle line comments', () => {
+            expect(generateRuntimeFunction('5 // this is a comment')()).toBe(5);
+            expect(generateRuntimeFunction('2 + 3 // add numbers')()).toBe(5);
+        });
+
+        it('should handle deeply nested expressions', () => {
+            const scope = {
+                a: { b: { c: { d: { e: 'deep' } } } }
+            };
+            expect(generateRuntimeFunction('a.b.c.d.e')(scope)).toBe('deep');
+        });
+
+        it('should handle complex nested structures', () => {
+            const scope = {
+                data: {
+                    users: [
+                        { name: 'Alice', scores: [90, 85, 88] },
+                        { name: 'Bob', scores: [78, 92, 85] }
+                    ]
+                },
+                index: 1
+            };
+            expect(generateRuntimeFunction('data.users[index].name')(scope)).toBe('Bob');
+            expect(generateRuntimeFunction('data.users[0].scores[2]')(scope)).toBe(88);
+        });
+    });
+
+    describe('Unsupported Features', () => {
+        it('should not support arrow functions', () => {
+            expect(() => generateRuntimeFunction('() => 5')).toThrow();
+        });
+
+        it('should not support function expressions', () => {
+            expect(() => generateRuntimeFunction('function() { return 5; }')).toThrow();
+        });
+
+        it('should not support template literals', () => {
+            expect(() => generateRuntimeFunction('`hello`')).toThrow();
+        });
+
+        it('should not support spread operator', () => {
+            expect(() => generateRuntimeFunction('[...arr]')).toThrow();
+            expect(() => generateRuntimeFunction('{ ...obj }')).toThrow();
+        });
+
+        it('should not support destructuring', () => {
+            expect(() => generateRuntimeFunction('{ a, b } = obj')).toThrow();
+            expect(() => generateRuntimeFunction('[a, b] = arr')).toThrow();
+        });
+
+        it('should not support optional chaining', () => {
+            expect(() => generateRuntimeFunction('obj?.prop')).toThrow();
+        });
+
+        it('should not support nullish coalescing', () => {
+            expect(() => generateRuntimeFunction('value ?? default')).toThrow();
+        });
+
+        it('should not support compound assignment', () => {
+            expect(() => generateRuntimeFunction('x += 5')).toThrow();
+            expect(() => generateRuntimeFunction('x *= 2')).toThrow();
+        });
+
+        it('should not support new operator', () => {
+            expect(() => generateRuntimeFunction('new Date()')).toThrow();
+        });
+
+        it('should not support typeof operator', () => {
+            expect(() => generateRuntimeFunction('typeof value')).toThrow();
+        });
+
+        it('should not support in operator', () => {
+            expect(() => generateRuntimeFunction('"prop" in obj')).toThrow();
+        });
+
+        it('should not support instanceof operator', () => {
+            expect(() => generateRuntimeFunction('obj instanceof Array')).toThrow();
+        });
+
+        it('should not support void operator', () => {
+            expect(() => generateRuntimeFunction('void 0')).toThrow();
+        });
+
+        it('should not support delete operator', () => {
+            expect(() => generateRuntimeFunction('delete obj.prop')).toThrow();
+        });
+
+        it('should not support regex literals', () => {
+            expect(() => generateRuntimeFunction('/pattern/g')).toThrow();
+        });
+
+        it('should not support class expressions', () => {
+            expect(() => generateRuntimeFunction('class Foo {}')).toThrow();
+        });
+
+        it('should not support async/await', () => {
+            expect(() => generateRuntimeFunction('async function() {}')).toThrow();
+            expect(() => generateRuntimeFunction('await promise')).toThrow();
+        });
+
+        it('should not support generators', () => {
+            expect(() => generateRuntimeFunction('function* gen() {}')).toThrow();
+            expect(() => generateRuntimeFunction('yield value')).toThrow();
+        });
+
+        it('should not support dynamic code execution', () => {
+            // eval is now accessible as a global, but the CSP will catch it at runtime
+            // Here we test that eval runs but the string code isn't defined
+            expect(() => generateRuntimeFunction('eval("code")')()).toThrow('code is not defined');
+
+            // new operator is not supported by parser
+            expect(() => generateRuntimeFunction('new Function("code")')).toThrow();
+        });
+    });
+
+    describe('Trailing Semicolons', () => {
+        it('should handle expressions with trailing semicolons', () => {
+            expect(generateRuntimeFunction('42;')()).toBe(42);
+            expect(generateRuntimeFunction('"hello";')()).toBe('hello');
+            expect(generateRuntimeFunction('true;')()).toBe(true);
+            expect(generateRuntimeFunction('null;')()).toBe(null);
+        });
+
+        it('should handle complex expressions with trailing semicolons', () => {
+            expect(generateRuntimeFunction('2 + 3;')()).toBe(5);
+            expect(generateRuntimeFunction('10 > 5;')()).toBe(true);
+            expect(generateRuntimeFunction('false || true;')()).toBe(true);
+
+            const scope = { name: 'world' };
+            expect(generateRuntimeFunction('"hello " + name;')(scope)).toBe('hello world');
+        });
+
+        it('should handle function calls with trailing semicolons', () => {
+            const scope = {
+                getValue: () => 42,
+                obj: {
+                    method: function() { return this.name; },
+                    name: 'test'
+                }
+            };
+
+            expect(generateRuntimeFunction('getValue();')(scope)).toBe(42);
+            expect(generateRuntimeFunction('obj.method();')(scope)).toBe('test');
+        });
+
+        it('should handle assignments with trailing semicolons', () => {
+            const scope = { x: 0, obj: { prop: 5 } };
+
+            expect(generateRuntimeFunction('x = 10;')(scope)).toBe(10);
+            expect(scope.x).toBe(10);
+
+            expect(generateRuntimeFunction('obj.prop = 20;')(scope)).toBe(20);
+            expect(scope.obj.prop).toBe(20);
+        });
+
+        it('should handle increment/decrement with trailing semicolons', () => {
+            const scope = { count: 5 };
+
+            expect(generateRuntimeFunction('++count;')(scope)).toBe(6);
+            expect(scope.count).toBe(6);
+
+            expect(generateRuntimeFunction('count--;')(scope)).toBe(6);
+            expect(scope.count).toBe(5);
+        });
+
+        it('should work with ternary expressions and trailing semicolons', () => {
+            expect(generateRuntimeFunction('true ? "yes" : "no";')()).toBe('yes');
+            expect(generateRuntimeFunction('false ? 1 : 2;')()).toBe(2);
+
+            const scope = { age: 25 };
+            expect(generateRuntimeFunction('age >= 18 ? "adult" : "minor";')(scope)).toBe('adult');
+        });
+
+        it('should work without semicolons (backward compatibility)', () => {
+            expect(generateRuntimeFunction('42')()).toBe(42);
+            expect(generateRuntimeFunction('2 + 3')()).toBe(5);
+            expect(generateRuntimeFunction('true ? "yes" : "no"')()).toBe('yes');
+        });
+    });
+});

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini