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