Skip to main content

pmcp_code_mode/
executor.rs

1//! AST-based JavaScript execution for Code Mode.
2//!
3//! This module provides secure execution of validated JavaScript code by:
4//! 1. Compiling the SWC AST to an ExecutionPlan
5//! 2. Executing the plan in pure Rust (no JS runtime)
6//!
7//! ## Security Model
8//!
9//! Only operations that can be represented in the ExecutionPlan are allowed.
10//! The plan is a simple tree structure that's fully auditable before execution.
11//!
12//! ## Supported Operations
13//!
14//! - API calls: `api.get()`, `api.post()`, `api.put()`, `api.delete()`, `api.patch()`
15//! - Variable assignment: `const x = ...`
16//! - Property access: `user.id`, `response.data`
17//! - Array methods: `.map()`, `.filter()`, `.slice()`, `.find()`, `.length`
18//! - Template literals: `` `/users/${id}` ``
19//! - Object literals: `{ name: "test", id: 123 }`
20//! - Array literals: `[1, 2, 3]`
21//! - Conditionals: `if/else`
22//! - Bounded loops: `for (const x of arr.slice(0, N))`
23//! - Return statements
24
25use crate::javascript::HttpMethod;
26use crate::types::ExecutionError;
27use serde::Serialize;
28use serde_json::Value as JsonValue;
29use std::collections::{HashMap, HashSet};
30
31// Import shared evaluation functions
32use crate::eval::{
33    evaluate as shared_evaluate, is_truthy as shared_is_truthy,
34    json_to_string_with_mode as shared_json_to_string_with_mode, JsonStringMode,
35};
36use swc_common::{FileName, SourceMap};
37use swc_ecma_ast::*;
38use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax};
39
40/// Internal control flow outcome from executing a single plan step.
41///
42/// Replaces the previous pattern of abusing `ExecutionError::LoopContinue`
43/// and `ExecutionError::LoopBreak` for non-error control flow.
44pub(crate) enum StepOutcome {
45    /// Step completed, no value produced.
46    None,
47    /// Step produced a return value (exits function/plan).
48    Return(serde_json::Value),
49    /// Loop continue signal (skip to next iteration).
50    Continue,
51    /// Loop break signal (exit current loop).
52    Break,
53}
54
55/// Configuration for execution.
56#[derive(Debug, Clone)]
57pub struct ExecutionConfig {
58    /// Maximum number of API calls allowed
59    pub max_api_calls: usize,
60    /// Maximum execution time in seconds
61    pub timeout_seconds: u64,
62    /// Maximum loop iterations
63    pub max_loop_iterations: usize,
64    /// Fields that should be filtered from API responses (internal blocklist).
65    /// These fields are stripped from responses before scripts can access them.
66    /// Field names are case-sensitive and matched at any nesting level.
67    pub blocked_fields: HashSet<String>,
68    /// Fields that cannot appear in script output (output blocklist).
69    /// These fields can be used internally but cannot be returned by the script.
70    /// Field names are case-sensitive and matched at any nesting level.
71    pub output_blocked_fields: HashSet<String>,
72}
73
74impl Default for ExecutionConfig {
75    fn default() -> Self {
76        Self {
77            max_api_calls: 50,
78            timeout_seconds: 30,
79            max_loop_iterations: 100,
80            blocked_fields: HashSet::new(),
81            output_blocked_fields: HashSet::new(),
82        }
83    }
84}
85
86impl ExecutionConfig {
87    /// Create a new config with blocked fields for API response filtering.
88    pub fn with_blocked_fields(
89        mut self,
90        fields: impl IntoIterator<Item = impl Into<String>>,
91    ) -> Self {
92        self.blocked_fields = fields.into_iter().map(Into::into).collect();
93        self
94    }
95
96    /// Create a new config with output blocked fields for return value validation.
97    pub fn with_output_blocked_fields(
98        mut self,
99        fields: impl IntoIterator<Item = impl Into<String>>,
100    ) -> Self {
101        self.output_blocked_fields = fields.into_iter().map(Into::into).collect();
102        self
103    }
104}
105
106/// Recursively filter blocked fields from a JSON value.
107///
108/// This removes any fields whose names are in the blocklist from objects,
109/// and recursively processes nested objects and arrays.
110///
111/// # Arguments
112///
113/// * `value` - The JSON value to filter
114/// * `blocked_fields` - Set of field names to remove
115///
116/// # Returns
117///
118/// A new JSON value with blocked fields removed.
119pub fn filter_blocked_fields(value: JsonValue, blocked_fields: &HashSet<String>) -> JsonValue {
120    if blocked_fields.is_empty() {
121        return value;
122    }
123
124    match value {
125        JsonValue::Object(mut map) => {
126            // Remove blocked fields at this level
127            map.retain(|key, _| !blocked_fields.contains(key));
128
129            // Recursively filter remaining values
130            let filtered: serde_json::Map<String, JsonValue> = map
131                .into_iter()
132                .map(|(k, v)| (k, filter_blocked_fields(v, blocked_fields)))
133                .collect();
134
135            JsonValue::Object(filtered)
136        },
137        JsonValue::Array(arr) => {
138            // Recursively filter each element
139            let filtered: Vec<JsonValue> = arr
140                .into_iter()
141                .map(|v| filter_blocked_fields(v, blocked_fields))
142                .collect();
143
144            JsonValue::Array(filtered)
145        },
146        // Non-container values pass through unchanged
147        other => other,
148    }
149}
150
151/// Find blocked fields that appear in a JSON value (output validation).
152///
153/// Unlike `filter_blocked_fields` which silently removes fields, this function
154/// identifies blocked fields without modifying the value. Used for output
155/// blocklist validation where blocked fields can be used internally but
156/// cannot appear in script output.
157///
158/// # Arguments
159///
160/// * `value` - The JSON value to check
161/// * `blocked_fields` - Set of field names that are not allowed in output
162///
163/// # Returns
164///
165/// A vector of blocked field names found in the value, along with their paths.
166pub fn find_blocked_fields_in_output(
167    value: &JsonValue,
168    blocked_fields: &HashSet<String>,
169) -> Vec<String> {
170    if blocked_fields.is_empty() {
171        return Vec::new();
172    }
173
174    let mut violations = Vec::new();
175    find_blocked_fields_recursive(value, blocked_fields, "", &mut violations);
176    violations
177}
178
179/// Recursively find blocked fields in a JSON value.
180fn find_blocked_fields_recursive(
181    value: &JsonValue,
182    blocked_fields: &HashSet<String>,
183    path: &str,
184    violations: &mut Vec<String>,
185) {
186    match value {
187        JsonValue::Object(map) => visit_object(map, blocked_fields, path, violations),
188        JsonValue::Array(arr) => visit_array(arr, blocked_fields, path, violations),
189        // Non-container values don't need checking.
190        _ => {},
191    }
192}
193
194/// Walk a JSON object: record direct blocked-key matches, then recurse into
195/// each value with an extended dotted path.
196fn visit_object(
197    map: &serde_json::Map<String, JsonValue>,
198    blocked_fields: &HashSet<String>,
199    path: &str,
200    violations: &mut Vec<String>,
201) {
202    for (key, v) in map {
203        let key_path = join_field_path(path, key);
204        if blocked_fields.contains(key) {
205            violations.push(key_path.clone());
206        }
207        find_blocked_fields_recursive(v, blocked_fields, &key_path, violations);
208    }
209}
210
211/// Walk a JSON array: recurse into each element with an indexed path.
212fn visit_array(
213    arr: &[JsonValue],
214    blocked_fields: &HashSet<String>,
215    path: &str,
216    violations: &mut Vec<String>,
217) {
218    for (i, v) in arr.iter().enumerate() {
219        let elem_path = join_index_path(path, i);
220        find_blocked_fields_recursive(v, blocked_fields, &elem_path, violations);
221    }
222}
223
224/// Append a dotted field segment to a path: `""` + `"k"` → `"k"`, `"a.b"` + `"k"` → `"a.b.k"`.
225fn join_field_path(path: &str, key: &str) -> String {
226    if path.is_empty() {
227        key.to_string()
228    } else {
229        format!("{}.{}", path, key)
230    }
231}
232
233/// Append an indexed segment to a path: `""` + `0` → `"[0]"`, `"a"` + `1` → `"a[1]"`.
234fn join_index_path(path: &str, idx: usize) -> String {
235    if path.is_empty() {
236        format!("[{}]", idx)
237    } else {
238        format!("{}[{}]", path, idx)
239    }
240}
241
242/// An execution plan compiled from JavaScript AST.
243#[derive(Debug, Clone, Serialize)]
244pub struct ExecutionPlan {
245    /// The steps to execute
246    pub steps: Vec<PlanStep>,
247    /// Metadata about the plan
248    pub metadata: PlanMetadata,
249}
250
251/// Metadata about the execution plan.
252#[derive(Debug, Clone, Serialize)]
253pub struct PlanMetadata {
254    /// Total number of API calls in the plan
255    pub api_call_count: usize,
256    /// Whether any mutations (POST/PUT/DELETE/PATCH) are present
257    pub has_mutations: bool,
258    /// List of all endpoints accessed
259    pub endpoints: Vec<String>,
260    /// HTTP methods used
261    pub methods_used: Vec<String>,
262}
263
264/// A single step in the execution plan.
265#[derive(Debug, Clone, Serialize)]
266pub enum PlanStep {
267    /// API call: `const result = await api.get('/path')`
268    ApiCall {
269        result_var: String,
270        method: String,
271        path: PathTemplate,
272        body: Option<ValueExpr>,
273    },
274
275    /// Variable assignment: `const x = expr`
276    Assign { var: String, expr: ValueExpr },
277
278    /// Conditional: `if (cond) { ... } else { ... }`
279    Conditional {
280        condition: ValueExpr,
281        then_steps: Vec<PlanStep>,
282        else_steps: Vec<PlanStep>,
283    },
284
285    /// Bounded loop: `for (const x of arr.slice(0, N)) { ... }`
286    BoundedLoop {
287        item_var: String,
288        collection: ValueExpr,
289        max_iterations: usize,
290        body: Vec<PlanStep>,
291    },
292
293    /// Return statement
294    Return { value: ValueExpr },
295
296    /// Try/catch statement: `try { ... } catch (e) { ... }`
297    TryCatch {
298        try_steps: Vec<PlanStep>,
299        catch_var: Option<String>,
300        catch_steps: Vec<PlanStep>,
301        finally_steps: Vec<PlanStep>,
302    },
303
304    /// Parallel API calls: `const [a, b] = await Promise.all([api.get(...), api.get(...)])`
305    ParallelApiCalls {
306        result_var: String,
307        calls: Vec<(String, String, PathTemplate, Option<ValueExpr>)>, // (temp_var, method, path, body)
308    },
309
310    /// Continue statement: skip to next loop iteration
311    Continue,
312
313    /// Break statement: exit the current loop
314    Break,
315
316    /// MCP tool call: `const result = await mcp.call('server', 'tool', { ... })`
317    #[cfg(feature = "mcp-code-mode")]
318    McpCall {
319        result_var: String,
320        server_id: String,
321        tool_name: String,
322        args: Option<ValueExpr>,
323    },
324
325    /// SDK call: `const result = await api.getCostAndUsage({ start_date: '...' })`
326    SdkCall {
327        result_var: String,
328        operation: String,       // camelCase SDK operation name
329        args: Option<ValueExpr>, // Single object argument
330    },
331}
332
333/// A path template that may contain interpolations.
334#[derive(Debug, Clone, Serialize)]
335pub struct PathTemplate {
336    /// Parts of the path
337    pub parts: Vec<PathPart>,
338}
339
340impl PathTemplate {
341    /// Create a static path.
342    pub fn static_path(path: String) -> Self {
343        Self {
344            parts: vec![PathPart::Literal(path)],
345        }
346    }
347
348    /// Check if this path has any dynamic parts.
349    pub fn is_dynamic(&self) -> bool {
350        self.parts
351            .iter()
352            .any(|p| matches!(p, PathPart::Variable(_) | PathPart::Expression(_)))
353    }
354}
355
356/// A part of a path template.
357#[derive(Debug, Clone, Serialize)]
358pub enum PathPart {
359    /// Literal string
360    Literal(String),
361    /// Variable reference: `${id}`
362    Variable(String),
363    /// Expression: `${user.id}`
364    Expression(ValueExpr),
365}
366
367/// An expression that produces a value.
368#[derive(Debug, Clone, Serialize)]
369pub enum ValueExpr {
370    /// Literal value (null, bool, number, string)
371    Literal(JsonValue),
372
373    /// Variable reference
374    Variable(String),
375
376    /// Property access: `obj.prop`
377    PropertyAccess {
378        object: Box<ValueExpr>,
379        property: String,
380    },
381
382    /// Array index: `arr[0]`
383    ArrayIndex {
384        array: Box<ValueExpr>,
385        index: Box<ValueExpr>,
386    },
387
388    /// Object literal: `{ key: value, ...spread }`
389    ObjectLiteral { fields: Vec<ObjectField> },
390
391    /// Array literal: `[1, 2, 3]`
392    ArrayLiteral { items: Vec<ValueExpr> },
393
394    /// Array method call
395    ArrayMethod {
396        array: Box<ValueExpr>,
397        method: ArrayMethodCall,
398    },
399
400    /// Number method call: `num.toFixed()`, etc.
401    NumberMethod {
402        number: Box<ValueExpr>,
403        method: NumberMethodCall,
404    },
405
406    /// Binary operation: `a + b`, `a === b`, etc.
407    BinaryOp {
408        left: Box<ValueExpr>,
409        op: BinaryOperator,
410        right: Box<ValueExpr>,
411    },
412
413    /// Unary operation: `!a`
414    UnaryOp {
415        op: UnaryOperator,
416        operand: Box<ValueExpr>,
417    },
418
419    /// Ternary: `cond ? a : b`
420    Ternary {
421        condition: Box<ValueExpr>,
422        consequent: Box<ValueExpr>,
423        alternate: Box<ValueExpr>,
424    },
425
426    /// Optional chaining: `obj?.prop`
427    OptionalChain {
428        object: Box<ValueExpr>,
429        property: String,
430    },
431
432    /// Nullish coalescing: `a ?? b`
433    NullishCoalesce {
434        left: Box<ValueExpr>,
435        right: Box<ValueExpr>,
436    },
437
438    /// Await expression (for Promise.all, etc.)
439    Await { expr: Box<ValueExpr> },
440
441    /// Promise.all: `Promise.all([...])`
442    PromiseAll { items: Vec<ValueExpr> },
443
444    /// API call expression (when used inline, not as a statement)
445    ApiCall {
446        method: String,
447        path: PathTemplate,
448        body: Option<Box<ValueExpr>>,
449    },
450
451    /// Block expression with local variable bindings and a final result.
452    /// Used for arrow function block bodies: `x => { const a = x.foo; return a; }`
453    Block {
454        /// Local variable bindings: `[(name, expr), ...]`
455        bindings: Vec<(String, ValueExpr)>,
456        /// The final expression to evaluate and return
457        result: Box<ValueExpr>,
458    },
459
460    /// MCP tool call expression: `mcp.call('server', 'tool', args)`
461    #[cfg(feature = "mcp-code-mode")]
462    McpCall {
463        server_id: String,
464        tool_name: String,
465        args: Option<Box<ValueExpr>>,
466    },
467
468    /// SDK call expression (used in non-assignment contexts): `api.getCostAndUsage({ ... })`
469    SdkCall {
470        operation: String,
471        args: Option<Box<ValueExpr>>,
472    },
473
474    /// Built-in function call: `parseFloat(x)`, `Math.abs(x)`, `Object.keys(obj)`
475    BuiltinCall {
476        func: BuiltinFunction,
477        args: Vec<ValueExpr>,
478    },
479}
480
481/// A field within an object literal, either a regular key-value pair or a spread.
482#[derive(Debug, Clone, Serialize)]
483pub enum ObjectField {
484    /// Regular key-value pair: `key: value`
485    KeyValue { key: String, value: ValueExpr },
486    /// Spread: `...expr`
487    Spread { expr: ValueExpr },
488}
489
490/// Array method calls.
491#[derive(Debug, Clone, Serialize)]
492pub enum ArrayMethodCall {
493    /// `.map(x => expr)`
494    Map {
495        item_var: String,
496        body: Box<ValueExpr>,
497    },
498    /// `.filter(x => expr)`
499    Filter {
500        item_var: String,
501        predicate: Box<ValueExpr>,
502    },
503    /// `.find(x => expr)`
504    Find {
505        item_var: String,
506        predicate: Box<ValueExpr>,
507    },
508    /// `.slice(start, end)`
509    Slice { start: usize, end: Option<usize> },
510    /// `.length`
511    Length,
512    /// `.some(x => expr)`
513    Some {
514        item_var: String,
515        predicate: Box<ValueExpr>,
516    },
517    /// `.every(x => expr)`
518    Every {
519        item_var: String,
520        predicate: Box<ValueExpr>,
521    },
522    /// `.reduce((acc, x) => expr, init)`
523    Reduce {
524        acc_var: String,
525        item_var: String,
526        body: Box<ValueExpr>,
527        initial: Box<ValueExpr>,
528    },
529    /// `.push(item)` - returns new array (pure)
530    Push { item: Box<ValueExpr> },
531    /// `.concat(other)`
532    Concat { other: Box<ValueExpr> },
533    /// `.includes(item)`
534    Includes { item: Box<ValueExpr> },
535    /// `.indexOf(item)`
536    IndexOf { item: Box<ValueExpr> },
537    /// `.join(separator)`
538    Join { separator: Option<String> },
539    /// `.reverse()` - returns new array (pure)
540    Reverse,
541    /// `.sort()` or `.sort((a, b) => expr)` - returns new array (pure)
542    Sort {
543        comparator: Option<(String, String, Box<ValueExpr>)>,
544    },
545    /// `.flat()` - flatten nested arrays
546    Flat,
547    /// `.flatMap(x => expr)`
548    FlatMap {
549        item_var: String,
550        body: Box<ValueExpr>,
551    },
552    /// Get first element: `[0]` or `.at(0)`
553    First,
554    /// Get last element: `.at(-1)`
555    Last,
556    /// `.toLowerCase()` (string-only)
557    ToLowerCase,
558    /// `.toUpperCase()` (string-only)
559    ToUpperCase,
560    /// `.startsWith(searchString)`
561    StartsWith { search: Box<ValueExpr> },
562    /// `.endsWith(searchString)`
563    EndsWith { search: Box<ValueExpr> },
564    /// `.trim()`
565    Trim,
566    /// `.replace(search, replacement)` — first occurrence only
567    Replace {
568        search: Box<ValueExpr>,
569        replacement: Box<ValueExpr>,
570    },
571    /// `.split(separator)`
572    Split { separator: Box<ValueExpr> },
573    /// `.substring(start, end?)`
574    Substring {
575        start: Box<ValueExpr>,
576        end: Option<Box<ValueExpr>>,
577    },
578    /// `.toString()` — works on strings (identity), numbers, arrays, objects
579    ToString,
580}
581
582/// Number method calls.
583#[derive(Debug, Clone, Serialize)]
584pub enum NumberMethodCall {
585    /// `.toFixed(digits)`
586    ToFixed { digits: usize },
587    /// `.toString()`
588    ToString,
589}
590
591/// Built-in global functions and static methods.
592///
593/// These mirror JavaScript built-in functions (parseFloat, parseInt, Number)
594/// and static methods (Math.abs, Object.keys, etc.) that are commonly used
595/// in cost analysis scripts.
596#[derive(Debug, Clone, Serialize)]
597pub enum BuiltinFunction {
598    // Type conversion
599    ParseFloat,
600    ParseInt,
601    NumberCast,
602    // Math static methods
603    MathAbs,
604    MathMax,
605    MathMin,
606    MathRound,
607    MathFloor,
608    MathCeil,
609    // Object static methods
610    ObjectKeys,
611    ObjectValues,
612    ObjectEntries,
613}
614
615/// Binary operators.
616#[derive(Debug, Clone, Copy, Serialize)]
617pub enum BinaryOperator {
618    // Arithmetic
619    Add,
620    Sub,
621    Mul,
622    Div,
623    Mod,
624    // Bitwise (integer truncation)
625    BitwiseOr,
626    // Comparison
627    Eq,
628    NotEq,
629    StrictEq,
630    StrictNotEq,
631    Lt,
632    Lte,
633    Gt,
634    Gte,
635    // Logical
636    And,
637    Or,
638    // String
639    Concat,
640}
641
642/// Unary operators.
643#[derive(Debug, Clone, Copy, Serialize)]
644pub enum UnaryOperator {
645    Not,
646    Neg,
647    Plus,
648    TypeOf,
649}
650
651/// Error during plan compilation.
652#[derive(Debug, thiserror::Error)]
653pub enum CompileError {
654    #[error("Unsupported statement type: {0}")]
655    UnsupportedStatement(String),
656
657    #[error("Unsupported expression type: {0}")]
658    UnsupportedExpression(String),
659
660    #[error("Invalid API call: {0}")]
661    InvalidApiCall(String),
662
663    #[error("Unbounded loop detected")]
664    UnboundedLoop,
665
666    #[error("Invalid path template: {0}")]
667    InvalidPath(String),
668
669    #[error("Too many API calls in plan: {count} (max: {max})")]
670    TooManyApiCalls { count: usize, max: usize },
671
672    #[error("Unsupported array method: {0}")]
673    UnsupportedArrayMethod(String),
674
675    #[error("Parse error: {0}")]
676    ParseError(String),
677
678    #[error("Missing variable name")]
679    MissingVariableName,
680}
681
682impl CompileError {
683    /// The text to show the CALLER of a script that failed to compile.
684    ///
685    /// Every variant's message is the compiler's own static guidance ("'while' loops
686    /// are not supported. Use for-of with .slice() instead"), which is what a model
687    /// needs to rewrite the script, with one exception: [`CompileError::ParseError`]
688    /// carries the `Debug` rendering of the parser's error, which quotes the token it
689    /// choked on and so repeats the caller's own code. It is replaced by a fixed
690    /// sentence. A refusal must not echo what the caller wrote.
691    #[must_use]
692    pub fn caller_message(&self) -> String {
693        match self {
694            Self::ParseError(_) => {
695                "the script has a syntax error and could not be parsed".to_string()
696            },
697            other => other.to_string(),
698        }
699    }
700}
701
702/// Result of extracting an API call from an AST expression.
703///
704/// Used by `try_extract_api_call()` to return either an HTTP-mode or SDK-mode call,
705/// enabling downstream code to create the appropriate `PlanStep` or `ValueExpr`.
706enum ExtractedCall {
707    /// HTTP call: `api.get('/path', body)` → `PlanStep::ApiCall` / `ValueExpr::ApiCall`
708    Http {
709        method: String,
710        path: PathTemplate,
711        body: Option<ValueExpr>,
712    },
713    /// SDK call: `api.getCostAndUsage({ ... })` → `PlanStep::SdkCall` / `ValueExpr::SdkCall`
714    Sdk {
715        operation: String,
716        args: Option<ValueExpr>,
717    },
718}
719
720/// Compiler that converts SWC AST to ExecutionPlan.
721pub struct PlanCompiler {
722    api_call_count: usize,
723    endpoints: Vec<String>,
724    methods_used: Vec<String>,
725    has_mutations: bool,
726    /// Set of allowed SDK operation names (camelCase). When non-empty, enables SDK mode.
727    sdk_operations: HashSet<String>,
728    /// Counter for generating unique temp variable names during destructuring.
729    destructure_counter: usize,
730}
731
732impl PlanCompiler {
733    /// Create a new compiler with default settings.
734    pub fn new() -> Self {
735        Self::with_config(&ExecutionConfig::default())
736    }
737
738    /// Create a new compiler with custom config.
739    pub fn with_config(_config: &ExecutionConfig) -> Self {
740        Self {
741            api_call_count: 0,
742            endpoints: Vec::new(),
743            methods_used: Vec::new(),
744            has_mutations: false,
745            sdk_operations: HashSet::new(),
746            destructure_counter: 0,
747        }
748    }
749
750    /// Set the allowed SDK operation names. When non-empty, the compiler operates in SDK mode:
751    /// `api.<operation>(args)` is compiled to `SdkCall` instead of HTTP `ApiCall`.
752    pub fn with_sdk_operations(mut self, operations: HashSet<String>) -> Self {
753        self.sdk_operations = operations;
754        self
755    }
756
757    /// Compile JavaScript code to an execution plan.
758    ///
759    /// This is a convenience method that parses the code and compiles it.
760    pub fn compile_code(&mut self, code: &str) -> Result<ExecutionPlan, CompileError> {
761        // Parse the JavaScript code using SWC
762        let cm = SourceMap::default();
763        let fm = cm.new_source_file(FileName::Anon.into(), code.to_string());
764
765        let lexer = Lexer::new(
766            Syntax::Es(Default::default()),
767            EsVersion::Es2022,
768            StringInput::from(&*fm),
769            None,
770        );
771
772        let mut parser = Parser::new_from(lexer);
773
774        let module = parser.parse_module().map_err(|e| {
775            CompileError::ParseError(format!("JavaScript parse error: {:?}", e.into_kind()))
776        })?;
777
778        // Compile the parsed module
779        self.compile(&module)
780    }
781
782    /// Compile a module to an execution plan.
783    pub fn compile(&mut self, module: &Module) -> Result<ExecutionPlan, CompileError> {
784        let mut steps = Vec::new();
785
786        for item in &module.body {
787            match item {
788                ModuleItem::Stmt(stmt) => {
789                    self.compile_statement(stmt, &mut steps)?;
790                },
791                _ => {
792                    return Err(CompileError::UnsupportedStatement(
793                        "import/export not allowed".into(),
794                    ));
795                },
796            }
797        }
798
799        // Deduplicate methods
800        self.methods_used.sort();
801        self.methods_used.dedup();
802
803        Ok(ExecutionPlan {
804            steps,
805            metadata: PlanMetadata {
806                api_call_count: self.api_call_count,
807                has_mutations: self.has_mutations,
808                endpoints: self.endpoints.clone(),
809                methods_used: self.methods_used.clone(),
810            },
811        })
812    }
813
814    fn compile_statement(
815        &mut self,
816        stmt: &Stmt,
817        steps: &mut Vec<PlanStep>,
818    ) -> Result<(), CompileError> {
819        match stmt {
820            // Variable declaration: const x = ... or const { a, b } = ...
821            Stmt::Decl(Decl::Var(var_decl)) => {
822                for decl in &var_decl.decls {
823                    if let Some(init) = &decl.init {
824                        match &decl.name {
825                            Pat::Ident(ident) => {
826                                let var_name = ident.id.sym.to_string();
827                                self.compile_var_init(&var_name, init, steps)?;
828                            },
829                            Pat::Object(obj_pat) => {
830                                self.compile_object_destructuring(obj_pat, init, steps)?;
831                            },
832                            Pat::Array(arr_pat) => {
833                                self.compile_array_destructuring(arr_pat, init, steps)?;
834                            },
835                            _ => {
836                                return Err(CompileError::UnsupportedExpression(
837                                    "complex destructuring pattern".into(),
838                                ));
839                            },
840                        }
841                    }
842                }
843            },
844
845            // Expression statement: await api.get(...), items.push(x), x = expr, etc.
846            Stmt::Expr(expr_stmt) => {
847                // Handle assignment expressions: `x = expr` or `x = await mcp.call(...)`
848                if let Expr::Assign(assign) = expr_stmt.expr.as_ref() {
849                    if assign.op == swc_ecma_ast::AssignOp::Assign {
850                        if let Some(ident) = assign.left.as_ident() {
851                            let var_name = ident.sym.to_string();
852                            self.compile_var_init(&var_name, &assign.right, steps)?;
853                            return Ok(());
854                        }
855                    }
856                }
857
858                let expr = self.compile_expr(&expr_stmt.expr)?;
859                match expr {
860                    // API calls at statement level are tracked as side effects
861                    ValueExpr::ApiCall { method, path, body } => {
862                        self.record_api_call(&method, &path);
863                        steps.push(PlanStep::ApiCall {
864                            result_var: "_".into(), // Discarded result
865                            method,
866                            path,
867                            body: body.map(|b| *b),
868                        });
869                    },
870                    // SDK calls at statement level (discarded result)
871                    ValueExpr::SdkCall { operation, args } => {
872                        steps.push(PlanStep::SdkCall {
873                            result_var: "_".into(), // Discarded result
874                            operation,
875                            args: args.map(|a| *a),
876                        });
877                    },
878                    // Mutating array methods (push, concat) as statements:
879                    // `items.push(x)` → assign the new array back to the variable
880                    ValueExpr::ArrayMethod {
881                        ref array,
882                        ref method,
883                    } if matches!(
884                        method,
885                        ArrayMethodCall::Push { .. } | ArrayMethodCall::Concat { .. }
886                    ) =>
887                    {
888                        if let ValueExpr::Variable(var_name) = array.as_ref() {
889                            steps.push(PlanStep::Assign {
890                                var: var_name.clone(),
891                                expr,
892                            });
893                        }
894                        // If array is not a simple variable (e.g., obj.arr.push(x)),
895                        // silently discard — same as before.
896                    },
897                    // Other expressions at statement level are discarded
898                    _ => {},
899                }
900            },
901
902            // If statement
903            Stmt::If(if_stmt) => {
904                let condition = self.compile_expr(&if_stmt.test)?;
905                let mut then_steps = Vec::new();
906                self.compile_statement(&if_stmt.cons, &mut then_steps)?;
907
908                let mut else_steps = Vec::new();
909                if let Some(alt) = &if_stmt.alt {
910                    self.compile_statement(alt, &mut else_steps)?;
911                }
912
913                steps.push(PlanStep::Conditional {
914                    condition,
915                    then_steps,
916                    else_steps,
917                });
918            },
919
920            // For-of statement: for (const x of arr) { ... } or for (const { a, b } of arr) { ... }
921            Stmt::ForOf(for_of) => {
922                let (item_var, destructure_bindings) = match &for_of.left {
923                    ForHead::VarDecl(decl) => {
924                        if let Some(first) = decl.decls.first() {
925                            self.extract_loop_var(&first.name)?
926                        } else {
927                            return Err(CompileError::MissingVariableName);
928                        }
929                    },
930                    ForHead::Pat(pat) => self.extract_loop_var(pat)?,
931                    _ => return Err(CompileError::MissingVariableName),
932                };
933
934                let collection = self.compile_expr(&for_of.right)?;
935
936                // Check if collection is bounded (has .slice())
937                let max_iterations = self.extract_bound(&collection).unwrap_or(100);
938
939                let mut body = destructure_bindings;
940                self.compile_statement(&for_of.body, &mut body)?;
941
942                steps.push(PlanStep::BoundedLoop {
943                    item_var,
944                    collection,
945                    max_iterations,
946                    body,
947                });
948            },
949
950            // Block statement: { ... }
951            Stmt::Block(block) => {
952                for stmt in &block.stmts {
953                    self.compile_statement(stmt, steps)?;
954                }
955            },
956
957            // Return statement
958            Stmt::Return(ret) => {
959                let value = if let Some(arg) = &ret.arg {
960                    self.compile_expr(arg)?
961                } else {
962                    ValueExpr::Literal(JsonValue::Null)
963                };
964                steps.push(PlanStep::Return { value });
965            },
966
967            // Empty statement
968            Stmt::Empty(_) => {},
969
970            // Try/catch statement
971            Stmt::Try(try_stmt) => {
972                let mut try_steps = Vec::new();
973                for stmt in &try_stmt.block.stmts {
974                    self.compile_statement(stmt, &mut try_steps)?;
975                }
976
977                let (catch_var, catch_steps) = if let Some(handler) = &try_stmt.handler {
978                    let var_name = handler.param.as_ref().map(|p| match p {
979                        swc_ecma_ast::Pat::Ident(ident) => ident.sym.to_string(),
980                        _ => "error".to_string(),
981                    });
982                    let mut catch_stmts = Vec::new();
983                    for stmt in &handler.body.stmts {
984                        self.compile_statement(stmt, &mut catch_stmts)?;
985                    }
986                    (var_name, catch_stmts)
987                } else {
988                    (None, Vec::new())
989                };
990
991                let finally_steps = if let Some(finalizer) = &try_stmt.finalizer {
992                    let mut finally_stmts = Vec::new();
993                    for stmt in &finalizer.stmts {
994                        self.compile_statement(stmt, &mut finally_stmts)?;
995                    }
996                    finally_stmts
997                } else {
998                    Vec::new()
999                };
1000
1001                steps.push(PlanStep::TryCatch {
1002                    try_steps,
1003                    catch_var,
1004                    catch_steps,
1005                    finally_steps,
1006                });
1007            },
1008
1009            // Continue statement: skip to next loop iteration
1010            Stmt::Continue(_) => {
1011                steps.push(PlanStep::Continue);
1012            },
1013
1014            // Break statement: exit the current loop
1015            Stmt::Break(_) => {
1016                steps.push(PlanStep::Break);
1017            },
1018
1019            Stmt::Decl(decl) => {
1020                let msg = match decl {
1021                    Decl::Fn(_) => "Function declarations are not supported. Use arrow functions inside array methods (.map, .filter) instead",
1022                    Decl::Class(_) => "Class declarations are not supported",
1023                    _ => "This declaration type is not supported",
1024                };
1025                return Err(CompileError::UnsupportedStatement(msg.into()));
1026            },
1027            Stmt::Switch(_) => {
1028                return Err(CompileError::UnsupportedStatement(
1029                    "'switch' statements are not supported. Use if/else if/else instead".into(),
1030                ));
1031            },
1032            Stmt::Throw(_) => {
1033                return Err(CompileError::UnsupportedStatement(
1034                    "'throw' statements are not supported. Use try/catch for error handling".into(),
1035                ));
1036            },
1037            Stmt::While(_) => {
1038                return Err(CompileError::UnsupportedStatement(
1039                    "'while' loops are not supported. Use for-of with .slice() instead: for (const item of array.slice(0, N)) { }".into(),
1040                ));
1041            },
1042            Stmt::DoWhile(_) => {
1043                return Err(CompileError::UnsupportedStatement(
1044                    "'do-while' loops are not supported. Use for-of with .slice() instead: for (const item of array.slice(0, N)) { }".into(),
1045                ));
1046            },
1047            Stmt::For(_) => {
1048                return Err(CompileError::UnsupportedStatement(
1049                    "'for(;;)' loops are not supported. Use for-of with .slice() instead: for (const item of array.slice(0, N)) { }".into(),
1050                ));
1051            },
1052            Stmt::ForIn(_) => {
1053                return Err(CompileError::UnsupportedStatement(
1054                    "'for-in' loops are not supported. Use for-of with .slice() instead".into(),
1055                ));
1056            },
1057            Stmt::Labeled(_) => {
1058                return Err(CompileError::UnsupportedStatement(
1059                    "Labeled statements are not supported".into(),
1060                ));
1061            },
1062            Stmt::With(_) => {
1063                return Err(CompileError::UnsupportedStatement(
1064                    "'with' statements are not supported".into(),
1065                ));
1066            },
1067            Stmt::Debugger(_) => {
1068                return Err(CompileError::UnsupportedStatement(
1069                    "'debugger' statements are not supported".into(),
1070                ));
1071            },
1072        }
1073
1074        Ok(())
1075    }
1076
1077    fn compile_var_init(
1078        &mut self,
1079        var_name: &str,
1080        init: &Expr,
1081        steps: &mut Vec<PlanStep>,
1082    ) -> Result<(), CompileError> {
1083        // Check if this is an await expression
1084        if let Expr::Await(await_expr) = init {
1085            // Check if awaiting an API call or SDK call
1086            if let Some(extracted) = self.try_extract_api_call(&await_expr.arg)? {
1087                match extracted {
1088                    ExtractedCall::Http { method, path, body } => {
1089                        self.record_api_call(&method, &path);
1090                        steps.push(PlanStep::ApiCall {
1091                            result_var: var_name.into(),
1092                            method,
1093                            path,
1094                            body,
1095                        });
1096                    },
1097                    ExtractedCall::Sdk { operation, args } => {
1098                        steps.push(PlanStep::SdkCall {
1099                            result_var: var_name.into(),
1100                            operation,
1101                            args,
1102                        });
1103                    },
1104                }
1105                return Ok(());
1106            }
1107
1108            // Check if awaiting an MCP call: const x = await mcp.call(...)
1109            #[cfg(feature = "mcp-code-mode")]
1110            if let Some((server_id, tool_name, args)) =
1111                self.try_extract_mcp_call(&await_expr.arg)?
1112            {
1113                steps.push(PlanStep::McpCall {
1114                    result_var: var_name.into(),
1115                    server_id,
1116                    tool_name,
1117                    args,
1118                });
1119                return Ok(());
1120            }
1121
1122            // Check if awaiting Promise.all([api calls...])
1123            if let Expr::Call(call) = await_expr.arg.as_ref() {
1124                let inner = self.compile_call(call)?;
1125                if let ValueExpr::PromiseAll { items } = inner {
1126                    return self.compile_promise_all(var_name, items, steps);
1127                }
1128            }
1129        }
1130
1131        // Regular assignment
1132        let expr = self.compile_expr(init)?;
1133        steps.push(PlanStep::Assign {
1134            var: var_name.into(),
1135            expr,
1136        });
1137        Ok(())
1138    }
1139
1140    /// Compile `await Promise.all([api.get(...), api.get(...)])` into a ParallelApiCalls step.
1141    /// Falls back to sequential execution if any item is not an API call.
1142    fn compile_promise_all(
1143        &mut self,
1144        result_var: &str,
1145        items: Vec<ValueExpr>,
1146        steps: &mut Vec<PlanStep>,
1147    ) -> Result<(), CompileError> {
1148        let mut calls = Vec::new();
1149        let mut all_api_calls = true;
1150
1151        for (i, item) in items.iter().enumerate() {
1152            match item {
1153                ValueExpr::ApiCall { method, path, body } => {
1154                    let temp_var = format!("__promise_all_{}_{}", result_var, i);
1155                    calls.push((
1156                        temp_var,
1157                        method.clone(),
1158                        path.clone(),
1159                        body.as_ref().map(|b| *b.clone()),
1160                    ));
1161                },
1162                _ => {
1163                    all_api_calls = false;
1164                    break;
1165                },
1166            }
1167        }
1168
1169        if all_api_calls && !calls.is_empty() {
1170            // Record all API calls for metadata
1171            for (_, method, path, _) in &calls {
1172                self.record_api_call(method, path);
1173            }
1174            steps.push(PlanStep::ParallelApiCalls {
1175                result_var: result_var.into(),
1176                calls,
1177            });
1178            Ok(())
1179        } else {
1180            // Fallback: execute items sequentially as individual API calls and collect results
1181            // This handles mixed expressions in Promise.all
1182            Err(CompileError::UnsupportedExpression(
1183                "Promise.all with non-API-call expressions".into(),
1184            ))
1185        }
1186    }
1187
1188    fn compile_expr(&mut self, expr: &Expr) -> Result<ValueExpr, CompileError> {
1189        match expr {
1190            // Literal values
1191            Expr::Lit(lit) => Ok(ValueExpr::Literal(self.lit_to_json(lit))),
1192
1193            // Variable reference
1194            Expr::Ident(ident) => Ok(ValueExpr::Variable(ident.sym.to_string())),
1195
1196            // Property access: obj.prop
1197            Expr::Member(member) => {
1198                let object = Box::new(self.compile_expr(&member.obj)?);
1199
1200                // Check if this is an array method call
1201                if let MemberProp::Ident(prop) = &member.prop {
1202                    let prop_name = prop.sym.to_string();
1203                    if prop_name == "length" {
1204                        return Ok(ValueExpr::ArrayMethod {
1205                            array: object,
1206                            method: ArrayMethodCall::Length,
1207                        });
1208                    }
1209                }
1210
1211                match &member.prop {
1212                    MemberProp::Ident(ident) => Ok(ValueExpr::PropertyAccess {
1213                        object,
1214                        property: ident.sym.to_string(),
1215                    }),
1216                    MemberProp::Computed(computed) => {
1217                        let index = Box::new(self.compile_expr(&computed.expr)?);
1218                        Ok(ValueExpr::ArrayIndex {
1219                            array: object,
1220                            index,
1221                        })
1222                    }
1223                    _ => Err(CompileError::UnsupportedExpression("private property".into())),
1224                }
1225            }
1226
1227            // Call expression: fn(), api.get(), arr.map(), etc.
1228            Expr::Call(call) => self.compile_call(call),
1229
1230            // Object literal: { key: value, ...spread }
1231            Expr::Object(obj) => {
1232                let mut fields = Vec::new();
1233                for prop in &obj.props {
1234                    match prop {
1235                        PropOrSpread::Prop(prop) => {
1236                            if let Prop::KeyValue(kv) = prop.as_ref() {
1237                                let key = self.prop_name_to_string(&kv.key)?;
1238                                let value = self.compile_expr(&kv.value)?;
1239                                fields.push(ObjectField::KeyValue { key, value });
1240                            } else if let Prop::Shorthand(ident) = prop.as_ref() {
1241                                let name = ident.sym.to_string();
1242                                fields.push(ObjectField::KeyValue {
1243                                    key: name.clone(),
1244                                    value: ValueExpr::Variable(name),
1245                                });
1246                            }
1247                        }
1248                        PropOrSpread::Spread(spread) => {
1249                            let expr = self.compile_expr(&spread.expr)?;
1250                            fields.push(ObjectField::Spread { expr });
1251                        }
1252                    }
1253                }
1254                Ok(ValueExpr::ObjectLiteral { fields })
1255            }
1256
1257            // Array literal: [1, 2, 3]
1258            Expr::Array(arr) => {
1259                let mut items = Vec::new();
1260                for elem in arr.elems.iter().flatten() {
1261                    if elem.spread.is_some() {
1262                        return Err(CompileError::UnsupportedExpression("spread".into()));
1263                    }
1264                    items.push(self.compile_expr(&elem.expr)?);
1265                }
1266                Ok(ValueExpr::ArrayLiteral { items })
1267            }
1268
1269            // Template literal: `Hello ${name}`
1270            Expr::Tpl(tpl) => {
1271                // For now, treat as string concatenation
1272                // This is used primarily for path templates
1273                let mut parts = Vec::new();
1274                for (i, quasi) in tpl.quasis.iter().enumerate() {
1275                    let raw = quasi.raw.to_string();
1276                    if !raw.is_empty() {
1277                        parts.push(ValueExpr::Literal(JsonValue::String(raw)));
1278                    }
1279                    if i < tpl.exprs.len() {
1280                        parts.push(self.compile_expr(&tpl.exprs[i])?);
1281                    }
1282                }
1283
1284                // If single part, return it directly
1285                if parts.len() == 1 {
1286                    return Ok(parts.remove(0));
1287                }
1288
1289                // Otherwise, build concatenation
1290                let mut result = parts.remove(0);
1291                for part in parts {
1292                    result = ValueExpr::BinaryOp {
1293                        left: Box::new(result),
1294                        op: BinaryOperator::Concat,
1295                        right: Box::new(part),
1296                    };
1297                }
1298                Ok(result)
1299            }
1300
1301            // Binary expression: a + b, a === b
1302            Expr::Bin(bin) => {
1303                let left = Box::new(self.compile_expr(&bin.left)?);
1304                let right = Box::new(self.compile_expr(&bin.right)?);
1305                let op = self.compile_bin_op(bin.op)?;
1306                Ok(ValueExpr::BinaryOp { left, op, right })
1307            }
1308
1309            // Unary expression: !a, -a
1310            Expr::Unary(unary) => {
1311                let operand = Box::new(self.compile_expr(&unary.arg)?);
1312                let op = match unary.op {
1313                    UnaryOp::Bang => UnaryOperator::Not,
1314                    UnaryOp::Minus => UnaryOperator::Neg,
1315                    UnaryOp::TypeOf => UnaryOperator::TypeOf,
1316                    UnaryOp::Plus => UnaryOperator::Plus,
1317                    _ => return Err(CompileError::UnsupportedExpression("unary op".into())),
1318                };
1319                Ok(ValueExpr::UnaryOp { op, operand })
1320            }
1321
1322            // Conditional/ternary: cond ? a : b
1323            Expr::Cond(cond) => {
1324                let condition = Box::new(self.compile_expr(&cond.test)?);
1325                let consequent = Box::new(self.compile_expr(&cond.cons)?);
1326                let alternate = Box::new(self.compile_expr(&cond.alt)?);
1327                Ok(ValueExpr::Ternary {
1328                    condition,
1329                    consequent,
1330                    alternate,
1331                })
1332            }
1333
1334            // Await expression
1335            Expr::Await(await_expr) => {
1336                // Check if it's an API call or SDK call
1337                if let Some(extracted) = self.try_extract_api_call(&await_expr.arg)? {
1338                    return match extracted {
1339                        ExtractedCall::Http { method, path, body } => {
1340                            self.record_api_call(&method, &path);
1341                            Ok(ValueExpr::ApiCall {
1342                                method,
1343                                path,
1344                                body: body.map(Box::new),
1345                            })
1346                        }
1347                        ExtractedCall::Sdk { operation, args } => {
1348                            Ok(ValueExpr::SdkCall {
1349                                operation,
1350                                args: args.map(Box::new),
1351                            })
1352                        }
1353                    };
1354                }
1355                // Check if it's an MCP call
1356                #[cfg(feature = "mcp-code-mode")]
1357                if let Some((server_id, tool_name, args)) = self.try_extract_mcp_call(&await_expr.arg)? {
1358                    return Ok(ValueExpr::McpCall {
1359                        server_id,
1360                        tool_name,
1361                        args: args.map(Box::new),
1362                    });
1363                }
1364                // Otherwise, just await the inner expression
1365                let inner = self.compile_expr(&await_expr.arg)?;
1366                Ok(ValueExpr::Await {
1367                    expr: Box::new(inner),
1368                })
1369            }
1370
1371            // Arrow function (for use in .map(), .filter(), etc.)
1372            Expr::Arrow(_) => {
1373                // Arrow functions are handled specially in array method compilation
1374                Err(CompileError::UnsupportedExpression(
1375                    "arrow function outside array method".into(),
1376                ))
1377            }
1378
1379            // Parenthesized expression
1380            Expr::Paren(paren) => self.compile_expr(&paren.expr),
1381
1382            // Optional chaining: obj?.prop
1383            Expr::OptChain(opt) => {
1384                match opt.base.as_ref() {
1385                    OptChainBase::Member(member) => {
1386                        let object = Box::new(self.compile_expr(&member.obj)?);
1387                        if let MemberProp::Ident(ident) = &member.prop {
1388                            Ok(ValueExpr::OptionalChain {
1389                                object,
1390                                property: ident.sym.to_string(),
1391                            })
1392                        } else {
1393                            Err(CompileError::UnsupportedExpression("computed optional chain".into()))
1394                        }
1395                    }
1396                    _ => Err(CompileError::UnsupportedExpression("optional call".into())),
1397                }
1398            }
1399
1400            Expr::This(_) => Err(CompileError::UnsupportedExpression(
1401                "'this' keyword is not supported".into(),
1402            )),
1403            Expr::Fn(_) => Err(CompileError::UnsupportedExpression(
1404                "Function expressions are not supported. Use arrow functions inside array methods (.map, .filter) instead".into(),
1405            )),
1406            Expr::Update(_) => Err(CompileError::UnsupportedExpression(
1407                "Increment/decrement operators (++, --) are not supported. Use 'x = x + 1' instead".into(),
1408            )),
1409            Expr::New(_) => Err(CompileError::UnsupportedExpression(
1410                "'new' keyword is not supported".into(),
1411            )),
1412            Expr::Seq(_) => Err(CompileError::UnsupportedExpression(
1413                "Sequence expressions (comma operator) are not supported. Use separate statements instead".into(),
1414            )),
1415            Expr::TaggedTpl(_) => Err(CompileError::UnsupportedExpression(
1416                "Tagged template literals are not supported. Use regular template literals instead".into(),
1417            )),
1418            Expr::Class(_) => Err(CompileError::UnsupportedExpression(
1419                "Class expressions are not supported".into(),
1420            )),
1421            Expr::Yield(_) => Err(CompileError::UnsupportedExpression(
1422                "Generator yield is not supported".into(),
1423            )),
1424            Expr::SuperProp(_) => Err(CompileError::UnsupportedExpression(
1425                "'super' is not supported".into(),
1426            )),
1427            Expr::Assign(_) => Err(CompileError::UnsupportedExpression(
1428                "Assignment expressions are not supported here. Use a separate variable declaration instead".into(),
1429            )),
1430            _ => Err(CompileError::UnsupportedExpression(
1431                "This expression type is not supported in the JavaScript subset".into(),
1432            )),
1433        }
1434    }
1435
1436    fn compile_call(&mut self, call: &CallExpr) -> Result<ValueExpr, CompileError> {
1437        // Check if this is an API call (HTTP) or SDK call
1438        if let Some(extracted) = self.try_extract_api_call(&Expr::Call(call.clone()))? {
1439            return match extracted {
1440                ExtractedCall::Http { method, path, body } => {
1441                    self.record_api_call(&method, &path);
1442                    Ok(ValueExpr::ApiCall {
1443                        method,
1444                        path,
1445                        body: body.map(Box::new),
1446                    })
1447                },
1448                ExtractedCall::Sdk { operation, args } => Ok(ValueExpr::SdkCall {
1449                    operation,
1450                    args: args.map(Box::new),
1451                }),
1452            };
1453        }
1454
1455        // Check if this is an MCP call: mcp.call('server', 'tool', args)
1456        #[cfg(feature = "mcp-code-mode")]
1457        if let Some((server_id, tool_name, args)) =
1458            self.try_extract_mcp_call(&Expr::Call(call.clone()))?
1459        {
1460            return Ok(ValueExpr::McpCall {
1461                server_id,
1462                tool_name,
1463                args: args.map(Box::new),
1464            });
1465        }
1466
1467        // Check if this is Promise.all
1468        if let Callee::Expr(callee) = &call.callee {
1469            if let Expr::Member(member) = callee.as_ref() {
1470                if let Expr::Ident(obj) = member.obj.as_ref() {
1471                    if obj.sym.as_ref() == "Promise" {
1472                        if let MemberProp::Ident(prop) = &member.prop {
1473                            if prop.sym.as_ref() == "all" {
1474                                if let Some(arg) = call.args.first() {
1475                                    if let Expr::Array(arr) = arg.expr.as_ref() {
1476                                        let mut items = Vec::new();
1477                                        for elem in arr.elems.iter().flatten() {
1478                                            items.push(self.compile_expr(&elem.expr)?);
1479                                        }
1480                                        return Ok(ValueExpr::PromiseAll { items });
1481                                    }
1482                                }
1483                            }
1484                        }
1485                    }
1486                }
1487            }
1488        }
1489
1490        // Check if this is an array method: arr.map(), arr.filter(), etc.
1491        if let Callee::Expr(callee) = &call.callee {
1492            if let Expr::Member(member) = callee.as_ref() {
1493                let array = Box::new(self.compile_expr(&member.obj)?);
1494
1495                if let MemberProp::Ident(method_ident) = &member.prop {
1496                    let method_name = method_ident.sym.as_ref();
1497
1498                    match method_name {
1499                        "map" => {
1500                            let (item_var, body) = self.extract_arrow_callback(call)?;
1501                            return Ok(ValueExpr::ArrayMethod {
1502                                array,
1503                                method: ArrayMethodCall::Map {
1504                                    item_var,
1505                                    body: Box::new(body),
1506                                },
1507                            });
1508                        },
1509                        "filter" => {
1510                            let (item_var, predicate) = self.extract_arrow_callback(call)?;
1511                            return Ok(ValueExpr::ArrayMethod {
1512                                array,
1513                                method: ArrayMethodCall::Filter {
1514                                    item_var,
1515                                    predicate: Box::new(predicate),
1516                                },
1517                            });
1518                        },
1519                        "find" => {
1520                            let (item_var, predicate) = self.extract_arrow_callback(call)?;
1521                            return Ok(ValueExpr::ArrayMethod {
1522                                array,
1523                                method: ArrayMethodCall::Find {
1524                                    item_var,
1525                                    predicate: Box::new(predicate),
1526                                },
1527                            });
1528                        },
1529                        "some" => {
1530                            let (item_var, predicate) = self.extract_arrow_callback(call)?;
1531                            return Ok(ValueExpr::ArrayMethod {
1532                                array,
1533                                method: ArrayMethodCall::Some {
1534                                    item_var,
1535                                    predicate: Box::new(predicate),
1536                                },
1537                            });
1538                        },
1539                        "every" => {
1540                            let (item_var, predicate) = self.extract_arrow_callback(call)?;
1541                            return Ok(ValueExpr::ArrayMethod {
1542                                array,
1543                                method: ArrayMethodCall::Every {
1544                                    item_var,
1545                                    predicate: Box::new(predicate),
1546                                },
1547                            });
1548                        },
1549                        "flatMap" => {
1550                            let (item_var, body) = self.extract_arrow_callback(call)?;
1551                            return Ok(ValueExpr::ArrayMethod {
1552                                array,
1553                                method: ArrayMethodCall::FlatMap {
1554                                    item_var,
1555                                    body: Box::new(body),
1556                                },
1557                            });
1558                        },
1559                        "slice" => {
1560                            let start = self.extract_number_arg(call, 0)?.unwrap_or(0) as usize;
1561                            let end = self.extract_number_arg(call, 1)?.map(|n| n as usize);
1562                            return Ok(ValueExpr::ArrayMethod {
1563                                array,
1564                                method: ArrayMethodCall::Slice { start, end },
1565                            });
1566                        },
1567                        "push" => {
1568                            if let Some(arg) = call.args.first() {
1569                                let item = Box::new(self.compile_expr(&arg.expr)?);
1570                                return Ok(ValueExpr::ArrayMethod {
1571                                    array,
1572                                    method: ArrayMethodCall::Push { item },
1573                                });
1574                            }
1575                        },
1576                        "concat" => {
1577                            if let Some(arg) = call.args.first() {
1578                                let other = Box::new(self.compile_expr(&arg.expr)?);
1579                                return Ok(ValueExpr::ArrayMethod {
1580                                    array,
1581                                    method: ArrayMethodCall::Concat { other },
1582                                });
1583                            }
1584                        },
1585                        "includes" => {
1586                            if let Some(arg) = call.args.first() {
1587                                let item = Box::new(self.compile_expr(&arg.expr)?);
1588                                return Ok(ValueExpr::ArrayMethod {
1589                                    array,
1590                                    method: ArrayMethodCall::Includes { item },
1591                                });
1592                            }
1593                        },
1594                        "indexOf" => {
1595                            if let Some(arg) = call.args.first() {
1596                                let item = Box::new(self.compile_expr(&arg.expr)?);
1597                                return Ok(ValueExpr::ArrayMethod {
1598                                    array,
1599                                    method: ArrayMethodCall::IndexOf { item },
1600                                });
1601                            }
1602                        },
1603                        "join" => {
1604                            let separator = if let Some(arg) = call.args.first() {
1605                                if let Expr::Lit(Lit::Str(s)) = arg.expr.as_ref() {
1606                                    Some(s.value.to_string_lossy().into_owned())
1607                                } else {
1608                                    None
1609                                }
1610                            } else {
1611                                None
1612                            };
1613                            return Ok(ValueExpr::ArrayMethod {
1614                                array,
1615                                method: ArrayMethodCall::Join { separator },
1616                            });
1617                        },
1618                        "reverse" => {
1619                            return Ok(ValueExpr::ArrayMethod {
1620                                array,
1621                                method: ArrayMethodCall::Reverse,
1622                            });
1623                        },
1624                        "sort" => {
1625                            let comparator = if !call.args.is_empty() {
1626                                let (a_var, b_var, body) = self.extract_reduce_callback(call)?;
1627                                Some((a_var, b_var, Box::new(body)))
1628                            } else {
1629                                None
1630                            };
1631                            return Ok(ValueExpr::ArrayMethod {
1632                                array,
1633                                method: ArrayMethodCall::Sort { comparator },
1634                            });
1635                        },
1636                        "flat" => {
1637                            return Ok(ValueExpr::ArrayMethod {
1638                                array,
1639                                method: ArrayMethodCall::Flat,
1640                            });
1641                        },
1642                        "at" => {
1643                            if let Some(n) = self.extract_number_arg(call, 0)? {
1644                                if n == 0 {
1645                                    return Ok(ValueExpr::ArrayMethod {
1646                                        array,
1647                                        method: ArrayMethodCall::First,
1648                                    });
1649                                } else if n == -1 {
1650                                    return Ok(ValueExpr::ArrayMethod {
1651                                        array,
1652                                        method: ArrayMethodCall::Last,
1653                                    });
1654                                }
1655                            }
1656                        },
1657                        // reduce((acc, item) => expr, initialValue)
1658                        "reduce" if call.args.len() >= 2 => {
1659                            let (acc_var, item_var, body) = self.extract_reduce_callback(call)?;
1660                            let initial = Box::new(self.compile_expr(&call.args[1].expr)?);
1661                            return Ok(ValueExpr::ArrayMethod {
1662                                array,
1663                                method: ArrayMethodCall::Reduce {
1664                                    acc_var,
1665                                    item_var,
1666                                    body: Box::new(body),
1667                                    initial,
1668                                },
1669                            });
1670                        },
1671                        "toFixed" => {
1672                            // Number.toFixed(digits) - treat as a number method
1673                            let digits = self.extract_number_arg(call, 0)?.unwrap_or(0) as usize;
1674                            return Ok(ValueExpr::NumberMethod {
1675                                number: array, // The "array" here is actually the number
1676                                method: NumberMethodCall::ToFixed { digits },
1677                            });
1678                        },
1679                        "toLowerCase" => {
1680                            return Ok(ValueExpr::ArrayMethod {
1681                                array,
1682                                method: ArrayMethodCall::ToLowerCase,
1683                            });
1684                        },
1685                        "toUpperCase" => {
1686                            return Ok(ValueExpr::ArrayMethod {
1687                                array,
1688                                method: ArrayMethodCall::ToUpperCase,
1689                            });
1690                        },
1691                        "startsWith" => {
1692                            let arg = call.args.first().ok_or_else(|| {
1693                                CompileError::UnsupportedExpression(
1694                                    "startsWith() requires a search argument".into(),
1695                                )
1696                            })?;
1697                            let search = Box::new(self.compile_expr(&arg.expr)?);
1698                            return Ok(ValueExpr::ArrayMethod {
1699                                array,
1700                                method: ArrayMethodCall::StartsWith { search },
1701                            });
1702                        },
1703                        "endsWith" => {
1704                            let arg = call.args.first().ok_or_else(|| {
1705                                CompileError::UnsupportedExpression(
1706                                    "endsWith() requires a search argument".into(),
1707                                )
1708                            })?;
1709                            let search = Box::new(self.compile_expr(&arg.expr)?);
1710                            return Ok(ValueExpr::ArrayMethod {
1711                                array,
1712                                method: ArrayMethodCall::EndsWith { search },
1713                            });
1714                        },
1715                        "trim" => {
1716                            return Ok(ValueExpr::ArrayMethod {
1717                                array,
1718                                method: ArrayMethodCall::Trim,
1719                            });
1720                        },
1721                        "replace" => {
1722                            if call.args.len() < 2 {
1723                                return Err(CompileError::UnsupportedExpression(
1724                                    "replace() requires search and replacement arguments".into(),
1725                                ));
1726                            }
1727                            let search = Box::new(self.compile_expr(&call.args[0].expr)?);
1728                            let replacement = Box::new(self.compile_expr(&call.args[1].expr)?);
1729                            return Ok(ValueExpr::ArrayMethod {
1730                                array,
1731                                method: ArrayMethodCall::Replace {
1732                                    search,
1733                                    replacement,
1734                                },
1735                            });
1736                        },
1737                        "split" => {
1738                            let arg = call.args.first().ok_or_else(|| {
1739                                CompileError::UnsupportedExpression(
1740                                    "split() requires a separator argument".into(),
1741                                )
1742                            })?;
1743                            let separator = Box::new(self.compile_expr(&arg.expr)?);
1744                            return Ok(ValueExpr::ArrayMethod {
1745                                array,
1746                                method: ArrayMethodCall::Split { separator },
1747                            });
1748                        },
1749                        "substring" => {
1750                            let arg = call.args.first().ok_or_else(|| {
1751                                CompileError::UnsupportedExpression(
1752                                    "substring() requires a start argument".into(),
1753                                )
1754                            })?;
1755                            let start = Box::new(self.compile_expr(&arg.expr)?);
1756                            let end = if call.args.len() >= 2 {
1757                                Some(Box::new(self.compile_expr(&call.args[1].expr)?))
1758                            } else {
1759                                None
1760                            };
1761                            return Ok(ValueExpr::ArrayMethod {
1762                                array,
1763                                method: ArrayMethodCall::Substring { start, end },
1764                            });
1765                        },
1766                        "toString" => {
1767                            return Ok(ValueExpr::ArrayMethod {
1768                                array,
1769                                method: ArrayMethodCall::ToString,
1770                            });
1771                        },
1772                        _ => {},
1773                    }
1774                }
1775            }
1776        }
1777
1778        // Check for built-in global functions: parseFloat(), parseInt(), Number()
1779        if let Callee::Expr(callee) = &call.callee {
1780            if let Expr::Ident(ident) = callee.as_ref() {
1781                let func = match ident.sym.as_ref() {
1782                    "parseFloat" => Some(BuiltinFunction::ParseFloat),
1783                    "parseInt" => Some(BuiltinFunction::ParseInt),
1784                    "Number" => Some(BuiltinFunction::NumberCast),
1785                    _ => None,
1786                };
1787                if let Some(func) = func {
1788                    let args = call
1789                        .args
1790                        .iter()
1791                        .map(|a| self.compile_expr(&a.expr))
1792                        .collect::<Result<Vec<_>, _>>()?;
1793                    return Ok(ValueExpr::BuiltinCall { func, args });
1794                }
1795            }
1796
1797            // Check for static method calls: Math.abs(), Object.keys(), etc.
1798            if let Expr::Member(member) = callee.as_ref() {
1799                if let Expr::Ident(obj) = member.obj.as_ref() {
1800                    if let MemberProp::Ident(prop) = &member.prop {
1801                        let func = match (obj.sym.as_ref(), prop.sym.as_ref()) {
1802                            ("Math", "abs") => Some(BuiltinFunction::MathAbs),
1803                            ("Math", "max") => Some(BuiltinFunction::MathMax),
1804                            ("Math", "min") => Some(BuiltinFunction::MathMin),
1805                            ("Math", "round") => Some(BuiltinFunction::MathRound),
1806                            ("Math", "floor") => Some(BuiltinFunction::MathFloor),
1807                            ("Math", "ceil") => Some(BuiltinFunction::MathCeil),
1808                            ("Object", "keys") => Some(BuiltinFunction::ObjectKeys),
1809                            ("Object", "values") => Some(BuiltinFunction::ObjectValues),
1810                            ("Object", "entries") => Some(BuiltinFunction::ObjectEntries),
1811                            _ => None,
1812                        };
1813                        if let Some(func) = func {
1814                            let args = call
1815                                .args
1816                                .iter()
1817                                .map(|a| self.compile_expr(&a.expr))
1818                                .collect::<Result<Vec<_>, _>>()?;
1819                            return Ok(ValueExpr::BuiltinCall { func, args });
1820                        }
1821                    }
1822                }
1823            }
1824        }
1825
1826        Err(CompileError::UnsupportedExpression("function call".into()))
1827    }
1828
1829    fn try_extract_api_call(&mut self, expr: &Expr) -> Result<Option<ExtractedCall>, CompileError> {
1830        let call = match expr {
1831            Expr::Call(c) => c,
1832            _ => return Ok(None),
1833        };
1834
1835        if let Callee::Expr(callee) = &call.callee {
1836            if let Expr::Member(member) = callee.as_ref() {
1837                if let Expr::Ident(obj) = member.obj.as_ref() {
1838                    if obj.sym.as_ref() == "api" {
1839                        if let MemberProp::Ident(method_ident) = &member.prop {
1840                            let method_name = method_ident.sym.as_ref();
1841
1842                            if !self.sdk_operations.is_empty() {
1843                                // SDK mode: validate against allowed operation names
1844                                if !self.sdk_operations.contains(method_name) {
1845                                    return Err(CompileError::InvalidApiCall(format!(
1846                                        "Unknown SDK operation: api.{}(). Check the code mode schema resource for available operations.",
1847                                        method_name
1848                                    )));
1849                                }
1850                                let args = if let Some(arg) = call.args.first() {
1851                                    Some(self.compile_expr(&arg.expr)?)
1852                                } else {
1853                                    None
1854                                };
1855                                self.api_call_count += 1;
1856                                let op_endpoint = format!("sdk:{}", method_name);
1857                                if !self.endpoints.contains(&op_endpoint) {
1858                                    self.endpoints.push(op_endpoint);
1859                                }
1860                                if !self.methods_used.contains(&method_name.to_string()) {
1861                                    self.methods_used.push(method_name.to_string());
1862                                }
1863                                return Ok(Some(ExtractedCall::Sdk {
1864                                    operation: method_name.to_string(),
1865                                    args,
1866                                }));
1867                            }
1868
1869                            // HTTP mode: validate it's a known HTTP method
1870                            if HttpMethod::from_str(method_name).is_none() {
1871                                return Err(CompileError::InvalidApiCall(format!(
1872                                    "Unknown method: api.{}",
1873                                    method_name
1874                                )));
1875                            }
1876
1877                            // Extract path from first argument
1878                            let path = if let Some(arg) = call.args.first() {
1879                                self.extract_path_template(&arg.expr)?
1880                            } else {
1881                                return Err(CompileError::InvalidApiCall(
1882                                    "API call requires path".into(),
1883                                ));
1884                            };
1885
1886                            // Extract body from second argument (for POST, PUT, PATCH)
1887                            let body = if let Some(arg) = call.args.get(1) {
1888                                Some(self.compile_expr(&arg.expr)?)
1889                            } else {
1890                                None
1891                            };
1892
1893                            return Ok(Some(ExtractedCall::Http {
1894                                method: method_name.to_uppercase(),
1895                                path,
1896                                body,
1897                            }));
1898                        }
1899                    }
1900                }
1901            }
1902        }
1903
1904        Ok(None)
1905    }
1906
1907    /// Try to extract an MCP call: `mcp.call('server', 'tool', { args })`
1908    ///
1909    /// Returns `(server_id, tool_name, args)` if the expression is an MCP call.
1910    #[cfg(feature = "mcp-code-mode")]
1911    fn try_extract_mcp_call(
1912        &mut self,
1913        expr: &Expr,
1914    ) -> Result<Option<(String, String, Option<ValueExpr>)>, CompileError> {
1915        let call = match expr {
1916            Expr::Call(c) => c,
1917            _ => return Ok(None),
1918        };
1919
1920        if let Callee::Expr(callee) = &call.callee {
1921            if let Expr::Member(member) = callee.as_ref() {
1922                if let Expr::Ident(obj) = member.obj.as_ref() {
1923                    if obj.sym.as_ref() == "mcp" {
1924                        if let MemberProp::Ident(method_ident) = &member.prop {
1925                            if method_ident.sym.as_ref() == "call" {
1926                                // Extract server_id from first arg (string literal)
1927                                let server_id = call.args.first()
1928                                    .and_then(|a| {
1929                                        if let Expr::Lit(Lit::Str(s)) = a.expr.as_ref() {
1930                                            Some(s.value.to_string_lossy().into_owned())
1931                                        } else {
1932                                            None
1933                                        }
1934                                    })
1935                                    .ok_or_else(|| CompileError::UnsupportedExpression(
1936                                        "mcp.call() first argument must be a string literal (server_id)".into(),
1937                                    ))?;
1938
1939                                // Extract tool_name from second arg (string literal)
1940                                let tool_name = call.args.get(1)
1941                                    .and_then(|a| {
1942                                        if let Expr::Lit(Lit::Str(s)) = a.expr.as_ref() {
1943                                            Some(s.value.to_string_lossy().into_owned())
1944                                        } else {
1945                                            None
1946                                        }
1947                                    })
1948                                    .ok_or_else(|| CompileError::UnsupportedExpression(
1949                                        "mcp.call() second argument must be a string literal (tool_name)".into(),
1950                                    ))?;
1951
1952                                // Extract args from third arg (optional object expression)
1953                                let args = call
1954                                    .args
1955                                    .get(2)
1956                                    .map(|a| self.compile_expr(&a.expr))
1957                                    .transpose()?;
1958
1959                                return Ok(Some((server_id, tool_name, args)));
1960                            }
1961                        }
1962                    }
1963                }
1964            }
1965        }
1966
1967        Ok(None)
1968    }
1969
1970    fn extract_path_template(&mut self, expr: &Expr) -> Result<PathTemplate, CompileError> {
1971        match expr {
1972            // Simple string: '/users'
1973            Expr::Lit(Lit::Str(s)) => Ok(PathTemplate::static_path(
1974                s.value.to_string_lossy().into_owned(),
1975            )),
1976
1977            // Template literal: `/users/${id}`
1978            Expr::Tpl(tpl) => {
1979                let mut parts = Vec::new();
1980                for (i, quasi) in tpl.quasis.iter().enumerate() {
1981                    let raw = quasi.raw.to_string();
1982                    if !raw.is_empty() {
1983                        parts.push(PathPart::Literal(raw));
1984                    }
1985                    if i < tpl.exprs.len() {
1986                        // Check if it's a simple variable
1987                        if let Expr::Ident(ident) = tpl.exprs[i].as_ref() {
1988                            parts.push(PathPart::Variable(ident.sym.to_string()));
1989                        } else {
1990                            // Complex expression
1991                            let expr = self.compile_expr(&tpl.exprs[i])?;
1992                            parts.push(PathPart::Expression(expr));
1993                        }
1994                    }
1995                }
1996                Ok(PathTemplate { parts })
1997            },
1998
1999            _ => Err(CompileError::InvalidPath(
2000                "Path must be a string or template literal".into(),
2001            )),
2002        }
2003    }
2004
2005    fn extract_arrow_callback(
2006        &mut self,
2007        call: &CallExpr,
2008    ) -> Result<(String, ValueExpr), CompileError> {
2009        let arg = call
2010            .args
2011            .first()
2012            .ok_or_else(|| CompileError::UnsupportedExpression("missing callback".into()))?;
2013
2014        if let Expr::Arrow(arrow) = arg.expr.as_ref() {
2015            // Get parameter name
2016            let param_name = if let Some(Pat::Ident(ident)) = arrow.params.first() {
2017                ident.id.sym.to_string()
2018            } else {
2019                return Err(CompileError::UnsupportedExpression(
2020                    "complex callback parameter".into(),
2021                ));
2022            };
2023
2024            // Compile body
2025            let body = match &*arrow.body {
2026                BlockStmtOrExpr::Expr(expr) => self.compile_expr(expr)?,
2027                BlockStmtOrExpr::BlockStmt(block) => {
2028                    // For block bodies, collect variable bindings and find return statement
2029                    let mut bindings: Vec<(String, ValueExpr)> = Vec::new();
2030                    let mut return_expr: Option<ValueExpr> = None;
2031
2032                    for stmt in &block.stmts {
2033                        match stmt {
2034                            // Variable declaration: const x = ...; or let x = ...;
2035                            Stmt::Decl(Decl::Var(var_decl)) => {
2036                                for decl in &var_decl.decls {
2037                                    let var_name = self.get_var_name(&decl.name)?;
2038                                    if let Some(init) = &decl.init {
2039                                        let expr = self.compile_expr(init)?;
2040                                        bindings.push((var_name, expr));
2041                                    }
2042                                }
2043                            },
2044                            // Return statement
2045                            Stmt::Return(ret) => {
2046                                if let Some(arg) = &ret.arg {
2047                                    return_expr = Some(self.compile_expr(arg)?);
2048                                }
2049                                break; // Stop processing after return
2050                            },
2051                            // Expression statement (e.g., a side effect)
2052                            Stmt::Expr(_) => {
2053                                // Ignore expression statements in arrow body for now
2054                            },
2055                            _ => {},
2056                        }
2057                    }
2058
2059                    match return_expr {
2060                        Some(result) if bindings.is_empty() => result,
2061                        Some(result) => ValueExpr::Block {
2062                            bindings,
2063                            result: Box::new(result),
2064                        },
2065                        None => {
2066                            return Err(CompileError::UnsupportedExpression(
2067                                "callback block without return".into(),
2068                            ));
2069                        },
2070                    }
2071                },
2072            };
2073
2074            Ok((param_name, body))
2075        } else {
2076            Err(CompileError::UnsupportedExpression(
2077                "callback must be arrow function".into(),
2078            ))
2079        }
2080    }
2081
2082    /// Extract reduce callback: (acc, item) => expr
2083    fn extract_reduce_callback(
2084        &mut self,
2085        call: &CallExpr,
2086    ) -> Result<(String, String, ValueExpr), CompileError> {
2087        let arg = call
2088            .args
2089            .first()
2090            .ok_or_else(|| CompileError::UnsupportedExpression("missing callback".into()))?;
2091
2092        if let Expr::Arrow(arrow) = arg.expr.as_ref() {
2093            // Reduce callback should have 2 parameters: (acc, item)
2094            if arrow.params.len() < 2 {
2095                return Err(CompileError::UnsupportedExpression(
2096                    "reduce callback must have 2 parameters".into(),
2097                ));
2098            }
2099
2100            let acc_name = if let Pat::Ident(ident) = &arrow.params[0] {
2101                ident.id.sym.to_string()
2102            } else {
2103                return Err(CompileError::UnsupportedExpression(
2104                    "complex callback parameter".into(),
2105                ));
2106            };
2107
2108            let item_name = if let Pat::Ident(ident) = &arrow.params[1] {
2109                ident.id.sym.to_string()
2110            } else {
2111                return Err(CompileError::UnsupportedExpression(
2112                    "complex callback parameter".into(),
2113                ));
2114            };
2115
2116            // Compile body
2117            let body = match &*arrow.body {
2118                BlockStmtOrExpr::Expr(expr) => self.compile_expr(expr)?,
2119                BlockStmtOrExpr::BlockStmt(block) => {
2120                    // For block bodies, look for return statement
2121                    for stmt in &block.stmts {
2122                        if let Stmt::Return(ret) = stmt {
2123                            if let Some(arg) = &ret.arg {
2124                                return Ok((acc_name, item_name, self.compile_expr(arg)?));
2125                            }
2126                        }
2127                    }
2128                    return Err(CompileError::UnsupportedExpression(
2129                        "callback block without return".into(),
2130                    ));
2131                },
2132            };
2133
2134            Ok((acc_name, item_name, body))
2135        } else {
2136            Err(CompileError::UnsupportedExpression(
2137                "callback must be arrow function".into(),
2138            ))
2139        }
2140    }
2141
2142    fn extract_number_arg(
2143        &self,
2144        call: &CallExpr,
2145        index: usize,
2146    ) -> Result<Option<i64>, CompileError> {
2147        if let Some(arg) = call.args.get(index) {
2148            if let Expr::Lit(Lit::Num(n)) = arg.expr.as_ref() {
2149                return Ok(Some(n.value as i64));
2150            }
2151            if let Expr::Unary(unary) = arg.expr.as_ref() {
2152                if unary.op == UnaryOp::Minus {
2153                    if let Expr::Lit(Lit::Num(n)) = unary.arg.as_ref() {
2154                        return Ok(Some(-(n.value as i64)));
2155                    }
2156                }
2157            }
2158        }
2159        Ok(None)
2160    }
2161
2162    fn extract_bound(&self, expr: &ValueExpr) -> Option<usize> {
2163        if let ValueExpr::ArrayMethod {
2164            method: ArrayMethodCall::Slice { end, .. },
2165            ..
2166        } = expr
2167        {
2168            return *end;
2169        }
2170        None
2171    }
2172
2173    fn get_var_name(&self, pat: &Pat) -> Result<String, CompileError> {
2174        match pat {
2175            Pat::Ident(ident) => Ok(ident.id.sym.to_string()),
2176            _ => Err(CompileError::UnsupportedExpression(
2177                "complex destructuring".into(),
2178            )),
2179        }
2180    }
2181
2182    /// Generate a unique temp variable name for destructuring.
2183    fn next_temp_var(&mut self) -> String {
2184        let name = format!("__destructure_{}", self.destructure_counter);
2185        self.destructure_counter += 1;
2186        name
2187    }
2188
2189    /// Extract loop variable and destructuring steps for for-of loops.
2190    /// Returns (item_var, steps_to_prepend_to_body).
2191    fn extract_loop_var(&mut self, pat: &Pat) -> Result<(String, Vec<PlanStep>), CompileError> {
2192        match pat {
2193            Pat::Ident(ident) => Ok((ident.id.sym.to_string(), Vec::new())),
2194            Pat::Object(obj_pat) => {
2195                let temp_var = self.next_temp_var();
2196                let bindings = Self::extract_object_bindings(obj_pat)?;
2197                let steps = bindings
2198                    .into_iter()
2199                    .map(|(var_name, property)| PlanStep::Assign {
2200                        var: var_name,
2201                        expr: ValueExpr::PropertyAccess {
2202                            object: Box::new(ValueExpr::Variable(temp_var.clone())),
2203                            property,
2204                        },
2205                    })
2206                    .collect();
2207                Ok((temp_var, steps))
2208            },
2209            Pat::Array(arr_pat) => {
2210                let temp_var = self.next_temp_var();
2211                let mut steps = Vec::new();
2212                for (i, elem) in arr_pat.elems.iter().enumerate() {
2213                    if let Some(p) = elem {
2214                        let var_name = self.get_var_name(p)?;
2215                        steps.push(PlanStep::Assign {
2216                            var: var_name,
2217                            expr: ValueExpr::ArrayIndex {
2218                                array: Box::new(ValueExpr::Variable(temp_var.clone())),
2219                                index: Box::new(ValueExpr::Literal(JsonValue::Number(
2220                                    (i as i64).into(),
2221                                ))),
2222                            },
2223                        });
2224                    }
2225                }
2226                Ok((temp_var, steps))
2227            },
2228            _ => Err(CompileError::UnsupportedExpression(
2229                "complex loop variable pattern".into(),
2230            )),
2231        }
2232    }
2233
2234    /// Extract (var_name, property_key) bindings from an object destructuring pattern.
2235    /// `{ a, b }` → [("a", "a"), ("b", "b")]
2236    /// `{ id: userId }` → [("userId", "id")]
2237    fn extract_object_bindings(obj_pat: &ObjectPat) -> Result<Vec<(String, String)>, CompileError> {
2238        let mut bindings = Vec::new();
2239        for prop in &obj_pat.props {
2240            match prop {
2241                ObjectPatProp::Assign(assign) => {
2242                    // Shorthand: `{ x }` — reject `{ x = default }` explicitly
2243                    if assign.value.is_some() {
2244                        return Err(CompileError::UnsupportedExpression(
2245                            "default values in destructuring".into(),
2246                        ));
2247                    }
2248                    let name = assign.key.sym.to_string();
2249                    bindings.push((name.clone(), name));
2250                },
2251                ObjectPatProp::KeyValue(kv) => {
2252                    // Renamed: `{ id: userId }`
2253                    let key = match &kv.key {
2254                        PropName::Ident(ident) => ident.sym.to_string(),
2255                        PropName::Str(s) => s.value.to_string_lossy().into_owned(),
2256                        _ => {
2257                            return Err(CompileError::UnsupportedExpression(
2258                                "computed destructuring key".into(),
2259                            ));
2260                        },
2261                    };
2262                    let var_name = match kv.value.as_ref() {
2263                        Pat::Ident(ident) => ident.id.sym.to_string(),
2264                        _ => {
2265                            return Err(CompileError::UnsupportedExpression(
2266                                "nested destructuring".into(),
2267                            ));
2268                        },
2269                    };
2270                    bindings.push((var_name, key));
2271                },
2272                ObjectPatProp::Rest(_) => {
2273                    return Err(CompileError::UnsupportedExpression(
2274                        "rest pattern in destructuring".into(),
2275                    ));
2276                },
2277            }
2278        }
2279        Ok(bindings)
2280    }
2281
2282    /// Compile object destructuring: `const { a, b } = expr`
2283    /// Generates a temp var assignment + property access assignments.
2284    fn compile_object_destructuring(
2285        &mut self,
2286        obj_pat: &ObjectPat,
2287        init: &Expr,
2288        steps: &mut Vec<PlanStep>,
2289    ) -> Result<(), CompileError> {
2290        let bindings = Self::extract_object_bindings(obj_pat)?;
2291        let temp_var = self.next_temp_var();
2292
2293        // Compile the RHS into the temp var
2294        self.compile_var_init(&temp_var, init, steps)?;
2295
2296        // Generate property access assignments for each binding
2297        for (var_name, property) in bindings {
2298            steps.push(PlanStep::Assign {
2299                var: var_name,
2300                expr: ValueExpr::PropertyAccess {
2301                    object: Box::new(ValueExpr::Variable(temp_var.clone())),
2302                    property,
2303                },
2304            });
2305        }
2306        Ok(())
2307    }
2308
2309    /// Compile array destructuring: `const [a, b] = expr`
2310    /// Generates a temp var assignment + index access assignments.
2311    fn compile_array_destructuring(
2312        &mut self,
2313        arr_pat: &ArrayPat,
2314        init: &Expr,
2315        steps: &mut Vec<PlanStep>,
2316    ) -> Result<(), CompileError> {
2317        let temp_var = self.next_temp_var();
2318
2319        // Compile the RHS into the temp var
2320        self.compile_var_init(&temp_var, init, steps)?;
2321
2322        // Generate index access assignments for each element
2323        for (i, elem) in arr_pat.elems.iter().enumerate() {
2324            if let Some(pat) = elem {
2325                let var_name = self.get_var_name(pat)?;
2326                steps.push(PlanStep::Assign {
2327                    var: var_name,
2328                    expr: ValueExpr::ArrayIndex {
2329                        array: Box::new(ValueExpr::Variable(temp_var.clone())),
2330                        index: Box::new(ValueExpr::Literal(JsonValue::Number((i as i64).into()))),
2331                    },
2332                });
2333            }
2334            // None elements (holes) are skipped: `const [, b] = arr`
2335        }
2336        Ok(())
2337    }
2338
2339    fn lit_to_json(&self, lit: &Lit) -> JsonValue {
2340        match lit {
2341            Lit::Str(s) => JsonValue::String(s.value.to_string_lossy().into_owned()),
2342            Lit::Num(n) => {
2343                if n.value.fract() == 0.0 {
2344                    JsonValue::Number((n.value as i64).into())
2345                } else {
2346                    serde_json::Number::from_f64(n.value)
2347                        .map(JsonValue::Number)
2348                        .unwrap_or(JsonValue::Null)
2349                }
2350            },
2351            Lit::Bool(b) => JsonValue::Bool(b.value),
2352            Lit::Null(_) => JsonValue::Null,
2353            _ => JsonValue::Null,
2354        }
2355    }
2356
2357    fn prop_name_to_string(&self, prop: &PropName) -> Result<String, CompileError> {
2358        match prop {
2359            PropName::Ident(ident) => Ok(ident.sym.to_string()),
2360            PropName::Str(s) => Ok(s.value.to_string_lossy().into_owned()),
2361            PropName::Num(n) => Ok(n.value.to_string()),
2362            _ => Err(CompileError::UnsupportedExpression(
2363                "computed property".into(),
2364            )),
2365        }
2366    }
2367
2368    fn compile_bin_op(&self, op: BinaryOp) -> Result<BinaryOperator, CompileError> {
2369        match op {
2370            BinaryOp::Add => Ok(BinaryOperator::Add),
2371            BinaryOp::Sub => Ok(BinaryOperator::Sub),
2372            BinaryOp::Mul => Ok(BinaryOperator::Mul),
2373            BinaryOp::Div => Ok(BinaryOperator::Div),
2374            BinaryOp::Mod => Ok(BinaryOperator::Mod),
2375            BinaryOp::BitOr => Ok(BinaryOperator::BitwiseOr),
2376            BinaryOp::EqEq => Ok(BinaryOperator::Eq),
2377            BinaryOp::NotEq => Ok(BinaryOperator::NotEq),
2378            BinaryOp::EqEqEq => Ok(BinaryOperator::StrictEq),
2379            BinaryOp::NotEqEq => Ok(BinaryOperator::StrictNotEq),
2380            BinaryOp::Lt => Ok(BinaryOperator::Lt),
2381            BinaryOp::LtEq => Ok(BinaryOperator::Lte),
2382            BinaryOp::Gt => Ok(BinaryOperator::Gt),
2383            BinaryOp::GtEq => Ok(BinaryOperator::Gte),
2384            BinaryOp::LogicalAnd => Ok(BinaryOperator::And),
2385            BinaryOp::LogicalOr => Ok(BinaryOperator::Or),
2386            BinaryOp::NullishCoalescing => {
2387                // Handled separately as NullishCoalesce expr
2388                Err(CompileError::UnsupportedExpression(
2389                    "nullish coalescing".into(),
2390                ))
2391            },
2392            _ => Err(CompileError::UnsupportedExpression(format!(
2393                "binary operator {:?}",
2394                op
2395            ))),
2396        }
2397    }
2398
2399    fn record_api_call(&mut self, method: &str, path: &PathTemplate) {
2400        self.api_call_count += 1;
2401
2402        // Track methods used
2403        if !self.methods_used.contains(&method.to_string()) {
2404            self.methods_used.push(method.to_string());
2405        }
2406
2407        // Track if mutations
2408        if method != "GET" && method != "HEAD" && method != "OPTIONS" {
2409            self.has_mutations = true;
2410        }
2411
2412        // Track endpoints (simplified path for static paths)
2413        let endpoint = if !path.is_dynamic() {
2414            path.parts
2415                .iter()
2416                .filter_map(|p| match p {
2417                    PathPart::Literal(s) => Some(s.clone()),
2418                    _ => None,
2419                })
2420                .collect::<String>()
2421        } else {
2422            "{dynamic}".to_string()
2423        };
2424        if !self.endpoints.contains(&endpoint) {
2425            self.endpoints.push(endpoint);
2426        }
2427    }
2428}
2429
2430impl Default for PlanCompiler {
2431    fn default() -> Self {
2432        Self::new()
2433    }
2434}
2435
2436// ============================================================================
2437// PLAN EXECUTOR - Executes the compiled execution plan
2438// ============================================================================
2439
2440/// A request path that has already been fully resolved and checked.
2441///
2442/// # What the value guarantees
2443///
2444/// Every value of this type was produced by [`ResolvedPath::from_checked`], which
2445/// is the only constructor. So, for any `ResolvedPath` an implementor receives:
2446///
2447/// - Every `${var}` template-literal interpolation (LAYER 1) and every `{key}`
2448///   placeholder (LAYER 2) has already been substituted. **There is nothing left
2449///   to resolve** and an implementor must not attempt its own placeholder
2450///   resolution.
2451/// - Each substituted contribution passed
2452///   [`validate_path_placeholder`](crate::validate_path_placeholder) where it was
2453///   produced.
2454/// - The COMPOSED string passed
2455///   [`validate_resolved_path`](crate::validate_resolved_path) — so it carries no
2456///   parent-directory sequence, no residual `{`/`}`, no `#`, no backslash, no
2457///   ASCII control byte, no over-cap segment and no empty interior segment, on
2458///   EITHER side of an author-written `?`. None of those is visible to a per-value
2459///   check, because a composition belongs to no single value.
2460/// - **At most one `?`,** and only one an author wrote into a
2461///   `PathPart::Literal`. See [`ResolvedPath::from_checked`] for why that single
2462///   exemption is safe.
2463/// - The `body` passed alongside has already had the path-consumed keys removed.
2464///
2465/// # What the value does NOT guarantee
2466///
2467/// It does not prove that a *spec-declared* narrowing (an OpenAPI `pattern` or
2468/// `maxLength`) was applied — that depends on the implementor's
2469/// [`HttpExecutor::placeholder_rules`] override, and an implementor that keeps the
2470/// default still gets the unconditional floor and the always-on cap but no
2471/// narrowing. It is also a **migration marker** as much as a proof carrier: its
2472/// job is to make a stale implementor written against the old `&str` parameter
2473/// fail to COMPILE rather than silently receive already-resolved data and
2474/// double-resolve it (Phase 128, D-09).
2475///
2476/// # Constructor shape
2477///
2478/// There is deliberately no `new` and no `new_unchecked`. A public unchecked
2479/// constructor whose rustdoc claims an invariant is exactly the
2480/// documented-but-absent class Phase 128 exists to correct, so the check lives
2481/// *inside* the one constructor and the type cannot be forged from outside.
2482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2483pub struct ResolvedPath<'a>(&'a str);
2484
2485impl<'a> ResolvedPath<'a> {
2486    /// Check `path` as a composed request path and wrap it on success.
2487    ///
2488    /// This is the only constructor: it runs
2489    /// [`validate_resolved_target`](crate::validate_resolved_target), so the
2490    /// invariant documented on the type is established here rather than
2491    /// asserted.
2492    ///
2493    /// # The one narrowing: an author-written `?` is permitted
2494    ///
2495    /// `validate_resolved_path` refuses a query separator ANYWHERE, and core keeps
2496    /// that strict rule — it is a general-purpose composed-path checker and other
2497    /// callers want it. Its core SIBLING `validate_resolved_target` — which this
2498    /// constructor calls, and which the curated surface
2499    /// (`pmcp_server_toolkit::http::HttpClient::check_composed_path`) calls too, so
2500    /// the two cannot drift — splits at the FIRST `?` and applies the full rule set
2501    /// to each side, which exempts exactly that one separator and nothing else.
2502    ///
2503    /// Why that is safe rather than a hole. Both per-value floors already refuse
2504    /// `?` in a substituted value, in literal AND percent-encoded form, with a
2505    /// decode-once pass so `%253F`-style regress cannot slip through. So a `?`
2506    /// surviving into the composed string can only have come from a
2507    /// `PathPart::Literal` — script text the operator authored and shipped, not
2508    /// caller data. The asymmetry with traversal is the whole point: refusing `..`
2509    /// from a literal catches a traversal bug, while refusing `?` from a literal
2510    /// rejects legitimate authoring. Same rule, different work.
2511    ///
2512    /// What the split does NOT relax, because a narrowing must not become a hole:
2513    ///
2514    /// - Traversal, control bytes, backslash, `#`, residual `{`/`}`, over-cap
2515    ///   segments and empty interior segments are checked on **both** sides. So
2516    ///   `/a/../b?x=1` is still refused for the traversal, and `/a?x=%00` is still
2517    ///   refused for the control byte.
2518    /// - A SECOND `?` is still refused: only the first is split off, so the query
2519    ///   portion is checked by the unmodified rule, which denies `?`.
2520    /// - An empty query portion is still refused — a dangling `/x?` is a doubled
2521    ///   or trailing separator, which is the same class as a trailing `/`.
2522    /// - A `?` reaching the composed string from a VALUE never gets here: the
2523    ///   per-value floor has already refused it.
2524    ///
2525    /// One inherited conservatism, stated so it is not a surprise: `%25` is
2526    /// refused outright (it is what bounds the decode to a single pass), so a query
2527    /// carrying a percent-encoded percent sign is refused. That is unchanged from
2528    /// the path portion's long-standing behaviour, not new here.
2529    ///
2530    /// # Errors
2531    ///
2532    /// Returns the [`PlaceholderRefusal`](crate::PlaceholderRefusal) from
2533    /// `validate_resolved_target`. The refusal is value-free: it names the rule and
2534    /// the declared expectation, never any byte of the path it refused.
2535    pub fn from_checked(path: &'a str) -> Result<Self, crate::PlaceholderRefusal> {
2536        crate::validate_resolved_target(path)?;
2537        Ok(Self(path))
2538    }
2539
2540    /// The resolved path as a string slice.
2541    #[must_use]
2542    pub fn as_str(&self) -> &'a str {
2543        self.0
2544    }
2545}
2546
2547impl std::fmt::Display for ResolvedPath<'_> {
2548    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2549        f.write_str(self.0)
2550    }
2551}
2552
2553/// Trait for making HTTP requests during execution.
2554///
2555/// This abstraction allows the executor to be used with different HTTP clients
2556/// and enables easy testing with mock implementations.
2557///
2558/// # The D-09 contract change (Phase 128)
2559///
2560/// `execute_request`'s path parameter used to be a bare `&str` carrying the
2561/// TEMPLATE the script wrote, and each implementor resolved its own `{key}`
2562/// placeholders inside its own impl. That made the public trait a blind seam: a
2563/// decorator wrapping `HttpExecutor` to inspect outbound requests saw only the
2564/// template and never the values, so a placeholder carrying a query separator
2565/// became a different endpoint with nothing in a position to notice.
2566///
2567/// Resolution now happens in [`PlanExecutor`] BEFORE dispatch, and the parameter
2568/// is a [`ResolvedPath`] rather than a `&str` so the change is a **compile error**
2569/// for a stale implementor instead of a silent semantic shift. A stale
2570/// implementor that kept compiling would quietly double-resolve an
2571/// already-resolved path, which for a security fix is the worst available
2572/// outcome.
2573#[async_trait::async_trait]
2574pub trait HttpExecutor: Send + Sync {
2575    /// Execute an HTTP request.
2576    ///
2577    /// `path` is already fully resolved and checked — see [`ResolvedPath`]. Do
2578    /// NOT perform placeholder substitution here; `body` has already had the
2579    /// path-consumed keys removed.
2580    async fn execute_request(
2581        &self,
2582        method: &str,
2583        path: ResolvedPath<'_>,
2584        body: Option<JsonValue>,
2585    ) -> Result<JsonValue, ExecutionError>;
2586
2587    /// The placeholder rules to apply to one LAYER-2 `{param}` value.
2588    ///
2589    /// This is the D4(b) seam on the Code Mode surface. [`PlanExecutor`] has no
2590    /// access to an OpenAPI document; the only component that does is the
2591    /// executor implementation, so the narrowing has to be asked for here.
2592    ///
2593    /// `method` is part of the signature and not decoration: an OpenAPI schema
2594    /// indexes operations by `(path, METHOD)`, so `GET /things/{id}` and
2595    /// `DELETE /things/{id}` are two operations that may declare different
2596    /// constraints for the same `id`. A `(path_template, param)` signature could
2597    /// not disambiguate them and would have to either scan linearly or narrow
2598    /// from the wrong operation.
2599    ///
2600    /// `path_template` is the path as it reaches layer 2 — after layer-1
2601    /// `${var}` interpolation and before `{key}` substitution. For the ordinary
2602    /// case of a string-literal path that is byte-identical to the OpenAPI path
2603    /// template, which is what makes a spec lookup work.
2604    ///
2605    /// # The default is safe
2606    ///
2607    /// The default returns [`PlaceholderRules::default()`](crate::PlaceholderRules),
2608    /// which is FLOOR-PLUS-CAP-WITH-NO-NARROWING. An implementor that keeps the
2609    /// default still gets the unconditional character floor and the always-on
2610    /// 256-code-point cap on every value; what is absent is only the
2611    /// spec-declared narrowing. The floor is never what a missing override costs.
2612    fn placeholder_rules(
2613        &self,
2614        method: &str,
2615        path_template: &str,
2616        param: &str,
2617    ) -> crate::PlaceholderRules<'_> {
2618        let _ = (method, path_template, param);
2619        crate::PlaceholderRules::default()
2620    }
2621}
2622
2623/// Executor for MCP foundation server calls.
2624///
2625/// Analogous to `HttpExecutor` for API calls, this trait abstracts MCP tool
2626/// invocation for use in the AST-based executor. Implementations delegate to
2627/// actual foundation clients (e.g., `CompositionClient`).
2628#[cfg(feature = "mcp-code-mode")]
2629#[async_trait::async_trait]
2630pub trait McpExecutor: Send + Sync {
2631    /// Call a tool on a foundation server.
2632    async fn call_tool(
2633        &self,
2634        server_id: &str,
2635        tool_name: &str,
2636        args: JsonValue,
2637    ) -> Result<JsonValue, ExecutionError>;
2638}
2639
2640/// Executor for SDK-backed API calls.
2641///
2642/// Analogous to `HttpExecutor` for HTTP calls, this trait abstracts named SDK
2643/// operation invocation. Implementations route `api.<operation>(args)` to actual
2644/// SDK calls (e.g., AWS Cost Explorer).
2645#[async_trait::async_trait]
2646pub trait SdkExecutor: Send + Sync {
2647    /// Execute a named SDK operation.
2648    ///
2649    /// `operation` is the camelCase method name (e.g., "getCostAndUsage").
2650    /// `args` is the optional JSON object argument from the script.
2651    async fn execute_operation(
2652        &self,
2653        operation: &str,
2654        args: Option<JsonValue>,
2655    ) -> Result<JsonValue, ExecutionError>;
2656}
2657
2658// ============================================================================
2659// MOCK HTTP EXECUTOR - For dry-run, testing, and development
2660// ============================================================================
2661
2662/// Execution mode for the mock executor.
2663#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2664pub enum MockExecutionMode {
2665    /// Dry-run: Returns mock responses, records calls for preview.
2666    #[default]
2667    DryRun,
2668    /// Testing: Returns configured mock responses for assertions.
2669    Testing,
2670    /// Record: Passes through to a real executor and records responses.
2671    Record,
2672}
2673
2674/// Mock HTTP executor for dry-run validation and testing.
2675///
2676/// This executor doesn't make real HTTP calls. Instead, it:
2677/// - Records all API calls that would be made
2678/// - Returns configurable mock responses
2679/// - Enables dry-run validation showing what a script would do
2680///
2681/// # Example
2682///
2683/// ```ignore
2684/// use mcp_server_common::code_mode::executor::{MockHttpExecutor, PlanExecutor};
2685///
2686/// // Create a mock executor for dry-run
2687/// let mock = MockHttpExecutor::new_dry_run();
2688///
2689/// // Or with custom responses for testing
2690/// let mock = MockHttpExecutor::new_testing()
2691///     .with_response("/users", json!({"users": [{"id": 1, "name": "Test"}]}))
2692///     .with_response("/orders/*", json!({"orders": []}));
2693///
2694/// // Execute the plan
2695/// let executor = PlanExecutor::new(mock, config);
2696/// let result = executor.execute(plan).await?;
2697///
2698/// // Check what calls would be made
2699/// for call in mock.recorded_calls() {
2700///     println!("Would call: {} {}", call.method, call.path);
2701/// }
2702/// ```
2703///
2704/// # Migration note for downstream test authors (Phase 128, D-09)
2705///
2706/// The recorded `path` is now the RESOLVED path, and any direct
2707/// `execute_request` call must pass a [`ResolvedPath`] built with
2708/// [`ResolvedPath::from_checked`] rather than a bare `&str` — so a mock
2709/// expectation written against a `{key}` TEMPLATE must be rewritten against the
2710/// substituted path it expects to see.
2711pub struct MockHttpExecutor {
2712    /// Mock responses by path pattern (exact match or glob pattern with *)
2713    responses: std::sync::RwLock<HashMap<String, JsonValue>>,
2714    /// Default response for unmatched paths
2715    default_response: JsonValue,
2716    /// Record of all calls made (method, path, body, response)
2717    recorded_calls: std::sync::RwLock<Vec<MockedCall>>,
2718}
2719
2720/// A recorded mock call with request and response.
2721#[derive(Debug, Clone, Serialize)]
2722pub struct MockedCall {
2723    /// HTTP method (GET, POST, etc.)
2724    pub method: String,
2725    /// Request path
2726    pub path: String,
2727    /// Request body if any
2728    pub body: Option<JsonValue>,
2729    /// Response returned
2730    pub response: JsonValue,
2731}
2732
2733impl MockHttpExecutor {
2734    /// Create a new mock executor for dry-run mode.
2735    /// Returns empty objects `{}` for all calls.
2736    pub fn new_dry_run() -> Self {
2737        Self {
2738            responses: std::sync::RwLock::new(HashMap::new()),
2739            default_response: JsonValue::Object(serde_json::Map::new()),
2740            recorded_calls: std::sync::RwLock::new(Vec::new()),
2741        }
2742    }
2743
2744    /// Create a new mock executor for testing mode.
2745    /// Configure responses with `with_response()`.
2746    pub fn new_testing() -> Self {
2747        Self {
2748            responses: std::sync::RwLock::new(HashMap::new()),
2749            default_response: JsonValue::Object(serde_json::Map::new()),
2750            recorded_calls: std::sync::RwLock::new(Vec::new()),
2751        }
2752    }
2753
2754    /// Set the default response for unmatched paths.
2755    pub fn with_default_response(mut self, response: JsonValue) -> Self {
2756        self.default_response = response;
2757        self
2758    }
2759
2760    /// Add a mock response for a specific path pattern.
2761    /// Supports exact matches and simple glob patterns with `*`.
2762    ///
2763    /// # Examples
2764    ///
2765    /// ```ignore
2766    /// mock.with_response("/users", json!({"users": []}))
2767    ///     .with_response("/users/*", json!({"id": 1, "name": "Test"}))
2768    ///     .with_response("/orders/*/items", json!({"items": []}));
2769    /// ```
2770    pub fn with_response(self, path_pattern: &str, response: JsonValue) -> Self {
2771        self.responses
2772            .write()
2773            .unwrap()
2774            .insert(path_pattern.to_string(), response);
2775        self
2776    }
2777
2778    /// Add a mock response (non-builder version).
2779    pub fn add_response(&self, path_pattern: &str, response: JsonValue) {
2780        self.responses
2781            .write()
2782            .unwrap()
2783            .insert(path_pattern.to_string(), response);
2784    }
2785
2786    /// Get all recorded calls.
2787    pub fn recorded_calls(&self) -> Vec<MockedCall> {
2788        self.recorded_calls.read().unwrap().clone()
2789    }
2790
2791    /// Clear all recorded calls.
2792    pub fn clear_calls(&self) {
2793        self.recorded_calls.write().unwrap().clear();
2794    }
2795
2796    /// Get the number of calls made.
2797    pub fn call_count(&self) -> usize {
2798        self.recorded_calls.read().unwrap().len()
2799    }
2800
2801    /// Check if a specific path was called.
2802    pub fn was_called(&self, path: &str) -> bool {
2803        self.recorded_calls
2804            .read()
2805            .unwrap()
2806            .iter()
2807            .any(|c| c.path == path)
2808    }
2809
2810    /// Check if a path was called with a specific method.
2811    pub fn was_called_with_method(&self, method: &str, path: &str) -> bool {
2812        self.recorded_calls
2813            .read()
2814            .unwrap()
2815            .iter()
2816            .any(|c| c.method == method && c.path == path)
2817    }
2818
2819    /// Find the response for a path, checking patterns.
2820    fn find_response(&self, path: &str) -> JsonValue {
2821        let responses = self.responses.read().unwrap();
2822
2823        // First try exact match
2824        if let Some(response) = responses.get(path) {
2825            return response.clone();
2826        }
2827
2828        // Then try pattern matching
2829        for (pattern, response) in responses.iter() {
2830            if Self::matches_pattern(pattern, path) {
2831                return response.clone();
2832            }
2833        }
2834
2835        // Return default
2836        self.default_response.clone()
2837    }
2838
2839    /// Simple glob pattern matching (supports * as wildcard for path segments).
2840    fn matches_pattern(pattern: &str, path: &str) -> bool {
2841        if !pattern.contains('*') {
2842            return pattern == path;
2843        }
2844
2845        let pattern_parts: Vec<&str> = pattern.split('/').collect();
2846        let path_parts: Vec<&str> = path.split('/').collect();
2847
2848        if pattern_parts.len() != path_parts.len() {
2849            // Check for trailing * that matches multiple segments
2850            if pattern.ends_with("*") && path_parts.len() >= pattern_parts.len() - 1 {
2851                // Allow trailing wildcard to match remaining segments
2852            } else {
2853                return false;
2854            }
2855        }
2856
2857        for (p, s) in pattern_parts.iter().zip(path_parts.iter()) {
2858            if *p != "*" && *p != *s {
2859                return false;
2860            }
2861        }
2862
2863        true
2864    }
2865}
2866
2867#[async_trait::async_trait]
2868impl HttpExecutor for MockHttpExecutor {
2869    async fn execute_request(
2870        &self,
2871        method: &str,
2872        path: ResolvedPath<'_>,
2873        body: Option<JsonValue>,
2874    ) -> Result<JsonValue, ExecutionError> {
2875        let path = path.as_str();
2876        let response = self.find_response(path);
2877
2878        // Record the call
2879        let call = MockedCall {
2880            method: method.to_string(),
2881            path: path.to_string(),
2882            body,
2883            response: response.clone(),
2884        };
2885        self.recorded_calls.write().unwrap().push(call);
2886
2887        Ok(response)
2888    }
2889}
2890
2891// Implement Send + Sync (safe because we use RwLock)
2892unsafe impl Send for MockHttpExecutor {}
2893unsafe impl Sync for MockHttpExecutor {}
2894
2895/// Result of executing a plan.
2896#[derive(Debug, Clone, Serialize)]
2897pub struct ExecutionResult {
2898    /// The final return value
2899    pub value: JsonValue,
2900    /// Log of all API calls made
2901    pub api_calls: Vec<ApiCallLog>,
2902    /// Total execution time in milliseconds
2903    pub execution_time_ms: u64,
2904}
2905
2906/// Log entry for an API call.
2907#[derive(Debug, Clone, Serialize)]
2908pub struct ApiCallLog {
2909    /// HTTP method
2910    pub method: String,
2911    /// The fully resolved request path.
2912    ///
2913    /// **Disclosure note (Phase 128, T-128-23 — an ACCEPTED residual.)** This is
2914    /// an internal execution log and not a client-facing message, which is why it
2915    /// keeps the resolved path: redacting it would remove the diagnostic signal
2916    /// the log exists for. But any caller-facing surface that exposes
2917    /// [`ExecutionResult::api_calls`] inherits the disclosure — a refused or
2918    /// attacker-shaped path that was deliberately kept out of the error message
2919    /// is still present here. Such a surface MUST redact this field.
2920    pub path: String,
2921    /// Request body (if any)
2922    pub body: Option<JsonValue>,
2923    /// Response value
2924    pub response: JsonValue,
2925    /// Time taken in milliseconds
2926    pub duration_ms: u64,
2927}
2928
2929/// Map a value-free [`PlaceholderRefusal`](crate::PlaceholderRefusal) into the
2930/// executor's error type.
2931///
2932/// `PlaceholderRefusal`'s `Display` names the parameter and the DECLARED
2933/// expectation and never any byte of the value it refused, so the resulting
2934/// message is safe to surface to an MCP client as-is. Kept a free function so the
2935/// two `ApiCall` arms cannot drift into two different renderings.
2936fn refusal_to_execution_error(refusal: crate::PlaceholderRefusal) -> ExecutionError {
2937    ExecutionError::RequestRefused {
2938        message: refusal.to_string(),
2939    }
2940}
2941
2942/// Convert an error from `HttpExecutor::execute_request` into what the plan
2943/// executor reports, adding the method and result variable.
2944///
2945/// A [`ExecutionError::RequestRefused`] STAYS a `RequestRefused`. Every other
2946/// error becomes a `RuntimeError`, as before. Flattening a refusal into a
2947/// `RuntimeError` string here is what made a refusal indistinguishable from a
2948/// fault at the tool boundary: this is the one place the variant used to be lost.
2949///
2950/// The resolved path is deliberately NOT formatted in (RESEARCH Pitfall 7 /
2951/// SC-7): a refusal is value-free where it is raised, and re-attaching the path
2952/// here is what would deliver the exact injected path to the client.
2953fn api_call_error(method: &str, result_var: &str, error: ExecutionError) -> ExecutionError {
2954    match error {
2955        ExecutionError::RequestRefused { message } => ExecutionError::RequestRefused {
2956            message: format!("{method} api call '{result_var}' was refused: {message}"),
2957        },
2958        other => ExecutionError::RuntimeError {
2959            message: format!("{method} api call '{result_var}' failed: {other}"),
2960        },
2961    }
2962}
2963
2964/// Apply the LAYER-1 floor to one rendered `${var}` / `${expr}` contribution.
2965///
2966/// `PlaceholderRules::default()` and deliberately NOT
2967/// [`HttpExecutor::placeholder_rules`]: a layer-1 part is not a spec-declared
2968/// path parameter. It can appear anywhere in the template, including mid-segment,
2969/// so there is no OpenAPI `Parameter` to narrow from — the unconditional
2970/// character floor plus the always-on 256-code-point cap is exactly the right
2971/// level here. A reader who expects a `placeholder_rules` consultation at this
2972/// layer is looking for something that has no well-defined answer.
2973fn floor_layer_one_contribution(param: &str, rendered: &str) -> Result<(), ExecutionError> {
2974    crate::validate_path_placeholder(param, rendered, &crate::PlaceholderRules::default())
2975        .map_err(refusal_to_execution_error)
2976}
2977
2978/// Render a JSON scalar for a LAYER-2 `{key}` substitution, REJECTING non-scalars
2979/// (WR-03 / GAP 4).
2980///
2981/// Moved up from `pmcp-server-toolkit`'s `HttpCodeExecutor::scalar_str` by D-09,
2982/// with the rule preserved byte for byte: a scalar (`String`, `Number`, `Bool`,
2983/// `Null`) renders to a bare string (`Null` -> `"null"`, preserving prior
2984/// behaviour); an `Object` or `Array` is rejected rather than silently
2985/// JSON-stringified into the URL.
2986///
2987/// # Errors
2988///
2989/// Returns [`ExecutionError::RequestRefused`] naming `key`: a non-scalar path or
2990/// query value is the script's to fix. Per Pitfall 5 the message names the KEY
2991/// only — never the value.
2992fn render_path_scalar(key: &str, value: JsonValue) -> Result<String, ExecutionError> {
2993    match value {
2994        JsonValue::String(s) => Ok(s),
2995        JsonValue::Null => Ok("null".to_string()),
2996        JsonValue::Number(n) => Ok(n.to_string()),
2997        JsonValue::Bool(b) => Ok(b.to_string()),
2998        JsonValue::Object(_) | JsonValue::Array(_) => Err(ExecutionError::RequestRefused {
2999            message: format!("path/query param '{key}' must be a scalar"),
3000        }),
3001    }
3002}
3003
3004/// Apply PASS 1's `substitutions` to `template` in ONE left-to-right scan.
3005///
3006/// Deliberately NOT a sequence of `String::replace` calls over a progressively
3007/// substituted string. That form re-scans text a PREVIOUS value contributed, so a
3008/// value holding a literal `{other_key}` manufactures a placeholder for a later
3009/// key to fill — which is exactly the invariant
3010/// [`resolve_layer_two_placeholders`] documents and, before this scan existed, did
3011/// not hold. Measured on the sequential form: template `/p/{a}/q/{b}` with body
3012/// `{"a": "x{b}y", "b": "zzz"}` composed to `/p/xzzzy/q/zzz`, putting `b`'s value
3013/// inside `a`'s segment. `ResolvedPath::from_checked` could not catch it: `{`/`}`
3014/// are not on `denied_byte`'s list, so `x{b}y` passes the per-value floor, and a
3015/// MANUFACTURED placeholder leaves no residual brace behind for the composed check
3016/// to refuse. Scanning the template once means a substituted value is never
3017/// re-examined: the `{b}` stays a LITERAL, so the composed string still carries a
3018/// brace and `ResolvedPath::from_checked` refuses it as an unsubstituted
3019/// placeholder. A silent injection became a refusal, which is the point. Pinned by
3020/// `layer_two::layer_two_value_cannot_manufacture_a_placeholder_for_a_later_key`.
3021///
3022/// The longest matching placeholder wins at any position, so a key whose `{key}`
3023/// token is a prefix of another's cannot shadow it.
3024fn apply_substitutions(template: &str, substitutions: &[(String, String)]) -> String {
3025    let mut order: Vec<(&str, &str)> = substitutions
3026        .iter()
3027        .map(|(placeholder, rendered)| (placeholder.as_str(), rendered.as_str()))
3028        .collect();
3029    order.sort_by_key(|(placeholder, _)| std::cmp::Reverse(placeholder.len()));
3030
3031    let mut resolved = String::with_capacity(template.len());
3032    let mut rest = template;
3033    while let Some(ch) = rest.chars().next() {
3034        match order.iter().copied().find(|(p, _)| rest.starts_with(*p)) {
3035            Some((placeholder, rendered)) => {
3036                resolved.push_str(rendered);
3037                rest = &rest[placeholder.len()..];
3038            },
3039            None => {
3040                resolved.push(ch);
3041                rest = &rest[ch.len_utf8()..];
3042            },
3043        }
3044    }
3045    resolved
3046}
3047
3048/// LAYER-2 `{key}` placeholder resolution, moved ahead of
3049/// [`HttpExecutor::execute_request`] by Phase 128 D-09.
3050///
3051/// Returns the substituted path plus the body with the path-consumed keys
3052/// removed. An implementor used to do this inside its own impl, which is what
3053/// made the public trait a blind seam (T-128-20).
3054///
3055/// # Ordering
3056///
3057/// Two passes on purpose. Pass one renders and CHECKS every contribution; pass
3058/// two applies them. So a refusal on any one placeholder aborts with no
3059/// substitution having been applied at all, rather than leaving a
3060/// half-substituted path one `?` away from being dispatched. Pass one also tests
3061/// containment against the ORIGINAL template rather than a progressively
3062/// substituted copy, so a value that itself contains `{`/`}` cannot manufacture a
3063/// placeholder for a later key to fill. Body iteration order is
3064/// `serde_json::Map`'s, which is deterministic (insertion order under this
3065/// workspace's `preserve_order`, key order otherwise).
3066///
3067/// # Errors
3068///
3069/// Returns [`ExecutionError::RuntimeError`] on a non-scalar value or a
3070/// [`PlaceholderRefusal`](crate::PlaceholderRefusal). Both messages are
3071/// value-free.
3072fn resolve_layer_two_placeholders<H: HttpExecutor + ?Sized>(
3073    http: &H,
3074    method: &str,
3075    template: &str,
3076    body: Option<JsonValue>,
3077) -> Result<(String, Option<JsonValue>), ExecutionError> {
3078    // Destructured BY VALUE: this function owns `body`, so a non-placeholder entry
3079    // can be MOVED into `remaining` rather than deep-cloned. Cloning here copied
3080    // essentially the whole request payload — every nested object and array — on
3081    // every Code Mode HTTP call.
3082    let obj = match body {
3083        Some(JsonValue::Object(obj)) => obj,
3084        other => return Ok((template.to_string(), other)),
3085    };
3086
3087    // PASS 1 — render + check, mutating nothing.
3088    let mut substitutions: Vec<(String, String)> = Vec::new();
3089    let mut remaining = serde_json::Map::new();
3090    // One reusable buffer for the containment test. Most body keys are NOT path
3091    // placeholders — the `else` arm is the common one — so building a fresh
3092    // `format!("{{{key}}}")` per key allocated once for every key in every request
3093    // body and threw most of them away. The buffer is cloned only on a match.
3094    let mut probe = String::new();
3095    for (key, value) in obj {
3096        probe.clear();
3097        probe.push('{');
3098        probe.push_str(&key);
3099        probe.push('}');
3100        if template.contains(probe.as_str()) {
3101            let rules = http.placeholder_rules(method, template, &key);
3102            // `value` is owned and dropped right here, so render by value rather
3103            // than cloning the `String` out of a `JsonValue::String`.
3104            let rendered = render_path_scalar(&key, value)?;
3105            crate::validate_path_placeholder(&key, &rendered, &rules)
3106                .map_err(refusal_to_execution_error)?;
3107            substitutions.push((probe.clone(), rendered));
3108        } else {
3109            remaining.insert(key, value);
3110        }
3111    }
3112
3113    // PASS 2 — apply, in ONE left-to-right scan over the TEMPLATE.
3114    let resolved = apply_substitutions(template, &substitutions);
3115
3116    let remaining = if remaining.is_empty() {
3117        None
3118    } else {
3119        Some(JsonValue::Object(remaining))
3120    };
3121    Ok((resolved, remaining))
3122}
3123
3124/// Executes a compiled execution plan.
3125pub struct PlanExecutor<H: HttpExecutor> {
3126    http: H,
3127    config: ExecutionConfig,
3128    variables: HashMap<String, JsonValue>,
3129    api_calls: Vec<ApiCallLog>,
3130    api_call_count: usize,
3131    #[cfg(feature = "mcp-code-mode")]
3132    mcp: Option<Box<dyn McpExecutor>>,
3133    /// Optional SDK executor for SDK-backed servers (e.g., aws-billing).
3134    sdk: Option<Box<dyn SdkExecutor>>,
3135}
3136
3137impl<H: HttpExecutor> PlanExecutor<H> {
3138    /// Create a new executor with the given HTTP client.
3139    pub fn new(http: H, config: ExecutionConfig) -> Self {
3140        Self {
3141            http,
3142            config,
3143            variables: HashMap::new(),
3144            api_calls: Vec::new(),
3145            api_call_count: 0,
3146            #[cfg(feature = "mcp-code-mode")]
3147            mcp: None,
3148            sdk: None,
3149        }
3150    }
3151
3152    /// Set the MCP executor for foundation server calls.
3153    #[cfg(feature = "mcp-code-mode")]
3154    pub fn set_mcp_executor(&mut self, executor: impl McpExecutor + 'static) {
3155        self.mcp = Some(Box::new(executor));
3156    }
3157
3158    /// Set the SDK executor for SDK-backed servers.
3159    pub fn set_sdk_executor(&mut self, executor: impl SdkExecutor + 'static) {
3160        self.sdk = Some(Box::new(executor));
3161    }
3162
3163    /// Pre-bind a variable before execution (e.g., `args` for script tools).
3164    pub fn set_variable(&mut self, name: impl Into<String>, value: JsonValue) {
3165        self.variables.insert(name.into(), value);
3166    }
3167
3168    /// Execute a plan and return the result.
3169    pub async fn execute(
3170        &mut self,
3171        plan: &ExecutionPlan,
3172    ) -> Result<ExecutionResult, ExecutionError> {
3173        let start = std::time::Instant::now();
3174
3175        let mut return_value = JsonValue::Null;
3176
3177        for step in &plan.steps {
3178            match self.execute_step(step).await? {
3179                StepOutcome::Return(value) => {
3180                    return_value = value;
3181                    break; // Early return — stop executing further steps
3182                },
3183                StepOutcome::None | StepOutcome::Continue | StepOutcome::Break => {},
3184            }
3185        }
3186
3187        // Validate output against output blocklist.
3188        // These are fields that can be used internally but cannot be returned.
3189        let blocked_in_output =
3190            find_blocked_fields_in_output(&return_value, &self.config.output_blocked_fields);
3191
3192        if !blocked_in_output.is_empty() {
3193            return Err(ExecutionError::RuntimeError {
3194                message: format!(
3195                    "Script output contains blocked fields: {}",
3196                    blocked_in_output.join(", ")
3197                ),
3198            });
3199        }
3200
3201        Ok(ExecutionResult {
3202            value: return_value,
3203            api_calls: std::mem::take(&mut self.api_calls),
3204            execution_time_ms: start.elapsed().as_millis() as u64,
3205        })
3206    }
3207
3208    /// Execute a single step, returning a `StepOutcome` for control flow.
3209    /// Uses Box::pin for recursive calls to avoid infinite future size.
3210    fn execute_step<'a>(
3211        &'a mut self,
3212        step: &'a PlanStep,
3213    ) -> std::pin::Pin<
3214        Box<dyn std::future::Future<Output = Result<StepOutcome, ExecutionError>> + Send + 'a>,
3215    > {
3216        Box::pin(async move {
3217            match step {
3218                PlanStep::ApiCall {
3219                    result_var,
3220                    method,
3221                    path,
3222                    body,
3223                } => {
3224                    self.api_call_count += 1;
3225                    if self.api_call_count > self.config.max_api_calls {
3226                        return Err(ExecutionError::RuntimeError {
3227                            message: format!(
3228                                "Too many API calls: {} (max: {})",
3229                                self.api_call_count, self.config.max_api_calls
3230                            ),
3231                        });
3232                    }
3233
3234                    // LAYER 1 — `${var}` / `${expr}` interpolation, each
3235                    // contribution floored inside `resolve_path` (FORK 2).
3236                    let templated_path = self.resolve_path(path)?;
3237                    let evaluated_body = match body {
3238                        Some(expr) => Some(self.evaluate(expr)?),
3239                        None => None,
3240                    };
3241                    // LAYER 2 — `{key}` resolution, moved ahead of dispatch (D-09).
3242                    let (resolved_path, resolved_body) = resolve_layer_two_placeholders(
3243                        &self.http,
3244                        method,
3245                        &templated_path,
3246                        evaluated_body,
3247                    )?;
3248                    // THE COMPOSED CHECK — the only check that can see an
3249                    // adjacency (T-128-20b). `.` + `.` composes to a traversal and
3250                    // 180 + 200 code points compose over the cap, from values that
3251                    // each passed both per-value checks above; a residual `{`/`}`
3252                    // from an unsubstituted placeholder is refused here too.
3253                    let checked_path = ResolvedPath::from_checked(&resolved_path)
3254                        .map_err(refusal_to_execution_error)?;
3255
3256                    let call_start = std::time::Instant::now();
3257                    let raw_response = self
3258                        .http
3259                        .execute_request(method, checked_path, resolved_body.clone())
3260                        .await
3261                        .map_err(|e| api_call_error(method, &result_var, e))?;
3262                    let duration_ms = call_start.elapsed().as_millis() as u64;
3263
3264                    // Filter blocked fields from API response before scripts can access them.
3265                    // This implements the "internal blocklist" - fields that are never accessible.
3266                    let response = filter_blocked_fields(raw_response, &self.config.blocked_fields);
3267
3268                    self.api_calls.push(ApiCallLog {
3269                        method: method.clone(),
3270                        path: resolved_path,
3271                        body: resolved_body,
3272                        response: response.clone(),
3273                        duration_ms,
3274                    });
3275
3276                    if result_var != "_" {
3277                        self.variables.insert(result_var.clone(), response);
3278                    }
3279                    Ok(StepOutcome::None)
3280                },
3281
3282                PlanStep::Assign { var, expr } => {
3283                    let value = self.evaluate(expr)?;
3284                    self.variables.insert(var.clone(), value);
3285                    Ok(StepOutcome::None)
3286                },
3287
3288                PlanStep::Conditional {
3289                    condition,
3290                    then_steps,
3291                    else_steps,
3292                } => {
3293                    let cond_value = self.evaluate(condition)?;
3294                    let steps = if shared_is_truthy(&cond_value) {
3295                        then_steps
3296                    } else {
3297                        else_steps
3298                    };
3299
3300                    for step in steps {
3301                        match self.execute_step(step).await? {
3302                            StepOutcome::None => {},
3303                            outcome => return Ok(outcome),
3304                        }
3305                    }
3306                    Ok(StepOutcome::None)
3307                },
3308
3309                PlanStep::BoundedLoop {
3310                    item_var,
3311                    collection,
3312                    max_iterations,
3313                    body,
3314                } => {
3315                    let collection_value = self.evaluate(collection)?;
3316                    let items = match collection_value {
3317                        JsonValue::Array(arr) => arr,
3318                        _ => {
3319                            return Err(ExecutionError::RuntimeError {
3320                                message: "Loop collection must be an array".into(),
3321                            })
3322                        },
3323                    };
3324
3325                    let limit = (*max_iterations).min(self.config.max_loop_iterations);
3326                    'outer: for item in items.into_iter().take(limit) {
3327                        self.variables.insert(item_var.clone(), item);
3328
3329                        for step in body {
3330                            match self.execute_step(step).await? {
3331                                StepOutcome::Return(value) => {
3332                                    return Ok(StepOutcome::Return(value))
3333                                },
3334                                StepOutcome::None => {},
3335                                StepOutcome::Continue => continue 'outer,
3336                                StepOutcome::Break => break 'outer,
3337                            }
3338                        }
3339                    }
3340                    Ok(StepOutcome::None)
3341                },
3342
3343                PlanStep::Return { value } => {
3344                    let result = self.evaluate(value)?;
3345                    Ok(StepOutcome::Return(result))
3346                },
3347
3348                PlanStep::TryCatch {
3349                    try_steps,
3350                    catch_var,
3351                    catch_steps,
3352                    finally_steps,
3353                } => {
3354                    // Execute try block
3355                    let try_result = async {
3356                        for step in try_steps {
3357                            match self.execute_step(step).await? {
3358                                StepOutcome::None => {},
3359                                outcome => return Ok::<StepOutcome, ExecutionError>(outcome),
3360                            }
3361                        }
3362                        Ok(StepOutcome::None)
3363                    }
3364                    .await;
3365
3366                    // If try succeeded, just run finally
3367                    let result = match try_result {
3368                        Ok(outcome) => {
3369                            // Try block succeeded
3370                            outcome
3371                        },
3372                        Err(error) => {
3373                            // Try block failed, run catch
3374                            if let Some(var) = catch_var {
3375                                // Store the error in the catch variable
3376                                let error_obj = JsonValue::Object(serde_json::Map::from_iter([(
3377                                    "message".to_string(),
3378                                    JsonValue::String(format!("{}", error)),
3379                                )]));
3380                                self.variables.insert(var.clone(), error_obj);
3381                            }
3382
3383                            // Execute catch block
3384                            let mut catch_outcome = StepOutcome::None;
3385                            for step in catch_steps {
3386                                match self.execute_step(step).await? {
3387                                    StepOutcome::None => {},
3388                                    outcome => {
3389                                        catch_outcome = outcome;
3390                                        break;
3391                                    },
3392                                }
3393                            }
3394                            catch_outcome
3395                        },
3396                    };
3397
3398                    // Execute finally block (always runs)
3399                    for step in finally_steps {
3400                        match self.execute_step(step).await? {
3401                            StepOutcome::None => {},
3402                            outcome => return Ok(outcome),
3403                        }
3404                    }
3405
3406                    Ok(result)
3407                },
3408
3409                // Parallel API calls: await Promise.all([api.get(...), ...])
3410                // Executed sequentially (true parallelism isn't needed for correctness),
3411                // results collected into an array assigned to result_var.
3412                PlanStep::ParallelApiCalls { result_var, calls } => {
3413                    let mut results = Vec::with_capacity(calls.len());
3414                    for (temp_var, method, path, body) in calls {
3415                        self.api_call_count += 1;
3416                        if self.api_call_count > self.config.max_api_calls {
3417                            return Err(ExecutionError::RuntimeError {
3418                                message: format!(
3419                                    "Maximum API calls exceeded ({})",
3420                                    self.config.max_api_calls
3421                                ),
3422                            });
3423                        }
3424
3425                        // LAYER 1, then LAYER 2, then the COMPOSED check — the same
3426                        // three steps as the single `ApiCall` arm. See that arm's
3427                        // comments; both arms must carry all three or the class is
3428                        // closed on only one of them.
3429                        let templated_path = self.resolve_path(path)?;
3430                        let evaluated_body = body.as_ref().map(|b| self.evaluate(b)).transpose()?;
3431                        let (resolved_path, resolved_body) = resolve_layer_two_placeholders(
3432                            &self.http,
3433                            method,
3434                            &templated_path,
3435                            evaluated_body,
3436                        )?;
3437                        let checked_path = ResolvedPath::from_checked(&resolved_path)
3438                            .map_err(refusal_to_execution_error)?;
3439                        let call_start = std::time::Instant::now();
3440                        let raw_response = self
3441                            .http
3442                            .execute_request(method, checked_path, resolved_body.clone())
3443                            .await
3444                            .map_err(|e| api_call_error(method, &temp_var, e))?;
3445                        let duration_ms = call_start.elapsed().as_millis() as u64;
3446                        let response =
3447                            filter_blocked_fields(raw_response, &self.config.blocked_fields);
3448
3449                        self.api_calls.push(ApiCallLog {
3450                            method: method.clone(),
3451                            path: resolved_path,
3452                            body: resolved_body,
3453                            response: response.clone(),
3454                            duration_ms,
3455                        });
3456
3457                        results.push(response);
3458                    }
3459                    self.variables
3460                        .insert(result_var.clone(), JsonValue::Array(results));
3461                    Ok(StepOutcome::None)
3462                },
3463
3464                // Continue: signal to skip to next loop iteration
3465                PlanStep::Continue => Ok(StepOutcome::Continue),
3466
3467                // Break: signal to exit the current loop
3468                PlanStep::Break => Ok(StepOutcome::Break),
3469
3470                // MCP tool call: await mcp.call('server', 'tool', { args })
3471                #[cfg(feature = "mcp-code-mode")]
3472                PlanStep::McpCall {
3473                    result_var,
3474                    server_id,
3475                    tool_name,
3476                    args,
3477                } => {
3478                    self.api_call_count += 1;
3479                    if self.api_call_count > self.config.max_api_calls {
3480                        return Err(ExecutionError::RuntimeError {
3481                            message: format!(
3482                                "Too many calls: {} (max: {})",
3483                                self.api_call_count, self.config.max_api_calls
3484                            ),
3485                        });
3486                    }
3487
3488                    let resolved_args = match args {
3489                        Some(expr) => self.evaluate(expr)?,
3490                        None => JsonValue::Object(Default::default()),
3491                    };
3492
3493                    let mcp_executor =
3494                        self.mcp
3495                            .as_ref()
3496                            .ok_or_else(|| ExecutionError::RuntimeError {
3497                                message: "MCP executor not configured".into(),
3498                            })?;
3499
3500                    let call_start = std::time::Instant::now();
3501                    let result = mcp_executor
3502                        .call_tool(server_id, tool_name, resolved_args.clone())
3503                        .await?;
3504                    let duration_ms = call_start.elapsed().as_millis() as u64;
3505
3506                    self.api_calls.push(ApiCallLog {
3507                        method: format!("MCP:{}.{}", server_id, tool_name),
3508                        path: format!("{}/{}", server_id, tool_name),
3509                        body: Some(resolved_args),
3510                        response: result.clone(),
3511                        duration_ms,
3512                    });
3513
3514                    if result_var != "_" {
3515                        self.variables.insert(result_var.clone(), result);
3516                    }
3517                    Ok(StepOutcome::None)
3518                },
3519
3520                // SDK call: await api.getCostAndUsage({ ... })
3521                PlanStep::SdkCall {
3522                    result_var,
3523                    operation,
3524                    args,
3525                } => {
3526                    self.api_call_count += 1;
3527                    if self.api_call_count > self.config.max_api_calls {
3528                        return Err(ExecutionError::RuntimeError {
3529                            message: format!(
3530                                "Too many calls: {} (max: {})",
3531                                self.api_call_count, self.config.max_api_calls
3532                            ),
3533                        });
3534                    }
3535
3536                    let resolved_args =
3537                        args.as_ref().map(|expr| self.evaluate(expr)).transpose()?;
3538
3539                    let sdk_executor =
3540                        self.sdk
3541                            .as_ref()
3542                            .ok_or_else(|| ExecutionError::RuntimeError {
3543                                message: "SDK executor not configured".into(),
3544                            })?;
3545
3546                    let call_start = std::time::Instant::now();
3547                    let result = sdk_executor
3548                        .execute_operation(operation, resolved_args.clone())
3549                        .await?;
3550                    let duration_ms = call_start.elapsed().as_millis() as u64;
3551
3552                    self.api_calls.push(ApiCallLog {
3553                        method: operation.clone(),
3554                        path: format!("sdk:{}", operation),
3555                        body: resolved_args,
3556                        response: result.clone(),
3557                        duration_ms,
3558                    });
3559
3560                    if result_var != "_" {
3561                        self.variables.insert(result_var.clone(), result);
3562                    }
3563                    Ok(StepOutcome::None)
3564                },
3565            }
3566        })
3567    }
3568
3569    /// Resolve a path template to a concrete path string — LAYER 1.
3570    ///
3571    /// # The FORK-2 floor (Phase 128, T-128-20a)
3572    ///
3573    /// Every DYNAMIC contribution is passed through
3574    /// [`validate_path_placeholder`](crate::validate_path_placeholder) before it is
3575    /// pushed, and the first refusal returns so nothing reaches `result`. This arm
3576    /// used to push the stringified value straight in, which meant a script
3577    /// writing `` api.get(`/search/${v}`) `` never touched a `{key}` placeholder,
3578    /// never entered layer 2, and was never floored at all. Code Mode scripts are
3579    /// model-authored, so that was the untrusted route.
3580    ///
3581    /// `PathPart::Literal` parts are deliberately NOT checked: they are the
3582    /// script's own literal text from the compiled template, not
3583    /// caller-substituted data, and flooring them would refuse every legitimate
3584    /// template whose literal segments contain `/`.
3585    ///
3586    /// # Errors
3587    ///
3588    /// Returns [`ExecutionError::RuntimeError`] on an undefined variable, an
3589    /// expression-evaluation failure, or a placeholder refusal. A refusal's
3590    /// message comes from the refusal's own value-free `Display`.
3591    fn resolve_path(&self, path: &PathTemplate) -> Result<String, ExecutionError> {
3592        let mut result = String::new();
3593        for (index, part) in path.parts.iter().enumerate() {
3594            match part {
3595                PathPart::Literal(s) => result.push_str(s),
3596                PathPart::Variable(var) => {
3597                    let value =
3598                        self.variables
3599                            .get(var)
3600                            .ok_or_else(|| ExecutionError::RuntimeError {
3601                                message: format!("Undefined variable in path: {}", var),
3602                            })?;
3603                    let rendered = shared_json_to_string_with_mode(value, JsonStringMode::Json);
3604                    // The refusal names the variable IDENTIFIER, which the part
3605                    // carries. This is safe because the identifier is CALLER-CHOSEN,
3606                    // not because it is trusted: echoing it back discloses nothing
3607                    // the caller does not already know, and the JS identifier grammar
3608                    // admits no whitespace, newline or punctuation beyond `$`/`_`, so
3609                    // it cannot carry a log-injection payload. The VALUE is always
3610                    // redacted. T-128-21b (accept, low) — Phase 128 security audit.
3611                    //
3612                    // An earlier revision justified this as "a script-chosen identifier
3613                    // is operator-shipped content, unlike the value". That was FALSE on
3614                    // the `execute_code` surface, whose own tool definition says it
3615                    // "runs caller-supplied code" (`handler.rs` `build_execute_tool`),
3616                    // and it contradicted this function's own doc 25 lines above
3617                    // ("Code Mode scripts are model-authored, so that was the untrusted
3618                    // route"). Correcting it is the same documented-but-untrue class
3619                    // Phase 128 exists to close, applied to this phase's own comment.
3620                    //
3621                    // Note the identifier class already reaches the client at three
3622                    // other sites (the `Undefined variable in path` message just above,
3623                    // and `result_var`/`temp_var` in the two `ApiCall` error wraps), so
3624                    // routing ONLY this arm through a positional descriptor would be a
3625                    // partial fix that reads as a complete one. The sibling
3626                    // `PathPart::Expression` arm below uses a fixed descriptor for a
3627                    // different reason: an expression has no name, and rendering its
3628                    // body could itself echo caller DATA.
3629                    floor_layer_one_contribution(var, &rendered)?;
3630                    result.push_str(&rendered);
3631                },
3632                PathPart::Expression(expr) => {
3633                    let value = self.evaluate(expr)?;
3634                    let rendered = shared_json_to_string_with_mode(&value, JsonStringMode::Json);
3635                    // An expression has NO name, and a rendering of the expression
3636                    // body could itself contain caller text — so the refusal
3637                    // carries a fixed positional descriptor and never interpolates
3638                    // either the body or the evaluated value.
3639                    floor_layer_one_contribution(&format!("path expression #{index}"), &rendered)?;
3640                    result.push_str(&rendered);
3641                },
3642            }
3643        }
3644        Ok(result)
3645    }
3646
3647    /// Evaluate an expression to a JSON value.
3648    /// Delegates to the shared evaluation module.
3649    fn evaluate(&self, expr: &ValueExpr) -> Result<JsonValue, ExecutionError> {
3650        shared_evaluate(expr, &self.variables)
3651    }
3652}
3653
3654// ============================================================================
3655// LEGACY COMPATIBILITY - Types for backward compatibility
3656// ============================================================================
3657
3658/// Legacy JsExecutor type alias for backward compatibility.
3659pub type JsExecutor = PlanCompiler;
3660
3661#[cfg(test)]
3662mod tests {
3663    use super::*;
3664
3665    #[test]
3666    fn test_execution_config_default() {
3667        let config = ExecutionConfig::default();
3668        assert_eq!(config.max_api_calls, 50);
3669        assert_eq!(config.timeout_seconds, 30);
3670        assert_eq!(config.max_loop_iterations, 100);
3671    }
3672
3673    #[test]
3674    fn test_path_template_static() {
3675        let path = PathTemplate::static_path("/users".into());
3676        assert!(!path.is_dynamic());
3677    }
3678
3679    #[test]
3680    fn test_path_template_dynamic() {
3681        let path = PathTemplate {
3682            parts: vec![
3683                PathPart::Literal("/users/".into()),
3684                PathPart::Variable("id".into()),
3685            ],
3686        };
3687        assert!(path.is_dynamic());
3688    }
3689
3690    #[test]
3691    fn test_plan_metadata() {
3692        let metadata = PlanMetadata {
3693            api_call_count: 2,
3694            has_mutations: false,
3695            endpoints: vec!["/users".into(), "/products".into()],
3696            methods_used: vec!["GET".into()],
3697        };
3698        assert_eq!(metadata.api_call_count, 2);
3699        assert!(!metadata.has_mutations);
3700    }
3701
3702    #[test]
3703    fn test_compile_simple_api_call() {
3704        let code = r#"
3705            const user = await api.get('/users/1');
3706            return user;
3707        "#;
3708
3709        let mut compiler = PlanCompiler::new();
3710        let plan = compiler.compile_code(code).expect("Should compile");
3711
3712        assert_eq!(plan.metadata.api_call_count, 1);
3713        assert!(!plan.metadata.has_mutations);
3714        assert_eq!(plan.steps.len(), 2); // ApiCall + Return
3715    }
3716
3717    #[test]
3718    fn test_compile_multiple_api_calls() {
3719        let code = r#"
3720            const users = await api.get('/users');
3721            const products = await api.get('/products');
3722            return { users, products };
3723        "#;
3724
3725        let mut compiler = PlanCompiler::new();
3726        let plan = compiler.compile_code(code).expect("Should compile");
3727
3728        assert_eq!(plan.metadata.api_call_count, 2);
3729        assert!(!plan.metadata.has_mutations);
3730    }
3731
3732    #[test]
3733    fn test_compile_mutation() {
3734        let code = r#"
3735            const result = await api.post('/users', { name: 'Test' });
3736            return result;
3737        "#;
3738
3739        let mut compiler = PlanCompiler::new();
3740        let plan = compiler.compile_code(code).expect("Should compile");
3741
3742        assert_eq!(plan.metadata.api_call_count, 1);
3743        assert!(plan.metadata.has_mutations);
3744    }
3745
3746    #[test]
3747    fn test_compile_dynamic_path() {
3748        let code = r#"
3749            const id = 123;
3750            const user = await api.get(`/users/${id}`);
3751            return user;
3752        "#;
3753
3754        let mut compiler = PlanCompiler::new();
3755        let plan = compiler.compile_code(code).expect("Should compile");
3756
3757        assert_eq!(plan.metadata.api_call_count, 1);
3758    }
3759
3760    #[test]
3761    fn test_compile_bounded_loop() {
3762        let code = r#"
3763            const items = [];
3764            const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
3765            for (const user of users.slice(0, 2)) {
3766                const detail = await api.get(`/users/${user.id}`);
3767                items.push(detail);
3768            }
3769            return items;
3770        "#;
3771
3772        let mut compiler = PlanCompiler::new();
3773        let plan = compiler.compile_code(code).expect("Should compile");
3774
3775        // The loop is bounded, so it should compile
3776        assert!(plan
3777            .steps
3778            .iter()
3779            .any(|s| matches!(s, PlanStep::BoundedLoop { .. })));
3780    }
3781
3782    #[test]
3783    fn test_compile_unbounded_loop_detection() {
3784        // Note: The current compiler allows for-of loops without explicit .slice() bounds
3785        // as long as the loop body doesn't exceed iteration limits at runtime.
3786        // This test documents the current behavior.
3787        let code = r#"
3788            const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
3789            for (const user of users) {
3790                const detail = await api.get(`/users/${user.id}`);
3791            }
3792            return users;
3793        "#;
3794
3795        let mut compiler = PlanCompiler::new();
3796        let result = compiler.compile_code(code);
3797
3798        // Currently this compiles - runtime will enforce iteration limits
3799        // See #249 — investigate compile-time loop-bounds checking.
3800        assert!(result.is_ok(), "Loop compiled: {:?}", result);
3801    }
3802
3803    #[test]
3804    fn test_compile_conditional() {
3805        let code = r#"
3806            const user = await api.get('/users/1');
3807            if (user.active) {
3808                const orders = await api.get(`/users/${user.id}/orders`);
3809                return orders;
3810            } else {
3811                return [];
3812            }
3813        "#;
3814
3815        let mut compiler = PlanCompiler::new();
3816        let plan = compiler.compile_code(code).expect("Should compile");
3817
3818        assert!(plan
3819            .steps
3820            .iter()
3821            .any(|s| matches!(s, PlanStep::Conditional { .. })));
3822    }
3823
3824    // Mock HTTP executor for testing
3825    struct MockHttpExecutor {
3826        responses: std::collections::HashMap<String, JsonValue>,
3827    }
3828
3829    impl MockHttpExecutor {
3830        fn new() -> Self {
3831            Self {
3832                responses: std::collections::HashMap::new(),
3833            }
3834        }
3835
3836        fn add_response(&mut self, path: &str, response: JsonValue) {
3837            self.responses.insert(path.to_string(), response);
3838        }
3839    }
3840
3841    #[async_trait::async_trait]
3842    impl HttpExecutor for MockHttpExecutor {
3843        async fn execute_request(
3844            &self,
3845            _method: &str,
3846            path: ResolvedPath<'_>,
3847            _body: Option<JsonValue>,
3848        ) -> Result<JsonValue, ExecutionError> {
3849            let path = path.as_str();
3850            self.responses
3851                .get(path)
3852                .cloned()
3853                .ok_or_else(|| ExecutionError::RuntimeError {
3854                    message: format!("No mock response for path: {}", path),
3855                })
3856        }
3857    }
3858
3859    #[tokio::test]
3860    async fn test_execute_simple_api_call() {
3861        let code = r#"
3862            const user = await api.get('/users/1');
3863            return user;
3864        "#;
3865
3866        let mut compiler = PlanCompiler::new();
3867        let plan = compiler.compile_code(code).expect("Should compile");
3868
3869        let mut mock_http = MockHttpExecutor::new();
3870        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "name": "Alice" }));
3871
3872        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3873        let result = executor.execute(&plan).await.expect("Should execute");
3874
3875        assert_eq!(result.value["id"], 1);
3876        assert_eq!(result.value["name"], "Alice");
3877        assert_eq!(result.api_calls.len(), 1);
3878    }
3879
3880    #[tokio::test]
3881    async fn test_execute_multiple_api_calls() {
3882        let code = r#"
3883            const users = await api.get('/users');
3884            const products = await api.get('/products');
3885            return { users, products };
3886        "#;
3887
3888        let mut compiler = PlanCompiler::new();
3889        let plan = compiler.compile_code(code).expect("Should compile");
3890
3891        let mut mock_http = MockHttpExecutor::new();
3892        mock_http.add_response("/users", serde_json::json!([{ "id": 1, "name": "Alice" }]));
3893        mock_http.add_response(
3894            "/products",
3895            serde_json::json!([{ "id": 100, "name": "Widget" }]),
3896        );
3897
3898        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3899        let result = executor.execute(&plan).await.expect("Should execute");
3900
3901        assert!(result.value["users"].is_array());
3902        assert!(result.value["products"].is_array());
3903        assert_eq!(result.api_calls.len(), 2);
3904    }
3905
3906    #[tokio::test]
3907    async fn test_execute_with_template_path() {
3908        let code = r#"
3909            const userId = 42;
3910            const user = await api.get(`/users/${userId}`);
3911            return user;
3912        "#;
3913
3914        let mut compiler = PlanCompiler::new();
3915        let plan = compiler.compile_code(code).expect("Should compile");
3916
3917        let mut mock_http = MockHttpExecutor::new();
3918        mock_http.add_response("/users/42", serde_json::json!({ "id": 42, "name": "Bob" }));
3919
3920        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3921        let result = executor.execute(&plan).await.expect("Should execute");
3922
3923        assert_eq!(result.value["id"], 42);
3924        assert_eq!(result.value["name"], "Bob");
3925    }
3926
3927    #[tokio::test]
3928    async fn test_execute_conditional_true_branch() {
3929        let code = r#"
3930            const user = await api.get('/users/1');
3931            if (user.active) {
3932                return { status: "active", user: user };
3933            } else {
3934                return { status: "inactive" };
3935            }
3936        "#;
3937
3938        let mut compiler = PlanCompiler::new();
3939        let plan = compiler.compile_code(code).expect("Should compile");
3940
3941        let mut mock_http = MockHttpExecutor::new();
3942        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "active": true }));
3943
3944        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3945        let result = executor.execute(&plan).await.expect("Should execute");
3946
3947        assert_eq!(result.value["status"], "active");
3948    }
3949
3950    #[tokio::test]
3951    async fn test_execute_conditional_false_branch() {
3952        let code = r#"
3953            const user = await api.get('/users/1');
3954            if (user.active) {
3955                return { status: "active" };
3956            } else {
3957                return { status: "inactive", user: user };
3958            }
3959        "#;
3960
3961        let mut compiler = PlanCompiler::new();
3962        let plan = compiler.compile_code(code).expect("Should compile");
3963
3964        let mut mock_http = MockHttpExecutor::new();
3965        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "active": false }));
3966
3967        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3968        let result = executor.execute(&plan).await.expect("Should execute");
3969
3970        assert_eq!(result.value["status"], "inactive");
3971    }
3972
3973    #[tokio::test]
3974    async fn test_compile_and_execute_reduce() {
3975        let code = r#"
3976            const products = await api.get('/products');
3977            const totalPrice = products.reduce((sum, p) => sum + p.price, 0);
3978            return { total: totalPrice };
3979        "#;
3980
3981        let mut compiler = PlanCompiler::new();
3982        let plan = compiler.compile_code(code).expect("Should compile reduce");
3983
3984        let mut mock_http = MockHttpExecutor::new();
3985        mock_http.add_response(
3986            "/products",
3987            serde_json::json!([
3988                { "id": 1, "name": "Widget", "price": 10 },
3989                { "id": 2, "name": "Gadget", "price": 25 },
3990                { "id": 3, "name": "Gizmo", "price": 15 }
3991            ]),
3992        );
3993
3994        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3995        let result = executor.execute(&plan).await.expect("Should execute");
3996
3997        // Result is f64, compare as number
3998        assert_eq!(result.value["total"].as_f64().unwrap(), 50.0);
3999    }
4000
4001    #[tokio::test]
4002    async fn test_compile_and_execute_to_fixed() {
4003        let code = r#"
4004            const products = await api.get('/products');
4005            const totalPrice = products.reduce((sum, p) => sum + p.price, 0);
4006            const averagePrice = products.length > 0 ? totalPrice / products.length : 0;
4007            return { averagePrice: averagePrice.toFixed(2) };
4008        "#;
4009
4010        let mut compiler = PlanCompiler::new();
4011        let plan = compiler.compile_code(code).expect("Should compile toFixed");
4012
4013        let mut mock_http = MockHttpExecutor::new();
4014        mock_http.add_response(
4015            "/products",
4016            serde_json::json!([
4017                { "id": 1, "name": "Widget", "price": 10 },
4018                { "id": 2, "name": "Gadget", "price": 25 },
4019                { "id": 3, "name": "Gizmo", "price": 15 }
4020            ]),
4021        );
4022
4023        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4024        let result = executor.execute(&plan).await.expect("Should execute");
4025
4026        // 50 / 3 = 16.666... toFixed(2) = "16.67"
4027        assert_eq!(result.value["averagePrice"], "16.67");
4028    }
4029
4030    // =========================================================================
4031    // Field Filtering Tests
4032    // =========================================================================
4033
4034    #[test]
4035    fn test_filter_blocked_fields_simple() {
4036        let value = serde_json::json!({
4037            "id": 1,
4038            "name": "Alice",
4039            "password": "secret123",
4040            "email": "alice@example.com"
4041        });
4042
4043        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4044        let filtered = filter_blocked_fields(value, &blocked);
4045
4046        assert_eq!(filtered["id"], 1);
4047        assert_eq!(filtered["name"], "Alice");
4048        assert_eq!(filtered["email"], "alice@example.com");
4049        assert!(filtered.get("password").is_none());
4050    }
4051
4052    #[test]
4053    fn test_filter_blocked_fields_multiple() {
4054        let value = serde_json::json!({
4055            "id": 1,
4056            "name": "Alice",
4057            "password": "secret123",
4058            "ssn": "123-45-6789",
4059            "apiKey": "key-abc123"
4060        });
4061
4062        let blocked: HashSet<String> = ["password", "ssn", "apiKey"]
4063            .iter()
4064            .map(|s| s.to_string())
4065            .collect();
4066        let filtered = filter_blocked_fields(value, &blocked);
4067
4068        assert_eq!(filtered["id"], 1);
4069        assert_eq!(filtered["name"], "Alice");
4070        assert!(filtered.get("password").is_none());
4071        assert!(filtered.get("ssn").is_none());
4072        assert!(filtered.get("apiKey").is_none());
4073    }
4074
4075    #[test]
4076    fn test_filter_blocked_fields_nested() {
4077        let value = serde_json::json!({
4078            "user": {
4079                "id": 1,
4080                "profile": {
4081                    "name": "Alice",
4082                    "password": "secret123"
4083                }
4084            }
4085        });
4086
4087        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4088        let filtered = filter_blocked_fields(value, &blocked);
4089
4090        assert_eq!(filtered["user"]["id"], 1);
4091        assert_eq!(filtered["user"]["profile"]["name"], "Alice");
4092        assert!(filtered["user"]["profile"].get("password").is_none());
4093    }
4094
4095    #[test]
4096    fn test_filter_blocked_fields_in_array() {
4097        let value = serde_json::json!([
4098            { "id": 1, "name": "Alice", "password": "secret1" },
4099            { "id": 2, "name": "Bob", "password": "secret2" }
4100        ]);
4101
4102        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4103        let filtered = filter_blocked_fields(value, &blocked);
4104
4105        let arr = filtered.as_array().unwrap();
4106        assert_eq!(arr.len(), 2);
4107        assert_eq!(arr[0]["id"], 1);
4108        assert_eq!(arr[0]["name"], "Alice");
4109        assert!(arr[0].get("password").is_none());
4110        assert_eq!(arr[1]["id"], 2);
4111        assert_eq!(arr[1]["name"], "Bob");
4112        assert!(arr[1].get("password").is_none());
4113    }
4114
4115    #[test]
4116    fn test_filter_blocked_fields_empty_blocklist() {
4117        let value = serde_json::json!({
4118            "id": 1,
4119            "password": "secret123"
4120        });
4121
4122        let blocked: HashSet<String> = HashSet::new();
4123        let filtered = filter_blocked_fields(value.clone(), &blocked);
4124
4125        // Should be unchanged
4126        assert_eq!(filtered, value);
4127    }
4128
4129    #[test]
4130    fn test_filter_blocked_fields_primitive_values() {
4131        // Primitives should pass through unchanged
4132        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4133
4134        assert_eq!(
4135            filter_blocked_fields(JsonValue::String("test".into()), &blocked),
4136            JsonValue::String("test".into())
4137        );
4138        assert_eq!(
4139            filter_blocked_fields(JsonValue::Number(42.into()), &blocked),
4140            JsonValue::Number(42.into())
4141        );
4142        assert_eq!(
4143            filter_blocked_fields(JsonValue::Bool(true), &blocked),
4144            JsonValue::Bool(true)
4145        );
4146        assert_eq!(
4147            filter_blocked_fields(JsonValue::Null, &blocked),
4148            JsonValue::Null
4149        );
4150    }
4151
4152    #[tokio::test]
4153    async fn test_execute_with_blocked_fields() {
4154        let code = r#"
4155            const user = await api.get('/users/1');
4156            return user;
4157        "#;
4158
4159        let mut compiler = PlanCompiler::new();
4160        let plan = compiler.compile_code(code).expect("Should compile");
4161
4162        let mut mock_http = MockHttpExecutor::new();
4163        mock_http.add_response(
4164            "/users/1",
4165            serde_json::json!({
4166                "id": 1,
4167                "name": "Alice",
4168                "password": "secret123",
4169                "apiKey": "key-abc"
4170            }),
4171        );
4172
4173        // Create config with blocked fields
4174        let config = ExecutionConfig::default().with_blocked_fields(["password", "apiKey"]);
4175
4176        let mut executor = PlanExecutor::new(mock_http, config);
4177        let result = executor.execute(&plan).await.expect("Should execute");
4178
4179        // Blocked fields should be filtered out
4180        assert_eq!(result.value["id"], 1);
4181        assert_eq!(result.value["name"], "Alice");
4182        assert!(result.value.get("password").is_none());
4183        assert!(result.value.get("apiKey").is_none());
4184    }
4185
4186    #[tokio::test]
4187    async fn test_execute_nested_blocked_fields() {
4188        let code = r#"
4189            const data = await api.get('/data');
4190            return data;
4191        "#;
4192
4193        let mut compiler = PlanCompiler::new();
4194        let plan = compiler.compile_code(code).expect("Should compile");
4195
4196        let mut mock_http = MockHttpExecutor::new();
4197        mock_http.add_response(
4198            "/data",
4199            serde_json::json!({
4200                "users": [
4201                    { "id": 1, "name": "Alice", "secret": "hidden1" },
4202                    { "id": 2, "name": "Bob", "secret": "hidden2" }
4203                ],
4204                "config": {
4205                    "setting": "value",
4206                    "secret": "also-hidden"
4207                }
4208            }),
4209        );
4210
4211        // Create config with blocked fields
4212        let config = ExecutionConfig::default().with_blocked_fields(["secret"]);
4213
4214        let mut executor = PlanExecutor::new(mock_http, config);
4215        let result = executor.execute(&plan).await.expect("Should execute");
4216
4217        // Secret should be filtered from all nested locations
4218        let users = result.value["users"].as_array().unwrap();
4219        assert_eq!(users[0]["name"], "Alice");
4220        assert!(users[0].get("secret").is_none());
4221        assert_eq!(users[1]["name"], "Bob");
4222        assert!(users[1].get("secret").is_none());
4223
4224        assert_eq!(result.value["config"]["setting"], "value");
4225        assert!(result.value["config"].get("secret").is_none());
4226    }
4227
4228    // =========================================================================
4229    // Output Validation Tests
4230    // =========================================================================
4231
4232    #[test]
4233    fn test_find_blocked_fields_in_output_simple() {
4234        let value = serde_json::json!({
4235            "id": 1,
4236            "name": "Alice",
4237            "ssn": "123-45-6789"
4238        });
4239
4240        let blocked: HashSet<String> = ["ssn"].iter().map(|s| s.to_string()).collect();
4241        let violations = find_blocked_fields_in_output(&value, &blocked);
4242
4243        assert_eq!(violations.len(), 1);
4244        assert_eq!(violations[0], "ssn");
4245    }
4246
4247    #[test]
4248    fn test_find_blocked_fields_in_output_nested() {
4249        let value = serde_json::json!({
4250            "user": {
4251                "profile": {
4252                    "name": "Alice",
4253                    "salary": 100000
4254                }
4255            }
4256        });
4257
4258        let blocked: HashSet<String> = ["salary"].iter().map(|s| s.to_string()).collect();
4259        let violations = find_blocked_fields_in_output(&value, &blocked);
4260
4261        assert_eq!(violations.len(), 1);
4262        assert!(violations[0].contains("salary"));
4263    }
4264
4265    #[test]
4266    fn test_find_blocked_fields_in_output_array() {
4267        let value = serde_json::json!([
4268            { "id": 1, "ssn": "111" },
4269            { "id": 2, "ssn": "222" }
4270        ]);
4271
4272        let blocked: HashSet<String> = ["ssn"].iter().map(|s| s.to_string()).collect();
4273        let violations = find_blocked_fields_in_output(&value, &blocked);
4274
4275        // Should find ssn in both array elements
4276        assert_eq!(violations.len(), 2);
4277    }
4278
4279    #[test]
4280    fn test_find_blocked_fields_in_output_empty_blocklist() {
4281        let value = serde_json::json!({
4282            "id": 1,
4283            "ssn": "123-45-6789"
4284        });
4285
4286        let blocked: HashSet<String> = HashSet::new();
4287        let violations = find_blocked_fields_in_output(&value, &blocked);
4288
4289        assert!(violations.is_empty());
4290    }
4291
4292    #[test]
4293    fn test_find_blocked_fields_in_output_no_violations() {
4294        let value = serde_json::json!({
4295            "id": 1,
4296            "name": "Alice"
4297        });
4298
4299        let blocked: HashSet<String> = ["ssn", "salary"].iter().map(|s| s.to_string()).collect();
4300        let violations = find_blocked_fields_in_output(&value, &blocked);
4301
4302        assert!(violations.is_empty());
4303    }
4304
4305    #[tokio::test]
4306    async fn test_execute_output_blocked_fields_rejected() {
4307        let code = r#"
4308            const user = await api.get('/users/1');
4309            return { name: user.name, ssn: user.ssn };
4310        "#;
4311
4312        let mut compiler = PlanCompiler::new();
4313        let plan = compiler.compile_code(code).expect("Should compile");
4314
4315        let mut mock_http = MockHttpExecutor::new();
4316        mock_http.add_response(
4317            "/users/1",
4318            serde_json::json!({
4319                "id": 1,
4320                "name": "Alice",
4321                "ssn": "123-45-6789"
4322            }),
4323        );
4324
4325        // Note: internal blocklist is empty, so ssn gets through to the script
4326        // But output blocklist should catch it in the return value
4327        let config = ExecutionConfig::default().with_output_blocked_fields(["ssn"]);
4328
4329        let mut executor = PlanExecutor::new(mock_http, config);
4330        let result = executor.execute(&plan).await;
4331
4332        // Should fail because output contains blocked field
4333        assert!(result.is_err());
4334        let err = result.unwrap_err();
4335        assert!(format!("{:?}", err).contains("ssn"));
4336    }
4337
4338    #[tokio::test]
4339    async fn test_execute_output_blocked_fields_internal_use_allowed() {
4340        // Script reads user data but only returns safe fields - should succeed
4341        let code = r#"
4342            const user = await api.get('/users/1');
4343            return { id: user.id, name: user.name };
4344        "#;
4345
4346        let mut compiler = PlanCompiler::new();
4347        let plan = compiler.compile_code(code).expect("Should compile");
4348
4349        let mut mock_http = MockHttpExecutor::new();
4350        mock_http.add_response(
4351            "/users/1",
4352            serde_json::json!({
4353                "id": 1,
4354                "name": "Alice",
4355                "ssn": "123-45-6789"
4356            }),
4357        );
4358
4359        // Output blocklist - ssn can be read but not returned
4360        // Note: This doesn't prevent script from accessing ssn, just returning it
4361        let config = ExecutionConfig::default().with_output_blocked_fields(["ssn"]);
4362
4363        let mut executor = PlanExecutor::new(mock_http, config);
4364        let result = executor.execute(&plan).await.expect("Should succeed");
4365
4366        // Script read user data but only returned safe fields
4367        assert_eq!(result.value["id"], 1);
4368        assert_eq!(result.value["name"], "Alice");
4369        assert!(result.value.get("ssn").is_none());
4370    }
4371
4372    #[tokio::test]
4373    async fn test_execute_both_blocklists() {
4374        // Test that internal blocklist AND output blocklist work together
4375        let code = r#"
4376            const user = await api.get('/users/1');
4377            return { name: user.name, dateOfBirth: user.dateOfBirth };
4378        "#;
4379
4380        let mut compiler = PlanCompiler::new();
4381        let plan = compiler.compile_code(code).expect("Should compile");
4382
4383        let mut mock_http = MockHttpExecutor::new();
4384        mock_http.add_response(
4385            "/users/1",
4386            serde_json::json!({
4387                "id": 1,
4388                "name": "Alice",
4389                "password": "secret123",
4390                "dateOfBirth": "1990-01-01"
4391            }),
4392        );
4393
4394        // Internal blocklist: password is stripped from API response
4395        // Output blocklist: dateOfBirth can be used but not returned
4396        let config = ExecutionConfig::default()
4397            .with_blocked_fields(["password"])
4398            .with_output_blocked_fields(["dateOfBirth"]);
4399
4400        let mut executor = PlanExecutor::new(mock_http, config);
4401        let result = executor.execute(&plan).await;
4402
4403        // Should fail because output contains dateOfBirth
4404        assert!(result.is_err());
4405        let err = result.unwrap_err();
4406        assert!(format!("{:?}", err).contains("dateOfBirth"));
4407    }
4408
4409    // ========================================================================
4410    // Tests for pre-bound variables (args) and conditionals
4411    // ========================================================================
4412
4413    #[tokio::test]
4414    async fn test_prebound_args_comparison() {
4415        // Test that `if (args.k > args.n)` works with pre-bound args
4416        let code = r#"
4417            if (args.k > args.n) {
4418                return { error: 'k must be <= n' };
4419            }
4420            return { ok: true };
4421        "#;
4422
4423        let mut compiler = PlanCompiler::new();
4424        let plan = compiler.compile_code(code).expect("Should compile");
4425
4426        let mock_http = MockHttpExecutor::new();
4427        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4428        executor.set_variable("args", serde_json::json!({"n": 3, "k": 5}));
4429
4430        let result = executor.execute(&plan).await.expect("Should execute");
4431        assert_eq!(
4432            result.value["error"], "k must be <= n",
4433            "Expected error for k > n, got: {:?}",
4434            result.value
4435        );
4436    }
4437
4438    #[tokio::test]
4439    async fn test_prebound_args_strict_equality() {
4440        // Test that `args.k === 0` works
4441        let code = r#"
4442            if (args.k === 0) {
4443                return { result: 1 };
4444            }
4445            return { result: 'not zero' };
4446        "#;
4447
4448        let mut compiler = PlanCompiler::new();
4449        let plan = compiler.compile_code(code).expect("Should compile");
4450
4451        let mock_http = MockHttpExecutor::new();
4452        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4453        executor.set_variable("args", serde_json::json!({"k": 0}));
4454
4455        let result = executor.execute(&plan).await.expect("Should execute");
4456        assert_eq!(result.value["result"], 1);
4457    }
4458
4459    #[tokio::test]
4460    async fn test_assignment_expression_in_statement() {
4461        // Test that `k = newValue` works as a statement (Expr::Assign)
4462        let code = r#"
4463            let k = 5;
4464            k = 2;
4465            return { k: k };
4466        "#;
4467
4468        let mut compiler = PlanCompiler::new();
4469        let plan = compiler.compile_code(code).expect("Should compile");
4470
4471        let mock_http = MockHttpExecutor::new();
4472        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4473
4474        let result = executor.execute(&plan).await.expect("Should execute");
4475        assert_eq!(result.value["k"], 2);
4476    }
4477
4478    #[tokio::test]
4479    async fn test_assignment_swap_variables() {
4480        // Test swapping two let-bound variables
4481        let code = r#"
4482            let a = 3;
4483            let b = 7;
4484            if (a < b) {
4485                const old_a = a;
4486                a = b;
4487                b = old_a;
4488            }
4489            return { a: a, b: b };
4490        "#;
4491
4492        let mut compiler = PlanCompiler::new();
4493        let plan = compiler.compile_code(code).expect("Should compile");
4494
4495        let mock_http = MockHttpExecutor::new();
4496        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4497
4498        let result = executor.execute(&plan).await.expect("Should execute");
4499        assert_eq!(result.value["a"], 7);
4500        assert_eq!(result.value["b"], 3);
4501    }
4502
4503    // ========================================================================
4504    // MCP call tests (require mcp-code-mode feature)
4505    // ========================================================================
4506
4507    #[cfg(feature = "mcp-code-mode")]
4508    mod mcp_tests {
4509        use super::*;
4510
4511        /// Mock MCP executor that simulates a calculator server.
4512        struct MockCalculatorExecutor;
4513
4514        #[async_trait::async_trait]
4515        impl McpExecutor for MockCalculatorExecutor {
4516            async fn call_tool(
4517                &self,
4518                _server_id: &str,
4519                tool_name: &str,
4520                args: JsonValue,
4521            ) -> Result<JsonValue, ExecutionError> {
4522                match tool_name {
4523                    "add" => {
4524                        let a = args["a"].as_f64().unwrap_or(0.0);
4525                        let b = args["b"].as_f64().unwrap_or(0.0);
4526                        Ok(serde_json::json!({"result": a + b}))
4527                    },
4528                    "subtract" => {
4529                        let a = args["a"].as_f64().unwrap_or(0.0);
4530                        let b = args["b"].as_f64().unwrap_or(0.0);
4531                        Ok(serde_json::json!({"result": a - b}))
4532                    },
4533                    "multiply" => {
4534                        let a = args["a"].as_f64().unwrap_or(0.0);
4535                        let b = args["b"].as_f64().unwrap_or(0.0);
4536                        Ok(serde_json::json!({"result": a * b}))
4537                    },
4538                    "divide" => {
4539                        let a = args["a"].as_f64().unwrap_or(0.0);
4540                        let b = args["b"].as_f64().unwrap_or(1.0);
4541                        Ok(serde_json::json!({"result": a / b}))
4542                    },
4543                    "power" => {
4544                        let base = args["base"].as_f64().unwrap_or(0.0);
4545                        let exponent = args["exponent"].as_f64().unwrap_or(1.0);
4546                        Ok(serde_json::json!({"result": base.powf(exponent)}))
4547                    },
4548                    "sqrt" => {
4549                        let n = args["n"].as_f64().unwrap_or(0.0);
4550                        Ok(serde_json::json!({"result": n.sqrt()}))
4551                    },
4552                    _ => Err(ExecutionError::RuntimeError {
4553                        message: format!("Unknown tool: {}", tool_name),
4554                    }),
4555                }
4556            }
4557        }
4558
4559        #[tokio::test]
4560        async fn test_mcp_call_simple() {
4561            let code = r#"
4562                const result = await mcp.call('calculator', 'add', { a: 5, b: 3 });
4563                return result;
4564            "#;
4565
4566            let mut compiler = PlanCompiler::new();
4567            let plan = compiler.compile_code(code).expect("Should compile");
4568
4569            let mock_http = MockHttpExecutor::new();
4570            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4571            executor.set_mcp_executor(MockCalculatorExecutor);
4572
4573            let result = executor.execute(&plan).await.expect("Should execute");
4574            assert_eq!(result.value["result"], 8.0);
4575        }
4576
4577        #[tokio::test]
4578        async fn test_mcp_call_with_args() {
4579            // Test mcp.call using pre-bound args variable
4580            let code = r#"
4581                const result = await mcp.call('calculator', 'add', { a: args.x, b: args.y });
4582                return { sum: result.result };
4583            "#;
4584
4585            let mut compiler = PlanCompiler::new();
4586            let plan = compiler.compile_code(code).expect("Should compile");
4587
4588            let mock_http = MockHttpExecutor::new();
4589            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4590            executor.set_mcp_executor(MockCalculatorExecutor);
4591            executor.set_variable("args", serde_json::json!({"x": 10, "y": 20}));
4592
4593            let result = executor.execute(&plan).await.expect("Should execute");
4594            assert_eq!(result.value["sum"], 30.0);
4595        }
4596
4597        #[tokio::test]
4598        async fn test_mcp_assignment_in_loop() {
4599            // Test `result = await mcp.call(...)` assignment inside a loop
4600            let code = r#"
4601                let result = { result: 1 };
4602                for (const i of [2, 3, 4, 5]) {
4603                    const mul = await mcp.call('calculator', 'multiply', { a: result.result, b: i });
4604                    result = mul;
4605                }
4606                return { factorial: result.result };
4607            "#;
4608
4609            let mut compiler = PlanCompiler::new();
4610            let plan = compiler.compile_code(code).expect("Should compile");
4611
4612            let mock_http = MockHttpExecutor::new();
4613            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4614            executor.set_mcp_executor(MockCalculatorExecutor);
4615
4616            let result = executor.execute(&plan).await.expect("Should execute");
4617            // 1 * 2 * 3 * 4 * 5 = 120
4618            assert_eq!(result.value["factorial"], 120.0);
4619        }
4620
4621        #[tokio::test]
4622        async fn test_combinations_c_5_3() {
4623            // Full combinations script: C(5,3) = 10
4624            let code = r#"
4625if (args.k > args.n) {
4626  return { error: 'k must be <= n', n: args.n, k: args.k };
4627}
4628if (args.k === 0 || args.k === args.n) {
4629  return { n: args.n, k: args.k, result: 1 };
4630}
4631let k = args.k;
4632const complement = await mcp.call('calculator', 'subtract', { a: args.n, b: args.k });
4633let nmk = complement.result;
4634if (nmk < k) {
4635  const old_k = k;
4636  k = nmk;
4637  nmk = old_k;
4638}
4639let result = { result: 1 };
4640for (const i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
4641  if (i > k) { break; }
4642  const nki = await mcp.call('calculator', 'add', { a: nmk, b: i });
4643  const num = await mcp.call('calculator', 'multiply', { a: result.result, b: nki.result });
4644  result = await mcp.call('calculator', 'divide', { a: num.result, b: i });
4645}
4646return { n: args.n, k: args.k, result: result.result };
4647            "#;
4648
4649            let mut compiler = PlanCompiler::new();
4650            let plan = compiler.compile_code(code).expect("Should compile");
4651
4652            let mock_http = MockHttpExecutor::new();
4653            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4654            executor.set_mcp_executor(MockCalculatorExecutor);
4655            executor.set_variable("args", serde_json::json!({"n": 5, "k": 3}));
4656
4657            let result = executor.execute(&plan).await.expect("Should execute");
4658            assert_eq!(
4659                result.value["result"], 10.0,
4660                "C(5,3) should be 10, got: {:?}",
4661                result.value
4662            );
4663        }
4664
4665        #[tokio::test]
4666        async fn test_combinations_k_greater_than_n() {
4667            // C(3,5) should return error
4668            let code = r#"
4669if (args.k > args.n) {
4670  return { error: 'k must be <= n', n: args.n, k: args.k };
4671}
4672return { result: 'should not reach here' };
4673            "#;
4674
4675            let mut compiler = PlanCompiler::new();
4676            let plan = compiler.compile_code(code).expect("Should compile");
4677
4678            let mock_http = MockHttpExecutor::new();
4679            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4680            executor.set_mcp_executor(MockCalculatorExecutor);
4681            executor.set_variable("args", serde_json::json!({"n": 3, "k": 5}));
4682
4683            let result = executor.execute(&plan).await.expect("Should execute");
4684            assert_eq!(
4685                result.value["error"], "k must be <= n",
4686                "C(3,5) should return error, got: {:?}",
4687                result.value
4688            );
4689        }
4690
4691        #[tokio::test]
4692        async fn test_combinations_c_5_2() {
4693            // C(5,2) = 10 — no swap needed
4694            let code = r#"
4695if (args.k > args.n) {
4696  return { error: 'k must be <= n', n: args.n, k: args.k };
4697}
4698if (args.k === 0 || args.k === args.n) {
4699  return { n: args.n, k: args.k, result: 1 };
4700}
4701let k = args.k;
4702const complement = await mcp.call('calculator', 'subtract', { a: args.n, b: args.k });
4703let nmk = complement.result;
4704if (nmk < k) {
4705  const old_k = k;
4706  k = nmk;
4707  nmk = old_k;
4708}
4709let result = { result: 1 };
4710for (const i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
4711  if (i > k) { break; }
4712  const nki = await mcp.call('calculator', 'add', { a: nmk, b: i });
4713  const num = await mcp.call('calculator', 'multiply', { a: result.result, b: nki.result });
4714  result = await mcp.call('calculator', 'divide', { a: num.result, b: i });
4715}
4716return { n: args.n, k: args.k, result: result.result };
4717            "#;
4718
4719            let mut compiler = PlanCompiler::new();
4720            let plan = compiler.compile_code(code).expect("Should compile");
4721
4722            let mock_http = MockHttpExecutor::new();
4723            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4724            executor.set_mcp_executor(MockCalculatorExecutor);
4725            executor.set_variable("args", serde_json::json!({"n": 5, "k": 2}));
4726
4727            let result = executor.execute(&plan).await.expect("Should execute");
4728            assert_eq!(
4729                result.value["result"], 10.0,
4730                "C(5,2) should be 10, got: {:?}",
4731                result.value
4732            );
4733        }
4734
4735        #[tokio::test]
4736        async fn test_combinations_edge_cases() {
4737            let code = r#"
4738if (args.k === 0 || args.k === args.n) {
4739  return { result: 1 };
4740}
4741return { result: 'not edge case' };
4742            "#;
4743
4744            let mut compiler = PlanCompiler::new();
4745            let plan = compiler.compile_code(code).expect("Should compile");
4746
4747            // Test k=0
4748            let mock_http = MockHttpExecutor::new();
4749            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4750            executor.set_variable("args", serde_json::json!({"n": 5, "k": 0}));
4751            let result = executor.execute(&plan).await.expect("Should execute");
4752            assert_eq!(result.value["result"], 1, "C(5,0) should be 1");
4753
4754            // Test k=n
4755            let mock_http = MockHttpExecutor::new();
4756            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4757            executor.set_variable("args", serde_json::json!({"n": 5, "k": 5}));
4758            let result = executor.execute(&plan).await.expect("Should execute");
4759            assert_eq!(result.value["result"], 1, "C(5,5) should be 1");
4760        }
4761
4762        #[tokio::test]
4763        async fn test_solve_quadratic() {
4764            // x² - 3x + 2 = 0 → roots [2, 1]
4765            let code = r#"
4766const b_sq = await mcp.call('calculator', 'power', { base: args.b, exponent: 2 });
4767const four_a = await mcp.call('calculator', 'multiply', { a: 4, b: args.a });
4768const four_ac = await mcp.call('calculator', 'multiply', { a: four_a.result, b: args.c });
4769const discriminant = await mcp.call('calculator', 'subtract', { a: b_sq.result, b: four_ac.result });
4770const root_type = discriminant.result > 0 ? 'two_real'
4771  : discriminant.result === 0 ? 'one_real' : 'complex';
4772if (discriminant.result < 0) {
4773  return { discriminant: discriminant.result, root_type: root_type, roots: [] };
4774}
4775const sqrt_disc = await mcp.call('calculator', 'sqrt', { n: discriminant.result });
4776const neg_b = await mcp.call('calculator', 'multiply', { a: -1, b: args.b });
4777const two_a = await mcp.call('calculator', 'multiply', { a: 2, b: args.a });
4778const x1_num = await mcp.call('calculator', 'add', { a: neg_b.result, b: sqrt_disc.result });
4779const x2_num = await mcp.call('calculator', 'subtract', { a: neg_b.result, b: sqrt_disc.result });
4780const x1 = await mcp.call('calculator', 'divide', { a: x1_num.result, b: two_a.result });
4781const x2 = await mcp.call('calculator', 'divide', { a: x2_num.result, b: two_a.result });
4782return { discriminant: discriminant.result, root_type: root_type, roots: [x1.result, x2.result] };
4783            "#;
4784
4785            let mut compiler = PlanCompiler::new();
4786            let plan = compiler.compile_code(code).expect("Should compile");
4787
4788            let mock_http = MockHttpExecutor::new();
4789            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4790            executor.set_mcp_executor(MockCalculatorExecutor);
4791            executor.set_variable("args", serde_json::json!({"a": 1, "b": -3, "c": 2}));
4792
4793            let result = executor.execute(&plan).await.expect("Should execute");
4794            assert_eq!(result.value["root_type"], "two_real");
4795            assert_eq!(result.value["discriminant"], 1.0);
4796            let roots = result.value["roots"]
4797                .as_array()
4798                .expect("roots should be array");
4799            assert_eq!(roots.len(), 2);
4800            assert_eq!(roots[0], 2.0);
4801            assert_eq!(roots[1], 1.0);
4802        }
4803    }
4804
4805    // =========================================================================
4806    // String method integration tests (compile + execute)
4807    // =========================================================================
4808
4809    #[tokio::test]
4810    async fn test_string_includes() {
4811        let code = r#"
4812            const text = "hello world";
4813            return { found: text.includes("world"), miss: text.includes("xyz") };
4814        "#;
4815
4816        let mut compiler = PlanCompiler::new();
4817        let plan = compiler.compile_code(code).expect("Should compile");
4818
4819        let mock_http = MockHttpExecutor::new();
4820        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4821        let result = executor.execute(&plan).await.expect("Should execute");
4822
4823        assert_eq!(result.value["found"], true);
4824        assert_eq!(result.value["miss"], false);
4825    }
4826
4827    #[tokio::test]
4828    async fn test_string_index_of() {
4829        let code = r#"
4830            const text = "abcdef";
4831            return { idx: text.indexOf("cd"), miss: text.indexOf("xyz") };
4832        "#;
4833
4834        let mut compiler = PlanCompiler::new();
4835        let plan = compiler.compile_code(code).expect("Should compile");
4836
4837        let mock_http = MockHttpExecutor::new();
4838        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4839        let result = executor.execute(&plan).await.expect("Should execute");
4840
4841        assert_eq!(result.value["idx"], 2);
4842        assert_eq!(result.value["miss"], -1);
4843    }
4844
4845    #[tokio::test]
4846    async fn test_string_length() {
4847        let code = r#"
4848            const text = "hello";
4849            return { len: text.length };
4850        "#;
4851
4852        let mut compiler = PlanCompiler::new();
4853        let plan = compiler.compile_code(code).expect("Should compile");
4854
4855        let mock_http = MockHttpExecutor::new();
4856        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4857        let result = executor.execute(&plan).await.expect("Should execute");
4858
4859        assert_eq!(result.value["len"], 5);
4860    }
4861
4862    #[tokio::test]
4863    async fn test_string_slice() {
4864        let code = r#"
4865            const text = "hello world";
4866            return { first: text.slice(0, 5), rest: text.slice(6, 11) };
4867        "#;
4868
4869        let mut compiler = PlanCompiler::new();
4870        let plan = compiler.compile_code(code).expect("Should compile");
4871
4872        let mock_http = MockHttpExecutor::new();
4873        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4874        let result = executor.execute(&plan).await.expect("Should execute");
4875
4876        assert_eq!(result.value["first"], "hello");
4877        assert_eq!(result.value["rest"], "world");
4878    }
4879
4880    #[tokio::test]
4881    async fn test_string_concat() {
4882        let code = r#"
4883            const greeting = "hello";
4884            return { result: greeting.concat(" world") };
4885        "#;
4886
4887        let mut compiler = PlanCompiler::new();
4888        let plan = compiler.compile_code(code).expect("Should compile");
4889
4890        let mock_http = MockHttpExecutor::new();
4891        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4892        let result = executor.execute(&plan).await.expect("Should execute");
4893
4894        assert_eq!(result.value["result"], "hello world");
4895    }
4896
4897    #[tokio::test]
4898    async fn test_string_includes_in_filter() {
4899        // Real-world pattern: filter array items by string content
4900        let code = r#"
4901            const items = [
4902                { name: "TIMESTAMP_2024", desc: "A timestamped record" },
4903                { name: "PERSON_1", desc: "A person entity" },
4904                { name: "TIMESTAMP_2025", desc: "Another timestamped record" }
4905            ];
4906            const timestamped = items.filter(item => item.name.includes("TIMESTAMP"));
4907            return { count: timestamped.length, names: timestamped.map(t => t.name) };
4908        "#;
4909
4910        let mut compiler = PlanCompiler::new();
4911        let plan = compiler.compile_code(code).expect("Should compile");
4912
4913        let mock_http = MockHttpExecutor::new();
4914        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4915        let result = executor.execute(&plan).await.expect("Should execute");
4916
4917        assert_eq!(result.value["count"], 2);
4918        let names = result.value["names"].as_array().unwrap();
4919        assert_eq!(names[0], "TIMESTAMP_2024");
4920        assert_eq!(names[1], "TIMESTAMP_2025");
4921    }
4922
4923    #[tokio::test]
4924    async fn test_array_includes_still_works() {
4925        // Regression: array .includes() must still work
4926        let code = r#"
4927            const ids = ["alice", "bob", "charlie"];
4928            return { has_bob: ids.includes("bob"), has_dave: ids.includes("dave") };
4929        "#;
4930
4931        let mut compiler = PlanCompiler::new();
4932        let plan = compiler.compile_code(code).expect("Should compile");
4933
4934        let mock_http = MockHttpExecutor::new();
4935        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4936        let result = executor.execute(&plan).await.expect("Should execute");
4937
4938        assert_eq!(result.value["has_bob"], true);
4939        assert_eq!(result.value["has_dave"], false);
4940    }
4941
4942    // =========================================================================
4943    // Built-in function compilation tests
4944    // =========================================================================
4945
4946    #[test]
4947    fn test_compile_parse_float() {
4948        let code = r#"
4949            const x = parseFloat("3.14");
4950            return x;
4951        "#;
4952        let mut compiler = PlanCompiler::new();
4953        let plan = compiler
4954            .compile_code(code)
4955            .expect("parseFloat should compile");
4956        assert_eq!(plan.steps.len(), 2); // Assign + Return
4957    }
4958
4959    #[test]
4960    fn test_compile_parse_int() {
4961        let code = r#"
4962            const x = parseInt("42");
4963            return x;
4964        "#;
4965        let mut compiler = PlanCompiler::new();
4966        compiler
4967            .compile_code(code)
4968            .expect("parseInt should compile");
4969    }
4970
4971    #[test]
4972    fn test_compile_math_abs() {
4973        let code = r#"
4974            const x = Math.abs(-5);
4975            return x;
4976        "#;
4977        let mut compiler = PlanCompiler::new();
4978        compiler
4979            .compile_code(code)
4980            .expect("Math.abs should compile");
4981    }
4982
4983    #[test]
4984    fn test_compile_math_max() {
4985        let code = r#"
4986            const x = Math.max(1, 2, 3);
4987            return x;
4988        "#;
4989        let mut compiler = PlanCompiler::new();
4990        compiler
4991            .compile_code(code)
4992            .expect("Math.max should compile");
4993    }
4994
4995    #[test]
4996    fn test_compile_object_keys() {
4997        let code = r#"
4998            const obj = { a: 1, b: 2 };
4999            const keys = Object.keys(obj);
5000            return keys;
5001        "#;
5002        let mut compiler = PlanCompiler::new();
5003        compiler
5004            .compile_code(code)
5005            .expect("Object.keys should compile");
5006    }
5007
5008    #[test]
5009    fn test_compile_object_entries() {
5010        let code = r#"
5011            const obj = { x: 10 };
5012            const entries = Object.entries(obj);
5013            return entries;
5014        "#;
5015        let mut compiler = PlanCompiler::new();
5016        compiler
5017            .compile_code(code)
5018            .expect("Object.entries should compile");
5019    }
5020
5021    #[test]
5022    fn test_compile_unary_plus() {
5023        let code = r#"
5024            const x = +"42";
5025            return x;
5026        "#;
5027        let mut compiler = PlanCompiler::new();
5028        compiler.compile_code(code).expect("unary + should compile");
5029    }
5030
5031    #[test]
5032    fn test_compile_sort_with_comparator() {
5033        let code = r#"
5034            const arr = [3, 1, 2];
5035            const sorted = arr.sort((a, b) => a - b);
5036            return sorted;
5037        "#;
5038        let mut compiler = PlanCompiler::new();
5039        compiler
5040            .compile_code(code)
5041            .expect("sort with comparator should compile");
5042    }
5043
5044    #[test]
5045    fn test_compile_sort_without_comparator() {
5046        let code = r#"
5047            const arr = ["b", "a", "c"];
5048            const sorted = arr.sort();
5049            return sorted;
5050        "#;
5051        let mut compiler = PlanCompiler::new();
5052        compiler
5053            .compile_code(code)
5054            .expect("sort without comparator should compile");
5055    }
5056
5057    // =========================================================================
5058    // End-to-end execution tests for new features
5059    // =========================================================================
5060
5061    #[tokio::test]
5062    async fn test_execute_parse_float() {
5063        let code = r#"
5064            const x = parseFloat("3.14");
5065            return x;
5066        "#;
5067        let mut compiler = PlanCompiler::new();
5068        let plan = compiler.compile_code(code).unwrap();
5069        let mock_http = MockHttpExecutor::new();
5070        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5071        let result = executor.execute(&plan).await.unwrap();
5072        // Why: test fixture uses 3.14 as a representative non-integer parse target,
5073        // not the mathematical PI constant — clippy::approx_constant is a false positive here.
5074        #[allow(clippy::approx_constant)]
5075        let expected = serde_json::json!(3.14);
5076        assert_eq!(result.value, expected);
5077    }
5078
5079    #[tokio::test]
5080    async fn test_execute_math_abs_and_sort() {
5081        let code = r#"
5082            const items = [
5083                { name: "a", val: -5 },
5084                { name: "b", val: 3 },
5085                { name: "c", val: -1 }
5086            ];
5087            const sorted = items.sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
5088            return sorted.map(x => x.name);
5089        "#;
5090        let mut compiler = PlanCompiler::new();
5091        let plan = compiler.compile_code(code).unwrap();
5092        let mock_http = MockHttpExecutor::new();
5093        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5094        let result = executor.execute(&plan).await.unwrap();
5095        assert_eq!(result.value, serde_json::json!(["a", "b", "c"]));
5096    }
5097
5098    #[tokio::test]
5099    async fn test_execute_object_keys() {
5100        let code = r#"
5101            const obj = { x: 1, y: 2, z: 3 };
5102            return Object.keys(obj).length;
5103        "#;
5104        let mut compiler = PlanCompiler::new();
5105        let plan = compiler.compile_code(code).unwrap();
5106        let mock_http = MockHttpExecutor::new();
5107        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5108        let result = executor.execute(&plan).await.unwrap();
5109        assert_eq!(result.value, serde_json::json!(3));
5110    }
5111
5112    #[tokio::test]
5113    async fn test_execute_unary_plus() {
5114        let code = r#"
5115            const x = +"42";
5116            return x;
5117        "#;
5118        let mut compiler = PlanCompiler::new();
5119        let plan = compiler.compile_code(code).unwrap();
5120        let mock_http = MockHttpExecutor::new();
5121        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5122        let result = executor.execute(&plan).await.unwrap();
5123        assert_eq!(result.value, serde_json::json!(42.0));
5124    }
5125
5126    #[tokio::test]
5127    async fn test_execute_number_cast() {
5128        let code = r#"
5129            const x = Number("99.5");
5130            return x;
5131        "#;
5132        let mut compiler = PlanCompiler::new();
5133        let plan = compiler.compile_code(code).unwrap();
5134        let mock_http = MockHttpExecutor::new();
5135        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5136        let result = executor.execute(&plan).await.unwrap();
5137        assert_eq!(result.value, serde_json::json!(99.5));
5138    }
5139
5140    #[tokio::test]
5141    async fn test_execute_math_round_floor_ceil() {
5142        let code = r#"
5143            return {
5144                round: Math.round(3.7),
5145                floor: Math.floor(3.7),
5146                ceil: Math.ceil(3.2)
5147            };
5148        "#;
5149        let mut compiler = PlanCompiler::new();
5150        let plan = compiler.compile_code(code).unwrap();
5151        let mock_http = MockHttpExecutor::new();
5152        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5153        let result = executor.execute(&plan).await.unwrap();
5154        assert_eq!(result.value["round"], serde_json::json!(4.0));
5155        assert_eq!(result.value["floor"], serde_json::json!(3.0));
5156        assert_eq!(result.value["ceil"], serde_json::json!(4.0));
5157    }
5158
5159    // =========================================================================
5160    // Object Spread Tests
5161    // =========================================================================
5162
5163    #[test]
5164    fn test_compile_object_spread_basic() {
5165        let code = r#"
5166            const base = { id: 1, name: "Alice" };
5167            const extended = { ...base, age: 30 };
5168            return extended;
5169        "#;
5170        let mut compiler = PlanCompiler::new();
5171        let plan = compiler
5172            .compile_code(code)
5173            .expect("Object spread should compile");
5174        assert!(plan.steps.len() >= 2);
5175    }
5176
5177    #[tokio::test]
5178    async fn test_execute_object_spread_basic() {
5179        let code = r#"
5180            const base = { id: 1, name: "Alice" };
5181            const extended = { ...base, age: 30 };
5182            return extended;
5183        "#;
5184        let mut compiler = PlanCompiler::new();
5185        let plan = compiler.compile_code(code).unwrap();
5186        let mock_http = MockHttpExecutor::new();
5187        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5188        let result = executor.execute(&plan).await.unwrap();
5189        assert_eq!(result.value["id"], serde_json::json!(1));
5190        assert_eq!(result.value["name"], serde_json::json!("Alice"));
5191        assert_eq!(result.value["age"], serde_json::json!(30));
5192    }
5193
5194    #[tokio::test]
5195    async fn test_execute_object_spread_override() {
5196        // Later properties should override spread properties (JS semantics)
5197        let code = r#"
5198            const obj = { id: 1, name: "old" };
5199            const updated = { ...obj, name: "new" };
5200            return updated;
5201        "#;
5202        let mut compiler = PlanCompiler::new();
5203        let plan = compiler.compile_code(code).unwrap();
5204        let mock_http = MockHttpExecutor::new();
5205        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5206        let result = executor.execute(&plan).await.unwrap();
5207        assert_eq!(result.value["id"], serde_json::json!(1));
5208        assert_eq!(result.value["name"], serde_json::json!("new"));
5209    }
5210
5211    #[tokio::test]
5212    async fn test_execute_object_spread_multiple() {
5213        let code = r#"
5214            const a = { x: 1 };
5215            const b = { y: 2 };
5216            const merged = { ...a, ...b, z: 3 };
5217            return merged;
5218        "#;
5219        let mut compiler = PlanCompiler::new();
5220        let plan = compiler.compile_code(code).unwrap();
5221        let mock_http = MockHttpExecutor::new();
5222        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5223        let result = executor.execute(&plan).await.unwrap();
5224        assert_eq!(result.value["x"], serde_json::json!(1));
5225        assert_eq!(result.value["y"], serde_json::json!(2));
5226        assert_eq!(result.value["z"], serde_json::json!(3));
5227    }
5228
5229    #[tokio::test]
5230    async fn test_execute_object_spread_with_api_result() {
5231        // Primary use case: spread API result into a new object
5232        let code = r#"
5233            const config = await api.get('/config');
5234            const result = { ...config, extra: "added" };
5235            return result;
5236        "#;
5237        let mut compiler = PlanCompiler::new();
5238        let plan = compiler.compile_code(code).unwrap();
5239        let mut mock_http = MockHttpExecutor::new();
5240        mock_http.add_response(
5241            "/config",
5242            serde_json::json!({ "key": "value", "enabled": true }),
5243        );
5244        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5245        let result = executor.execute(&plan).await.unwrap();
5246        assert_eq!(result.value["key"], serde_json::json!("value"));
5247        assert_eq!(result.value["enabled"], serde_json::json!(true));
5248        assert_eq!(result.value["extra"], serde_json::json!("added"));
5249    }
5250
5251    #[tokio::test]
5252    async fn test_execute_object_spread_non_object_noop() {
5253        // Spreading a non-object should be a no-op (matches JS behavior)
5254        let code = r#"
5255            const x = 42;
5256            const obj = { ...x, name: "test" };
5257            return obj;
5258        "#;
5259        let mut compiler = PlanCompiler::new();
5260        let plan = compiler.compile_code(code).unwrap();
5261        let mock_http = MockHttpExecutor::new();
5262        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5263        let result = executor.execute(&plan).await.unwrap();
5264        assert_eq!(result.value["name"], serde_json::json!("test"));
5265        // x (number) should not add any properties
5266        assert!(result.value.as_object().unwrap().len() == 1);
5267    }
5268
5269    #[tokio::test]
5270    async fn test_execute_object_spread_preserves_order() {
5271        // Spread before explicit property: explicit wins
5272        // Explicit before spread: spread wins
5273        let code = r#"
5274            const obj = { a: 1, b: 2 };
5275            const result = { b: 99, ...obj, a: 100 };
5276            return result;
5277        "#;
5278        let mut compiler = PlanCompiler::new();
5279        let plan = compiler.compile_code(code).unwrap();
5280        let mock_http = MockHttpExecutor::new();
5281        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5282        let result = executor.execute(&plan).await.unwrap();
5283        // { b: 99 } then { ...obj } → b overridden to 2, then { a: 100 } → a overridden to 100
5284        assert_eq!(result.value["a"], serde_json::json!(100));
5285        assert_eq!(result.value["b"], serde_json::json!(2));
5286    }
5287
5288    // =========================================================================
5289    // Object Destructuring Tests
5290    // =========================================================================
5291
5292    #[test]
5293    fn test_compile_object_destructuring_simple() {
5294        let code = r#"
5295            const obj = { id: 1, name: "Alice" };
5296            const { id, name } = obj;
5297            return { id, name };
5298        "#;
5299        let mut compiler = PlanCompiler::new();
5300        let plan = compiler
5301            .compile_code(code)
5302            .expect("Object destructuring should compile");
5303        // Should have: Assign(obj), Assign(__destructure_0), Assign(id), Assign(name), Return
5304        assert!(plan.steps.len() >= 4);
5305    }
5306
5307    #[tokio::test]
5308    async fn test_execute_object_destructuring_simple() {
5309        let code = r#"
5310            const obj = { id: 1, name: "Alice", extra: "ignored" };
5311            const { id, name } = obj;
5312            return { id, name };
5313        "#;
5314        let mut compiler = PlanCompiler::new();
5315        let plan = compiler.compile_code(code).unwrap();
5316        let mock_http = MockHttpExecutor::new();
5317        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5318        let result = executor.execute(&plan).await.unwrap();
5319        assert_eq!(result.value["id"], serde_json::json!(1));
5320        assert_eq!(result.value["name"], serde_json::json!("Alice"));
5321        // "extra" should not be in output since we only destructured id and name
5322        assert!(result.value.get("extra").is_none());
5323    }
5324
5325    #[tokio::test]
5326    async fn test_execute_object_destructuring_renamed() {
5327        let code = r#"
5328            const user = { id: 1, name: "Alice" };
5329            const { id: userId, name: userName } = user;
5330            return { userId, userName };
5331        "#;
5332        let mut compiler = PlanCompiler::new();
5333        let plan = compiler.compile_code(code).unwrap();
5334        let mock_http = MockHttpExecutor::new();
5335        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5336        let result = executor.execute(&plan).await.unwrap();
5337        assert_eq!(result.value["userId"], serde_json::json!(1));
5338        assert_eq!(result.value["userName"], serde_json::json!("Alice"));
5339    }
5340
5341    #[tokio::test]
5342    async fn test_execute_object_destructuring_with_api_call() {
5343        // The primary use case: destructure API response
5344        let code = r#"
5345            const { data, status } = await api.get('/users');
5346            return { data, status };
5347        "#;
5348        let mut compiler = PlanCompiler::new();
5349        let plan = compiler.compile_code(code).unwrap();
5350        let mut mock_http = MockHttpExecutor::new();
5351        mock_http.add_response(
5352            "/users",
5353            serde_json::json!({ "data": [{"id": 1}], "status": "ok", "meta": "hidden" }),
5354        );
5355        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5356        let result = executor.execute(&plan).await.unwrap();
5357        assert_eq!(result.value["data"], serde_json::json!([{"id": 1}]));
5358        assert_eq!(result.value["status"], serde_json::json!("ok"));
5359    }
5360
5361    #[tokio::test]
5362    async fn test_execute_object_destructuring_missing_property() {
5363        // Missing properties should be null (matches JS behavior)
5364        let code = r#"
5365            const obj = { id: 1 };
5366            const { id, name } = obj;
5367            return { id, name };
5368        "#;
5369        let mut compiler = PlanCompiler::new();
5370        let plan = compiler.compile_code(code).unwrap();
5371        let mock_http = MockHttpExecutor::new();
5372        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5373        let result = executor.execute(&plan).await.unwrap();
5374        assert_eq!(result.value["id"], serde_json::json!(1));
5375        assert_eq!(result.value["name"], serde_json::json!(null));
5376    }
5377
5378    // =========================================================================
5379    // Array Destructuring Tests
5380    // =========================================================================
5381
5382    #[tokio::test]
5383    async fn test_execute_array_destructuring_simple() {
5384        let code = r#"
5385            const arr = [10, 20, 30];
5386            const [a, b] = arr;
5387            return { a, b };
5388        "#;
5389        let mut compiler = PlanCompiler::new();
5390        let plan = compiler.compile_code(code).unwrap();
5391        let mock_http = MockHttpExecutor::new();
5392        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5393        let result = executor.execute(&plan).await.unwrap();
5394        assert_eq!(result.value["a"], serde_json::json!(10));
5395        assert_eq!(result.value["b"], serde_json::json!(20));
5396    }
5397
5398    #[tokio::test]
5399    async fn test_execute_array_destructuring_with_promise_all() {
5400        // Common pattern: destructure Promise.all results
5401        let code = r#"
5402            const [users, products] = await Promise.all([
5403                api.get('/users'),
5404                api.get('/products')
5405            ]);
5406            return { users, products };
5407        "#;
5408        let mut compiler = PlanCompiler::new();
5409        let plan = compiler.compile_code(code).unwrap();
5410        let mut mock_http = MockHttpExecutor::new();
5411        mock_http.add_response("/users", serde_json::json!([{"id": 1}]));
5412        mock_http.add_response("/products", serde_json::json!([{"sku": "A"}]));
5413        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5414        let result = executor.execute(&plan).await.unwrap();
5415        assert_eq!(result.value["users"], serde_json::json!([{"id": 1}]));
5416        assert_eq!(result.value["products"], serde_json::json!([{"sku": "A"}]));
5417    }
5418
5419    // =========================================================================
5420    // For-of Loop Destructuring Tests
5421    // =========================================================================
5422
5423    #[test]
5424    fn test_compile_for_of_destructuring() {
5425        let code = r#"
5426            const items = [{ id: 1, name: "A" }, { id: 2, name: "B" }];
5427            const results = [];
5428            for (const { id, name } of items.slice(0, 10)) {
5429                results.push({ id, name });
5430            }
5431            return results;
5432        "#;
5433        let mut compiler = PlanCompiler::new();
5434        compiler
5435            .compile_code(code)
5436            .expect("For-of with destructuring should compile");
5437    }
5438
5439    #[tokio::test]
5440    async fn test_execute_for_of_destructuring() {
5441        let code = r#"
5442            const items = [{ id: 1, name: "A" }, { id: 2, name: "B" }];
5443            const results = [];
5444            for (const { id, name } of items.slice(0, 10)) {
5445                results.push({ label: name, num: id });
5446            }
5447            return results;
5448        "#;
5449        let mut compiler = PlanCompiler::new();
5450        let plan = compiler.compile_code(code).unwrap();
5451        let mock_http = MockHttpExecutor::new();
5452        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5453        let result = executor.execute(&plan).await.unwrap();
5454        let arr = result.value.as_array().unwrap();
5455        assert_eq!(arr.len(), 2);
5456        assert_eq!(arr[0]["label"], serde_json::json!("A"));
5457        assert_eq!(arr[0]["num"], serde_json::json!(1));
5458        assert_eq!(arr[1]["label"], serde_json::json!("B"));
5459        assert_eq!(arr[1]["num"], serde_json::json!(2));
5460    }
5461
5462    #[tokio::test]
5463    async fn test_execute_for_of_destructuring_with_api_calls() {
5464        // Real-world pattern: destructure loop items, use properties in API calls
5465        let code = r#"
5466            const users = [{ id: 1, role: "admin" }, { id: 2, role: "user" }];
5467            const results = [];
5468            for (const { id, role } of users.slice(0, 10)) {
5469                const detail = await api.get(`/users/${id}`);
5470                results.push({ role, detail });
5471            }
5472            return results;
5473        "#;
5474        let mut compiler = PlanCompiler::new();
5475        let plan = compiler.compile_code(code).unwrap();
5476        let mut mock_http = MockHttpExecutor::new();
5477        mock_http.add_response("/users/1", serde_json::json!({ "name": "Alice" }));
5478        mock_http.add_response("/users/2", serde_json::json!({ "name": "Bob" }));
5479        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5480        let result = executor.execute(&plan).await.unwrap();
5481        let arr = result.value.as_array().unwrap();
5482        assert_eq!(arr.len(), 2);
5483        assert_eq!(arr[0]["role"], serde_json::json!("admin"));
5484        assert_eq!(arr[0]["detail"]["name"], serde_json::json!("Alice"));
5485        assert_eq!(arr[1]["role"], serde_json::json!("user"));
5486        assert_eq!(arr[1]["detail"]["name"], serde_json::json!("Bob"));
5487    }
5488
5489    // =========================================================================
5490    // Combined Spread + Destructuring Tests
5491    // =========================================================================
5492
5493    #[tokio::test]
5494    async fn test_execute_spread_and_destructuring_combined() {
5495        // Realistic pattern: destructure API response, spread into new request
5496        let code = r#"
5497            const { data, token } = await api.get('/auth');
5498            const result = await api.post('/action', { ...data, token });
5499            return result;
5500        "#;
5501        let mut compiler = PlanCompiler::new();
5502        let plan = compiler.compile_code(code).unwrap();
5503        let mut mock_http = MockHttpExecutor::new();
5504        mock_http.add_response(
5505            "/auth",
5506            serde_json::json!({ "data": { "user": "alice" }, "token": "abc123" }),
5507        );
5508        mock_http.add_response("/action", serde_json::json!({ "success": true }));
5509        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5510        let result = executor.execute(&plan).await.unwrap();
5511        assert_eq!(result.value["success"], serde_json::json!(true));
5512    }
5513}
5514
5515// ============================================================================
5516// PHASE 128 D-09 — resolve-and-check-before-dispatch test support
5517// ============================================================================
5518
5519/// Shared fixtures for the `layer_one` / `layer_two` / `composition` /
5520/// `error_does_not_echo_path` modules below.
5521///
5522/// Those four modules sit at THIS level (siblings of `tests`) rather than inside
5523/// it on purpose: the plan's verify selections are `executor::layer_one`,
5524/// `executor::composition` and `executor::error_does_not_echo_path`, and libtest
5525/// matches the FULL test path. A test nested in `tests` would be
5526/// `executor::tests::layer_one_…`, which those filters do NOT match — a
5527/// zero-selection that exits 0 and measures nothing.
5528#[cfg(test)]
5529mod d09_support {
5530    use super::*;
5531    use std::sync::{Arc, Mutex};
5532
5533    /// One observation of an `execute_request` call.
5534    #[derive(Debug, Clone)]
5535    pub(super) struct Seen {
5536        pub(super) method: String,
5537        pub(super) path: String,
5538        pub(super) body: Option<JsonValue>,
5539    }
5540
5541    /// A recording `HttpExecutor` whose log stays observable after the executor
5542    /// has been moved into a `PlanExecutor`.
5543    pub(super) struct RecordingHttp {
5544        seen: Arc<Mutex<Vec<Seen>>>,
5545        response: JsonValue,
5546    }
5547
5548    impl RecordingHttp {
5549        /// Returns the executor plus a handle onto its call log.
5550        pub(super) fn new() -> (Self, Arc<Mutex<Vec<Seen>>>) {
5551            let seen = Arc::new(Mutex::new(Vec::new()));
5552            (
5553                Self {
5554                    seen: Arc::clone(&seen),
5555                    response: JsonValue::Object(serde_json::Map::new()),
5556                },
5557                seen,
5558            )
5559        }
5560    }
5561
5562    #[async_trait::async_trait]
5563    impl HttpExecutor for RecordingHttp {
5564        async fn execute_request(
5565            &self,
5566            method: &str,
5567            path: ResolvedPath<'_>,
5568            body: Option<JsonValue>,
5569        ) -> Result<JsonValue, ExecutionError> {
5570            self.seen.lock().unwrap().push(Seen {
5571                method: method.to_string(),
5572                path: path.as_str().to_string(),
5573                body,
5574            });
5575            Ok(self.response.clone())
5576        }
5577    }
5578
5579    /// An `HttpExecutor` that always fails, for the Pitfall-7 wrap tests.
5580    pub(super) struct FailingHttp;
5581
5582    #[async_trait::async_trait]
5583    impl HttpExecutor for FailingHttp {
5584        async fn execute_request(
5585            &self,
5586            _method: &str,
5587            _path: ResolvedPath<'_>,
5588            _body: Option<JsonValue>,
5589        ) -> Result<JsonValue, ExecutionError> {
5590            Err(ExecutionError::RuntimeError {
5591                message: "simulated transport failure".to_string(),
5592            })
5593        }
5594    }
5595
5596    /// An `HttpExecutor` that REFUSES every request, as an embedder's outbound
5597    /// policy does. The message is a fixed constant so a test can assert it survives
5598    /// the plan executor and that nothing else of the request is added to it.
5599    pub(super) struct RefusingHttp;
5600
5601    pub(super) const REFUSAL_MESSAGE: &str = "outbound request refused by policy: fixed text";
5602
5603    #[async_trait::async_trait]
5604    impl HttpExecutor for RefusingHttp {
5605        async fn execute_request(
5606            &self,
5607            _method: &str,
5608            _path: ResolvedPath<'_>,
5609            _body: Option<JsonValue>,
5610        ) -> Result<JsonValue, ExecutionError> {
5611            Err(ExecutionError::RequestRefused {
5612                message: REFUSAL_MESSAGE.to_string(),
5613            })
5614        }
5615    }
5616
5617    pub(super) fn plan(steps: Vec<PlanStep>) -> ExecutionPlan {
5618        ExecutionPlan {
5619            steps,
5620            metadata: PlanMetadata {
5621                api_call_count: 0,
5622                has_mutations: false,
5623                endpoints: Vec::new(),
5624                methods_used: Vec::new(),
5625            },
5626        }
5627    }
5628
5629    /// A GET `ApiCall` step over the given path parts and optional body literal.
5630    pub(super) fn get_step(parts: Vec<PathPart>, body: Option<JsonValue>) -> PlanStep {
5631        PlanStep::ApiCall {
5632            result_var: "out".to_string(),
5633            method: "GET".to_string(),
5634            path: PathTemplate { parts },
5635            body: body.map(ValueExpr::Literal),
5636        }
5637    }
5638
5639    /// `literal` is the common case: a string-literal path template.
5640    pub(super) fn literal(path: &str) -> Vec<PathPart> {
5641        vec![PathPart::Literal(path.to_string())]
5642    }
5643}
5644
5645/// FORK 2 — LAYER-1 `${var}` template-literal interpolation is floored.
5646///
5647/// `PathPart::Variable` and `PathPart::Expression` are what the JS compiler emits
5648/// for `` api.get(`/search/${v}`) ``, and their rendered values used to be pushed
5649/// into the path unchecked. A script taking that route never touches a `{key}`
5650/// placeholder, so before this phase it was never floored at all (T-128-20a).
5651#[cfg(test)]
5652mod layer_one {
5653    use super::d09_support::{get_step, literal, plan, RecordingHttp};
5654    use super::*;
5655
5656    async fn run_with_var(
5657        var: &str,
5658        value: JsonValue,
5659    ) -> (Result<ExecutionResult, ExecutionError>, usize) {
5660        let (http, seen) = RecordingHttp::new();
5661        let step = get_step(
5662            vec![
5663                PathPart::Literal("/search/".to_string()),
5664                PathPart::Variable(var.to_string()),
5665            ],
5666            None,
5667        );
5668        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5669        executor.set_variable(var, value);
5670        let result = executor.execute(&plan(vec![step])).await;
5671        let count = seen.lock().unwrap().len();
5672        (result, count)
5673    }
5674
5675    #[tokio::test]
5676    async fn layer_one_refuses_a_query_separator_in_a_variable_part() {
5677        let payload = format!("2026AA?string={}", "z".repeat(60));
5678        let (result, calls) = run_with_var("lookupKey", JsonValue::String(payload.clone())).await;
5679        let err = result.expect_err("a query separator in a ${var} part must be refused");
5680        let rendered = err.to_string();
5681        assert_eq!(
5682            calls, 0,
5683            "no upstream request may be dispatched: {rendered}"
5684        );
5685        assert!(
5686            rendered.contains("lookupKey"),
5687            "a Variable part's refusal names its identifier, which is what proves \
5688             the refusal came from LAYER 1 and not from the composed check: {rendered}"
5689        );
5690        assert!(
5691            !rendered.contains("2026AA") && !rendered.contains('?'),
5692            "the refusal must carry no byte of the value: {rendered}"
5693        );
5694    }
5695
5696    #[tokio::test]
5697    async fn layer_one_refuses_parent_traversal_in_a_variable_part() {
5698        let (result, calls) =
5699            run_with_var("lookupKey", JsonValue::String("../etc".to_string())).await;
5700        let rendered = result
5701            .expect_err("parent traversal in a ${var} part must be refused")
5702            .to_string();
5703        assert_eq!(
5704            calls, 0,
5705            "no upstream request may be dispatched: {rendered}"
5706        );
5707        assert!(rendered.contains("lookupKey"), "{rendered}");
5708    }
5709
5710    #[tokio::test]
5711    async fn layer_one_refuses_an_over_cap_variable_part() {
5712        let over = "a".repeat(crate::PLACEHOLDER_MAX_LENGTH + 1);
5713        let (result, calls) = run_with_var("lookupKey", JsonValue::String(over)).await;
5714        let rendered = result
5715            .expect_err("an over-cap ${var} part must be refused")
5716            .to_string();
5717        assert_eq!(
5718            calls, 0,
5719            "no upstream request may be dispatched: {rendered}"
5720        );
5721        assert!(rendered.contains("lookupKey"), "{rendered}");
5722    }
5723
5724    #[tokio::test]
5725    async fn layer_one_refuses_a_query_separator_in_an_expression_part() {
5726        // An Expression part has no name, and a rendering of the expression body
5727        // could itself contain caller text — so the refusal must use a FIXED
5728        // positional descriptor and never interpolate either one.
5729        let (http, seen) = RecordingHttp::new();
5730        let step = get_step(
5731            vec![
5732                PathPart::Literal("/search/".to_string()),
5733                PathPart::Expression(ValueExpr::PropertyAccess {
5734                    object: Box::new(ValueExpr::Variable("holder".to_string())),
5735                    property: "secretField".to_string(),
5736                }),
5737            ],
5738            None,
5739        );
5740        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5741        executor.set_variable(
5742            "holder",
5743            serde_json::json!({ "secretField": "2026AA?string=payload" }),
5744        );
5745        let rendered = executor
5746            .execute(&plan(vec![step]))
5747            .await
5748            .expect_err("a query separator in an ${expr} part must be refused")
5749            .to_string();
5750        assert_eq!(seen.lock().unwrap().len(), 0, "{rendered}");
5751        assert!(
5752            rendered.contains("path expression"),
5753            "an Expression part uses a fixed positional descriptor: {rendered}"
5754        );
5755        for forbidden in ["2026AA", "payload", "secretField", "holder", "?"] {
5756            assert!(
5757                !rendered.contains(forbidden),
5758                "the refusal must carry neither the evaluated value nor a rendering \
5759                 of the expression body; found {forbidden:?} in {rendered:?}"
5760            );
5761        }
5762    }
5763
5764    #[tokio::test]
5765    async fn layer_one_accepts_a_literal_only_template_including_slashes() {
5766        // Literal parts are the SCRIPT's own compiled text, not caller-substituted
5767        // data. Flooring them would refuse every legitimate multi-segment template.
5768        let (http, seen) = RecordingHttp::new();
5769        let step = get_step(literal("/Line/Mode/tube/Status"), None);
5770        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5771        executor
5772            .execute(&plan(vec![step]))
5773            .await
5774            .expect("a literal-only template with slashes must still resolve");
5775        let seen = seen.lock().unwrap();
5776        assert_eq!(seen.len(), 1);
5777        assert_eq!(seen[0].path, "/Line/Mode/tube/Status");
5778    }
5779}
5780
5781/// LAYER-2 `{key}` placeholder resolution, moved ahead of dispatch by D-09.
5782#[cfg(test)]
5783mod layer_two {
5784    use super::d09_support::{get_step, literal, plan, RecordingHttp};
5785    use super::*;
5786
5787    async fn run(
5788        path: &str,
5789        body: JsonValue,
5790    ) -> (
5791        Result<ExecutionResult, ExecutionError>,
5792        Vec<super::d09_support::Seen>,
5793    ) {
5794        let (http, seen) = RecordingHttp::new();
5795        let step = get_step(literal(path), Some(body));
5796        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5797        let result = executor.execute(&plan(vec![step])).await;
5798        let seen = seen.lock().unwrap().clone();
5799        (result, seen)
5800    }
5801
5802    /// A refused request is a `RequestRefused`, NOT a `RuntimeError`.
5803    ///
5804    /// The two have different owners: a runtime fault is the server's, a refused
5805    /// request is the caller's to fix, and a tool handler reports them on different
5806    /// channels. Until 0.7 every refusal here was a `RuntimeError`, so a model was
5807    /// told a policy refusal was an internal error. Fails if the variant reverts.
5808    #[tokio::test]
5809    async fn a_refused_placeholder_is_request_refused_not_runtime_error() {
5810        let (result, seen) =
5811            run("/search/{v}", serde_json::json!({"v": "2026AA?string=zzz"})).await;
5812        let err = result.expect_err("a query separator in a placeholder value is refused");
5813        assert!(
5814            matches!(err, ExecutionError::RequestRefused { .. }),
5815            "a placeholder-floor refusal must be RequestRefused, got: {err:?}"
5816        );
5817        assert!(seen.is_empty(), "a refused call dispatches nothing");
5818    }
5819
5820    #[tokio::test]
5821    async fn a_non_scalar_path_value_is_request_refused() {
5822        let (result, seen) = run("/x/{v}", serde_json::json!({"v": {"nested": 1}})).await;
5823        let err = result.expect_err("an object cannot be a path value");
5824        assert!(
5825            matches!(err, ExecutionError::RequestRefused { .. }),
5826            "a non-scalar path value is the script's to fix: {err:?}"
5827        );
5828        assert!(seen.is_empty());
5829    }
5830
5831    /// The variant must SURVIVE the two `api call ... failed` wrappers. They are
5832    /// where it used to be flattened into a `RuntimeError` string, so this is the
5833    /// row that makes the whole change real: a refusal raised by an embedder's
5834    /// `HttpExecutor` reaches the caller still classified as a refusal.
5835    #[tokio::test]
5836    async fn an_embedder_refusal_survives_the_api_call_wrapper() {
5837        use super::d09_support::{RefusingHttp, REFUSAL_MESSAGE};
5838        let step = get_step(literal("/anything"), None);
5839        let mut executor = PlanExecutor::new(RefusingHttp, ExecutionConfig::default());
5840        let err = executor
5841            .execute(&plan(vec![step]))
5842            .await
5843            .expect_err("the executor refuses");
5844        let ExecutionError::RequestRefused { message } = &err else {
5845            panic!("a refusal must stay a RequestRefused through the wrapper, got: {err:?}");
5846        };
5847        assert!(
5848            message.contains(REFUSAL_MESSAGE),
5849            "the embedder's own message must be carried through: {message}"
5850        );
5851        assert!(
5852            message.contains("GET api call") && message.contains("was refused"),
5853            "the wrapper adds the method and result variable: {message}"
5854        );
5855        assert!(
5856            !message.contains("/anything"),
5857            "the resolved path must NOT be re-attached (Pitfall 7 / SC-7): {message}"
5858        );
5859    }
5860
5861    /// The other half of the same contract: a genuine transport FAULT is still a
5862    /// `RuntimeError`. The variant split must not reclassify real failures as
5863    /// refusals, or every backend outage would read to a model as its own mistake.
5864    #[tokio::test]
5865    async fn a_transport_failure_is_still_a_runtime_error() {
5866        use super::d09_support::FailingHttp;
5867        let step = get_step(literal("/anything"), None);
5868        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
5869        let err = executor
5870            .execute(&plan(vec![step]))
5871            .await
5872            .expect_err("the transport fails");
5873        assert!(
5874            matches!(err, ExecutionError::RuntimeError { .. }),
5875            "a real fault must stay a RuntimeError, got: {err:?}"
5876        );
5877    }
5878
5879    #[tokio::test]
5880    async fn layer_two_substitutes_and_removes_the_consumed_key_from_the_body() {
5881        let (result, seen) = run("/users/{v}", serde_json::json!({"v": "7", "q": "keep"})).await;
5882        result.expect("a conforming placeholder must resolve");
5883        assert_eq!(seen.len(), 1);
5884        assert_eq!(seen[0].path, "/users/7");
5885        assert_eq!(
5886            seen[0].body,
5887            Some(serde_json::json!({"q": "keep"})),
5888            "the path-consumed key must not also reach the body"
5889        );
5890    }
5891
5892    /// The `# Ordering` invariant on [`resolve_layer_two_placeholders`], asserted
5893    /// end-to-end: "a value that itself contains `{`/`}` cannot manufacture a
5894    /// placeholder for a later key to fill."
5895    ///
5896    /// It did NOT hold while PASS 2 was a sequence of `String::replace` calls over a
5897    /// progressively substituted string — `a`'s literal `{b}` was expanded by the
5898    /// next iteration, composing `/p/xzzzy/q/zzz`. Nothing caught it: `{`/`}` are
5899    /// not denied bytes, so `x{b}y` passes the per-value floor, and a MANUFACTURED
5900    /// placeholder leaves no residual brace for `ResolvedPath::from_checked` to
5901    /// refuse. `apply_substitutions`' single scan over the TEMPLATE is what makes
5902    /// the claim true; this row is what keeps it true.
5903    #[tokio::test]
5904    async fn layer_two_value_cannot_manufacture_a_placeholder_for_a_later_key() {
5905        let (result, seen) = run(
5906            "/p/{a}/q/{b}",
5907            serde_json::json!({"a": "x{b}y", "b": "zzz"}),
5908        )
5909        .await;
5910        let rendered = result
5911            .expect_err("a value's literal `{b}` must never be expanded as a placeholder")
5912            .to_string();
5913        assert!(
5914            seen.is_empty(),
5915            "no upstream request may be dispatched: {rendered}"
5916        );
5917        // `a`'s `{b}` survives PASS 2 as a LITERAL, so the composed string still
5918        // carries a brace and `ResolvedPath::from_checked` refuses it as an
5919        // unsubstituted placeholder. Under the old sequential `String::replace` the
5920        // brace was CONSUMED — composing `/p/xzzzy/q/zzz`, with `b`'s value inside
5921        // `a`'s segment and nothing left for the composed check to refuse. Turning a
5922        // silent injection into a refusal is the point.
5923        assert!(
5924            !rendered.contains("zzz"),
5925            "the refusal must carry no byte of any value: {rendered}"
5926        );
5927    }
5928
5929    #[tokio::test]
5930    async fn layer_two_conforming_call_produces_exactly_one_request() {
5931        let (result, seen) = run("/users/{v}", serde_json::json!({"v": "ada"})).await;
5932        result.expect("a conforming placeholder must resolve");
5933        assert_eq!(seen.len(), 1, "exactly one upstream request");
5934        assert_eq!(seen[0].method, "GET");
5935        assert_eq!(seen[0].path, "/users/ada");
5936    }
5937
5938    #[tokio::test]
5939    async fn layer_two_refuses_a_query_separator_in_a_placeholder_value() {
5940        let payload = format!("2026AA?string={}", "z".repeat(60));
5941        let (result, seen) = run("/search/{v}", serde_json::json!({"v": payload})).await;
5942        let rendered = result
5943            .expect_err("a query separator in a {key} value must be refused")
5944            .to_string();
5945        assert!(
5946            seen.is_empty(),
5947            "no upstream request may be dispatched: {rendered}"
5948        );
5949        assert!(
5950            !rendered.contains("2026AA") && !rendered.contains('?'),
5951            "the refusal must carry no byte of the value: {rendered}"
5952        );
5953    }
5954
5955    #[tokio::test]
5956    async fn layer_two_refuses_parent_traversal_in_a_placeholder_value() {
5957        let (result, seen) = run("/files/{v}", serde_json::json!({"v": "../../etc/passwd"})).await;
5958        let rendered = result
5959            .expect_err("parent traversal in a {key} value must be refused")
5960            .to_string();
5961        assert!(seen.is_empty(), "{rendered}");
5962        assert!(!rendered.contains("passwd"), "{rendered}");
5963    }
5964
5965    #[tokio::test]
5966    async fn layer_two_refuses_an_object_valued_placeholder_naming_the_key_only() {
5967        // Preserves the WR-03 rule the toolkit's `scalar_str` used to apply, now
5968        // that resolution has moved up.
5969        let (result, seen) = run(
5970            "/users/{userId}",
5971            serde_json::json!({"userId": {"nested": [1, 2]}}),
5972        )
5973        .await;
5974        let rendered = result
5975            .expect_err("a non-scalar {key} value must be refused")
5976            .to_string();
5977        assert!(seen.is_empty(), "{rendered}");
5978        assert!(rendered.contains("userId"), "must name the key: {rendered}");
5979        for forbidden in ['{', '[', '"'] {
5980            assert!(
5981                !rendered.contains(forbidden),
5982                "must not echo JSON: {rendered}"
5983            );
5984        }
5985    }
5986
5987    #[tokio::test]
5988    async fn layer_two_refuses_an_unsubstituted_placeholder_before_dispatch() {
5989        // No body key matches `{missing}`, so the placeholder survives. It must be
5990        // refused rather than sent upstream as literal braces.
5991        let (result, seen) = run("/users/{missing}", serde_json::json!({"other": "x"})).await;
5992        let rendered = result
5993            .expect_err("an unsubstituted placeholder must be refused")
5994            .to_string();
5995        assert!(
5996            seen.is_empty(),
5997            "literal braces must never reach the wire: {rendered}"
5998        );
5999    }
6000
6001    #[tokio::test]
6002    async fn layer_two_aborts_before_any_substitution_reaches_a_request() {
6003        // Ordering: one conforming value and one refused value in the same path.
6004        // The refusal must abort the step, not dispatch a half-substituted path.
6005        let (result, seen) = run(
6006            "/a/{good}/b/{bad}",
6007            serde_json::json!({"good": "ok", "bad": "../escape"}),
6008        )
6009        .await;
6010        let rendered = result
6011            .expect_err("a refusal on any placeholder aborts the step")
6012            .to_string();
6013        assert!(seen.is_empty(), "{rendered}");
6014    }
6015}
6016
6017/// The COMPOSED path check — the only check that can see an adjacency.
6018///
6019/// Per-value checking is insufficient BY CONSTRUCTION: 180 plus 200 code points
6020/// compose to an over-cap segment and `.` plus `.` composes to a traversal, from
6021/// contributions that each pass on their own (T-128-20b).
6022#[cfg(test)]
6023mod composition {
6024    use super::d09_support::{get_step, plan, RecordingHttp};
6025    use super::*;
6026
6027    #[tokio::test]
6028    async fn composition_refuses_two_adjacent_values_over_the_cap_that_each_pass_alone() {
6029        let first = "a".repeat(180);
6030        let second = "b".repeat(200);
6031
6032        // HALF ONE — each value passes `validate_path_placeholder` in isolation.
6033        // Without this assertion the test would still pass with the composed check
6034        // deleted, because some other rule could be doing the refusing.
6035        let rules = crate::PlaceholderRules::default();
6036        crate::validate_path_placeholder("a", &first, &rules)
6037            .expect("180 code points is under the cap and must pass alone");
6038        crate::validate_path_placeholder("b", &second, &rules)
6039            .expect("200 code points is under the cap and must pass alone");
6040
6041        // HALF TWO — composed, the same two values are refused.
6042        let (http, seen) = RecordingHttp::new();
6043        let step = get_step(
6044            super::d09_support::literal("/search/{a}{b}"),
6045            Some(serde_json::json!({"a": first, "b": second})),
6046        );
6047        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6048        let rendered = executor
6049            .execute(&plan(vec![step]))
6050            .await
6051            .expect_err("380 composed code points exceed the cap and must be refused")
6052            .to_string();
6053        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6054        assert!(
6055            !rendered.contains("aaaa") && !rendered.contains("bbbb"),
6056            "the composed refusal must carry no byte of either value: {rendered}"
6057        );
6058    }
6059
6060    #[tokio::test]
6061    async fn composition_refuses_traversal_assembled_from_unchecked_literal_parts() {
6062        // The ISOLATING row. `PathPart::Literal` parts are deliberately NOT
6063        // floored, so NO per-value check runs here at all — the refusal can only
6064        // come from the composed check. This is the row that fails if
6065        // `validate_resolved_path` is removed.
6066        let (http, seen) = RecordingHttp::new();
6067        let step = get_step(
6068            vec![
6069                PathPart::Literal("/a/.".to_string()),
6070                PathPart::Literal(".".to_string()),
6071                PathPart::Literal("/b".to_string()),
6072            ],
6073            None,
6074        );
6075        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6076        let rendered = executor
6077            .execute(&plan(vec![step]))
6078            .await
6079            .expect_err("a composed `..` segment must be refused")
6080            .to_string();
6081        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6082    }
6083
6084    #[tokio::test]
6085    async fn composition_refuses_two_adjacent_single_dot_placeholders() {
6086        // The change request's row. After plan 02's single-dot floor this is
6087        // refused TWICE over — once per value, once composed — so it is the
6088        // `…_unchecked_literal_parts` sibling above that isolates the mechanism.
6089        let (http, seen) = RecordingHttp::new();
6090        let step = get_step(
6091            super::d09_support::literal("/a/{x}{y}/b"),
6092            Some(serde_json::json!({"x": ".", "y": "."})),
6093        );
6094        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6095        let rendered = executor
6096            .execute(&plan(vec![step]))
6097            .await
6098            .expect_err("`.` + `.` composes to traversal and must be refused")
6099            .to_string();
6100        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6101    }
6102
6103    #[tokio::test]
6104    async fn composition_refuses_a_mixed_layer_one_and_layer_two_over_cap_segment() {
6105        // One `${var}` part and one `{key}` placeholder adjacent in the SAME
6106        // segment. Neither layer alone can see this composition.
6107        let first = "a".repeat(180);
6108        let second = "b".repeat(200);
6109        let rules = crate::PlaceholderRules::default();
6110        crate::validate_path_placeholder("lookupKey", &first, &rules)
6111            .expect("the layer-1 contribution passes alone");
6112        crate::validate_path_placeholder("b", &second, &rules)
6113            .expect("the layer-2 contribution passes alone");
6114
6115        let (http, seen) = RecordingHttp::new();
6116        let step = get_step(
6117            vec![
6118                PathPart::Literal("/search/".to_string()),
6119                PathPart::Variable("lookupKey".to_string()),
6120                PathPart::Literal("{b}".to_string()),
6121            ],
6122            Some(serde_json::json!({"b": second})),
6123        );
6124        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6125        executor.set_variable("lookupKey", JsonValue::String(first));
6126        let rendered = executor
6127            .execute(&plan(vec![step]))
6128            .await
6129            .expect_err("a mixed-layer composed segment over the cap must be refused")
6130            .to_string();
6131        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6132    }
6133}
6134
6135/// RESEARCH Pitfall 7 / SC-7 — no refusal reaching a client carries the path.
6136///
6137/// The module is named for the invariant so the plan's
6138/// `executor::error_does_not_echo_path` verify selection resolves.
6139#[cfg(test)]
6140mod error_does_not_echo_path {
6141    use super::d09_support::{get_step, literal, plan, FailingHttp};
6142    use super::*;
6143
6144    #[tokio::test]
6145    async fn error_does_not_echo_path_when_the_executor_itself_fails() {
6146        let step = get_step(literal("/secret/inventory/endpoint"), None);
6147        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
6148        let rendered = executor
6149            .execute(&plan(vec![step]))
6150            .await
6151            .expect_err("the failing executor must surface an error")
6152            .to_string();
6153        assert!(
6154            rendered.contains("GET"),
6155            "the wrap must still name the method: {rendered}"
6156        );
6157        assert!(
6158            rendered.contains("simulated transport failure"),
6159            "the wrap must still carry the underlying cause: {rendered}"
6160        );
6161        assert!(
6162            !rendered.contains("/secret/inventory/endpoint"),
6163            "the wrap must NOT re-attach the resolved path: {rendered}"
6164        );
6165    }
6166
6167    #[tokio::test]
6168    async fn error_does_not_echo_path_in_the_parallel_arm() {
6169        let step = PlanStep::ParallelApiCalls {
6170            result_var: "out".to_string(),
6171            calls: vec![(
6172                "t0".to_string(),
6173                "GET".to_string(),
6174                PathTemplate {
6175                    parts: literal("/secret/inventory/endpoint"),
6176                },
6177                None,
6178            )],
6179        };
6180        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
6181        let rendered = executor
6182            .execute(&plan(vec![step]))
6183            .await
6184            .expect_err("the failing executor must surface an error")
6185            .to_string();
6186        assert!(rendered.contains("GET"), "{rendered}");
6187        assert!(
6188            !rendered.contains("/secret/inventory/endpoint"),
6189            "the parallel arm's wrap must NOT re-attach the resolved path: {rendered}"
6190        );
6191    }
6192
6193    #[tokio::test]
6194    async fn error_does_not_echo_path_on_a_refused_placeholder() {
6195        let (http, seen) = super::d09_support::RecordingHttp::new();
6196        let step = get_step(
6197            literal("/inventory/{sku}"),
6198            Some(serde_json::json!({"sku": "../../etc/shadow"})),
6199        );
6200        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6201        let rendered = executor
6202            .execute(&plan(vec![step]))
6203            .await
6204            .expect_err("a refused placeholder must error")
6205            .to_string();
6206        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6207        for forbidden in ["shadow", "/inventory/", ".."] {
6208            assert!(
6209                !rendered.contains(forbidden),
6210                "a refusal carries neither the value nor the resolved path; \
6211                 found {forbidden:?} in {rendered:?}"
6212            );
6213        }
6214    }
6215}
6216
6217/// The `?` narrowing, pinned in BOTH directions so it cannot become a hole.
6218///
6219/// `ResolvedPath::from_checked` calls core's `validate_resolved_target`, which
6220/// splits at the first `?` and applies the full rule set to each side, exempting
6221/// exactly one author-written query separator.
6222/// These rows assert what that buys AND everything it does not relax. A narrowing
6223/// with only accept-rows is indistinguishable from a deleted check.
6224#[cfg(test)]
6225mod query_separator {
6226    use super::d09_support::{get_step, literal, plan, RecordingHttp};
6227    use super::*;
6228
6229    async fn run(
6230        parts: Vec<PathPart>,
6231        body: Option<JsonValue>,
6232    ) -> (
6233        Result<ExecutionResult, ExecutionError>,
6234        Vec<super::d09_support::Seen>,
6235    ) {
6236        let (http, seen) = RecordingHttp::new();
6237        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6238        let result = executor.execute(&plan(vec![get_step(parts, body)])).await;
6239        let seen = seen.lock().unwrap().clone();
6240        (result, seen)
6241    }
6242
6243    // ---- ACCEPTED: the separator an author wrote into a literal ----
6244
6245    #[tokio::test]
6246    async fn query_separator_accepts_an_author_written_query_string() {
6247        // The row the operator's narrowing exists for: a `?` in the script's own
6248        // literal path text reaches the wire instead of being refused.
6249        let (result, seen) = run(literal("/Line/Mode/tube/Status?detail=true"), None).await;
6250        result.expect("an author-written query string must be accepted");
6251        assert_eq!(seen.len(), 1);
6252        assert_eq!(seen[0].path, "/Line/Mode/tube/Status?detail=true");
6253    }
6254
6255    #[tokio::test]
6256    async fn query_separator_accepts_a_literal_query_alongside_a_floored_placeholder() {
6257        // The separator is author-written; the `{v}` value still goes through the
6258        // per-value floor. Both mechanisms coexist on one path.
6259        let (result, seen) = run(
6260            literal("/content/{version}/CUI?string=headache"),
6261            Some(serde_json::json!({"version": "current"})),
6262        )
6263        .await;
6264        result.expect("an author query plus a conforming placeholder must be accepted");
6265        assert_eq!(seen.len(), 1);
6266        assert_eq!(seen[0].path, "/content/current/CUI?string=headache");
6267    }
6268
6269    #[tokio::test]
6270    async fn query_separator_accepts_a_graph_style_dollar_projection() {
6271        // The exact shape the in-tree Contoso M365 scripts author.
6272        let (result, seen) = run(
6273            literal("/drives/D/items/I/workbook/worksheets/Customers/range(address='A2:D7')?$select=values"),
6274            None,
6275        )
6276        .await;
6277        result.expect("a Graph $select projection in the path must be accepted");
6278        assert_eq!(seen.len(), 1);
6279        assert!(
6280            seen[0].path.ends_with("?$select=values"),
6281            "{:?}",
6282            seen[0].path
6283        );
6284    }
6285
6286    // ---- STILL REFUSED: everything the split does not relax ----
6287
6288    #[tokio::test]
6289    async fn query_separator_still_refuses_traversal_in_the_path_portion() {
6290        // `/a/../b?x=1` — the traversal rule applies to the whole string, so
6291        // appending a query does NOT launder a traversal past the composed check.
6292        let (result, seen) = run(literal("/a/../b?x=1"), None).await;
6293        let rendered = result
6294            .expect_err("traversal must still be refused when a query follows it")
6295            .to_string();
6296        assert!(seen.is_empty(), "{rendered}");
6297    }
6298
6299    #[tokio::test]
6300    async fn query_separator_still_refuses_traversal_in_the_query_portion() {
6301        let (result, seen) = run(literal("/search?next=../../etc/passwd"), None).await;
6302        let rendered = result
6303            .expect_err("traversal inside the query portion must still be refused")
6304            .to_string();
6305        assert!(seen.is_empty(), "{rendered}");
6306        assert!(!rendered.contains("passwd"), "{rendered}");
6307    }
6308
6309    #[tokio::test]
6310    async fn query_separator_still_refuses_a_control_byte_in_the_query_portion() {
6311        // Percent-encoded NUL, so the decode-once pass is what has to catch it.
6312        let (result, seen) = run(literal("/search?x=a%00b"), None).await;
6313        assert!(
6314            result.is_err(),
6315            "a control byte in the query portion must still be refused"
6316        );
6317        assert!(seen.is_empty());
6318    }
6319
6320    #[tokio::test]
6321    async fn query_separator_still_refuses_an_over_cap_query_portion() {
6322        let long = "z".repeat(crate::PLACEHOLDER_MAX_LENGTH + 1);
6323        let (result, seen) = run(literal(&format!("/search?q={long}")), None).await;
6324        assert!(
6325            result.is_err(),
6326            "an over-cap query portion must still be refused"
6327        );
6328        assert!(seen.is_empty());
6329    }
6330
6331    #[tokio::test]
6332    async fn query_separator_still_refuses_a_second_question_mark() {
6333        // Only the FIRST `?` is split off, so the query portion faces the
6334        // unmodified rule — which denies `?`. One exemption, not a general licence.
6335        let (result, seen) = run(literal("/search?a=1?b=2"), None).await;
6336        assert!(result.is_err(), "a second `?` must still be refused");
6337        assert!(seen.is_empty());
6338    }
6339
6340    #[tokio::test]
6341    async fn query_separator_still_refuses_an_empty_query_portion() {
6342        // A dangling `/x?` is a trailing separator — the same class as a trailing
6343        // `/`, which plan 02 refuses deliberately.
6344        let (result, seen) = run(literal("/search?"), None).await;
6345        assert!(result.is_err(), "a dangling `?` must still be refused");
6346        assert!(seen.is_empty());
6347    }
6348
6349    #[tokio::test]
6350    async fn query_separator_still_refuses_a_fragment_marker() {
6351        // The split is `?`-only. `#` is never sent to a server and stays denied.
6352        let (result, seen) = run(literal("/search#frag"), None).await;
6353        assert!(result.is_err(), "a fragment marker must still be refused");
6354        assert!(seen.is_empty());
6355    }
6356
6357    #[tokio::test]
6358    async fn query_separator_still_refuses_an_injected_separator_from_a_value() {
6359        // THE row that proves the narrowing is not a hole. The template carries an
6360        // author-written `?` (now legal) AND a placeholder value carries an
6361        // injected one (still refused, by the per-value floor — which is the
6362        // mechanism the narrowing relies on for its safety argument).
6363        let payload = format!("2026AA?string={}", "z".repeat(60));
6364        let (result, seen) = run(
6365            literal("/search/{v}?detail=true"),
6366            Some(serde_json::json!({"v": payload})),
6367        )
6368        .await;
6369        let rendered = result
6370            .expect_err("an injected `?` in a VALUE must still be refused")
6371            .to_string();
6372        assert!(seen.is_empty(), "{rendered}");
6373        assert!(
6374            !rendered.contains("2026AA") && !rendered.contains('?'),
6375            "the refusal must still carry no byte of the value: {rendered}"
6376        );
6377    }
6378
6379    #[tokio::test]
6380    async fn query_separator_still_refuses_an_injected_separator_from_a_layer_one_variable() {
6381        // Same boundary, layer-1 route: `${v}` rather than `{v}`.
6382        let (http, seen) = RecordingHttp::new();
6383        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6384        executor.set_variable(
6385            "lookupKey",
6386            JsonValue::String("2026AA?string=x".to_string()),
6387        );
6388        let rendered = executor
6389            .execute(&plan(vec![get_step(
6390                vec![
6391                    PathPart::Literal("/search/".to_string()),
6392                    PathPart::Variable("lookupKey".to_string()),
6393                    PathPart::Literal("?detail=true".to_string()),
6394                ],
6395                None,
6396            )]))
6397            .await
6398            .expect_err("an injected `?` in a ${var} part must still be refused")
6399            .to_string();
6400        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6401        assert!(rendered.contains("lookupKey"), "{rendered}");
6402    }
6403}