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::RequestRefused {
2918        message: refusal.to_string(),
2919    }
2920}
2921
2922/// Convert an error from `HttpExecutor::execute_request` into what the plan
2923/// executor reports, adding the method and result variable.
2924///
2925/// A [`ExecutionError::RequestRefused`] STAYS a `RequestRefused`. Every other
2926/// error becomes a `RuntimeError`, as before. Flattening a refusal into a
2927/// `RuntimeError` string here is what made a refusal indistinguishable from a
2928/// fault at the tool boundary: this is the one place the variant used to be lost.
2929///
2930/// The resolved path is deliberately NOT formatted in (RESEARCH Pitfall 7 /
2931/// SC-7): a refusal is value-free where it is raised, and re-attaching the path
2932/// here is what would deliver the exact injected path to the client.
2933fn api_call_error(method: &str, result_var: &str, error: ExecutionError) -> ExecutionError {
2934    match error {
2935        ExecutionError::RequestRefused { message } => ExecutionError::RequestRefused {
2936            message: format!("{method} api call '{result_var}' was refused: {message}"),
2937        },
2938        other => ExecutionError::RuntimeError {
2939            message: format!("{method} api call '{result_var}' failed: {other}"),
2940        },
2941    }
2942}
2943
2944/// Apply the LAYER-1 floor to one rendered `${var}` / `${expr}` contribution.
2945///
2946/// `PlaceholderRules::default()` and deliberately NOT
2947/// [`HttpExecutor::placeholder_rules`]: a layer-1 part is not a spec-declared
2948/// path parameter. It can appear anywhere in the template, including mid-segment,
2949/// so there is no OpenAPI `Parameter` to narrow from — the unconditional
2950/// character floor plus the always-on 256-code-point cap is exactly the right
2951/// level here. A reader who expects a `placeholder_rules` consultation at this
2952/// layer is looking for something that has no well-defined answer.
2953fn floor_layer_one_contribution(param: &str, rendered: &str) -> Result<(), ExecutionError> {
2954    crate::validate_path_placeholder(param, rendered, &crate::PlaceholderRules::default())
2955        .map_err(refusal_to_execution_error)
2956}
2957
2958/// Render a JSON scalar for a LAYER-2 `{key}` substitution, REJECTING non-scalars
2959/// (WR-03 / GAP 4).
2960///
2961/// Moved up from `pmcp-server-toolkit`'s `HttpCodeExecutor::scalar_str` by D-09,
2962/// with the rule preserved byte for byte: a scalar (`String`, `Number`, `Bool`,
2963/// `Null`) renders to a bare string (`Null` -> `"null"`, preserving prior
2964/// behaviour); an `Object` or `Array` is rejected rather than silently
2965/// JSON-stringified into the URL.
2966///
2967/// # Errors
2968///
2969/// Returns [`ExecutionError::RequestRefused`] naming `key`: a non-scalar path or
2970/// query value is the script's to fix. Per Pitfall 5 the message names the KEY
2971/// only — never the value.
2972fn render_path_scalar(key: &str, value: JsonValue) -> Result<String, ExecutionError> {
2973    match value {
2974        JsonValue::String(s) => Ok(s),
2975        JsonValue::Null => Ok("null".to_string()),
2976        JsonValue::Number(n) => Ok(n.to_string()),
2977        JsonValue::Bool(b) => Ok(b.to_string()),
2978        JsonValue::Object(_) | JsonValue::Array(_) => Err(ExecutionError::RequestRefused {
2979            message: format!("path/query param '{key}' must be a scalar"),
2980        }),
2981    }
2982}
2983
2984/// Apply PASS 1's `substitutions` to `template` in ONE left-to-right scan.
2985///
2986/// Deliberately NOT a sequence of `String::replace` calls over a progressively
2987/// substituted string. That form re-scans text a PREVIOUS value contributed, so a
2988/// value holding a literal `{other_key}` manufactures a placeholder for a later
2989/// key to fill — which is exactly the invariant
2990/// [`resolve_layer_two_placeholders`] documents and, before this scan existed, did
2991/// not hold. Measured on the sequential form: template `/p/{a}/q/{b}` with body
2992/// `{"a": "x{b}y", "b": "zzz"}` composed to `/p/xzzzy/q/zzz`, putting `b`'s value
2993/// inside `a`'s segment. `ResolvedPath::from_checked` could not catch it: `{`/`}`
2994/// are not on `denied_byte`'s list, so `x{b}y` passes the per-value floor, and a
2995/// MANUFACTURED placeholder leaves no residual brace behind for the composed check
2996/// to refuse. Scanning the template once means a substituted value is never
2997/// re-examined: the `{b}` stays a LITERAL, so the composed string still carries a
2998/// brace and `ResolvedPath::from_checked` refuses it as an unsubstituted
2999/// placeholder. A silent injection became a refusal, which is the point. Pinned by
3000/// `layer_two::layer_two_value_cannot_manufacture_a_placeholder_for_a_later_key`.
3001///
3002/// The longest matching placeholder wins at any position, so a key whose `{key}`
3003/// token is a prefix of another's cannot shadow it.
3004fn apply_substitutions(template: &str, substitutions: &[(String, String)]) -> String {
3005    let mut order: Vec<(&str, &str)> = substitutions
3006        .iter()
3007        .map(|(placeholder, rendered)| (placeholder.as_str(), rendered.as_str()))
3008        .collect();
3009    order.sort_by_key(|(placeholder, _)| std::cmp::Reverse(placeholder.len()));
3010
3011    let mut resolved = String::with_capacity(template.len());
3012    let mut rest = template;
3013    while let Some(ch) = rest.chars().next() {
3014        match order.iter().copied().find(|(p, _)| rest.starts_with(*p)) {
3015            Some((placeholder, rendered)) => {
3016                resolved.push_str(rendered);
3017                rest = &rest[placeholder.len()..];
3018            },
3019            None => {
3020                resolved.push(ch);
3021                rest = &rest[ch.len_utf8()..];
3022            },
3023        }
3024    }
3025    resolved
3026}
3027
3028/// LAYER-2 `{key}` placeholder resolution, moved ahead of
3029/// [`HttpExecutor::execute_request`] by Phase 128 D-09.
3030///
3031/// Returns the substituted path plus the body with the path-consumed keys
3032/// removed. An implementor used to do this inside its own impl, which is what
3033/// made the public trait a blind seam (T-128-20).
3034///
3035/// # Ordering
3036///
3037/// Two passes on purpose. Pass one renders and CHECKS every contribution; pass
3038/// two applies them. So a refusal on any one placeholder aborts with no
3039/// substitution having been applied at all, rather than leaving a
3040/// half-substituted path one `?` away from being dispatched. Pass one also tests
3041/// containment against the ORIGINAL template rather than a progressively
3042/// substituted copy, so a value that itself contains `{`/`}` cannot manufacture a
3043/// placeholder for a later key to fill. Body iteration order is
3044/// `serde_json::Map`'s, which is deterministic (insertion order under this
3045/// workspace's `preserve_order`, key order otherwise).
3046///
3047/// # Errors
3048///
3049/// Returns [`ExecutionError::RuntimeError`] on a non-scalar value or a
3050/// [`PlaceholderRefusal`](crate::PlaceholderRefusal). Both messages are
3051/// value-free.
3052fn resolve_layer_two_placeholders<H: HttpExecutor + ?Sized>(
3053    http: &H,
3054    method: &str,
3055    template: &str,
3056    body: Option<JsonValue>,
3057) -> Result<(String, Option<JsonValue>), ExecutionError> {
3058    // Destructured BY VALUE: this function owns `body`, so a non-placeholder entry
3059    // can be MOVED into `remaining` rather than deep-cloned. Cloning here copied
3060    // essentially the whole request payload — every nested object and array — on
3061    // every Code Mode HTTP call.
3062    let obj = match body {
3063        Some(JsonValue::Object(obj)) => obj,
3064        other => return Ok((template.to_string(), other)),
3065    };
3066
3067    // PASS 1 — render + check, mutating nothing.
3068    let mut substitutions: Vec<(String, String)> = Vec::new();
3069    let mut remaining = serde_json::Map::new();
3070    // One reusable buffer for the containment test. Most body keys are NOT path
3071    // placeholders — the `else` arm is the common one — so building a fresh
3072    // `format!("{{{key}}}")` per key allocated once for every key in every request
3073    // body and threw most of them away. The buffer is cloned only on a match.
3074    let mut probe = String::new();
3075    for (key, value) in obj {
3076        probe.clear();
3077        probe.push('{');
3078        probe.push_str(&key);
3079        probe.push('}');
3080        if template.contains(probe.as_str()) {
3081            let rules = http.placeholder_rules(method, template, &key);
3082            // `value` is owned and dropped right here, so render by value rather
3083            // than cloning the `String` out of a `JsonValue::String`.
3084            let rendered = render_path_scalar(&key, value)?;
3085            crate::validate_path_placeholder(&key, &rendered, &rules)
3086                .map_err(refusal_to_execution_error)?;
3087            substitutions.push((probe.clone(), rendered));
3088        } else {
3089            remaining.insert(key, value);
3090        }
3091    }
3092
3093    // PASS 2 — apply, in ONE left-to-right scan over the TEMPLATE.
3094    let resolved = apply_substitutions(template, &substitutions);
3095
3096    let remaining = if remaining.is_empty() {
3097        None
3098    } else {
3099        Some(JsonValue::Object(remaining))
3100    };
3101    Ok((resolved, remaining))
3102}
3103
3104/// Executes a compiled execution plan.
3105pub struct PlanExecutor<H: HttpExecutor> {
3106    http: H,
3107    config: ExecutionConfig,
3108    variables: HashMap<String, JsonValue>,
3109    api_calls: Vec<ApiCallLog>,
3110    api_call_count: usize,
3111    #[cfg(feature = "mcp-code-mode")]
3112    mcp: Option<Box<dyn McpExecutor>>,
3113    /// Optional SDK executor for SDK-backed servers (e.g., aws-billing).
3114    sdk: Option<Box<dyn SdkExecutor>>,
3115}
3116
3117impl<H: HttpExecutor> PlanExecutor<H> {
3118    /// Create a new executor with the given HTTP client.
3119    pub fn new(http: H, config: ExecutionConfig) -> Self {
3120        Self {
3121            http,
3122            config,
3123            variables: HashMap::new(),
3124            api_calls: Vec::new(),
3125            api_call_count: 0,
3126            #[cfg(feature = "mcp-code-mode")]
3127            mcp: None,
3128            sdk: None,
3129        }
3130    }
3131
3132    /// Set the MCP executor for foundation server calls.
3133    #[cfg(feature = "mcp-code-mode")]
3134    pub fn set_mcp_executor(&mut self, executor: impl McpExecutor + 'static) {
3135        self.mcp = Some(Box::new(executor));
3136    }
3137
3138    /// Set the SDK executor for SDK-backed servers.
3139    pub fn set_sdk_executor(&mut self, executor: impl SdkExecutor + 'static) {
3140        self.sdk = Some(Box::new(executor));
3141    }
3142
3143    /// Pre-bind a variable before execution (e.g., `args` for script tools).
3144    pub fn set_variable(&mut self, name: impl Into<String>, value: JsonValue) {
3145        self.variables.insert(name.into(), value);
3146    }
3147
3148    /// Execute a plan and return the result.
3149    pub async fn execute(
3150        &mut self,
3151        plan: &ExecutionPlan,
3152    ) -> Result<ExecutionResult, ExecutionError> {
3153        let start = std::time::Instant::now();
3154
3155        let mut return_value = JsonValue::Null;
3156
3157        for step in &plan.steps {
3158            match self.execute_step(step).await? {
3159                StepOutcome::Return(value) => {
3160                    return_value = value;
3161                    break; // Early return — stop executing further steps
3162                },
3163                StepOutcome::None | StepOutcome::Continue | StepOutcome::Break => {},
3164            }
3165        }
3166
3167        // Validate output against output blocklist.
3168        // These are fields that can be used internally but cannot be returned.
3169        let blocked_in_output =
3170            find_blocked_fields_in_output(&return_value, &self.config.output_blocked_fields);
3171
3172        if !blocked_in_output.is_empty() {
3173            return Err(ExecutionError::RuntimeError {
3174                message: format!(
3175                    "Script output contains blocked fields: {}",
3176                    blocked_in_output.join(", ")
3177                ),
3178            });
3179        }
3180
3181        Ok(ExecutionResult {
3182            value: return_value,
3183            api_calls: std::mem::take(&mut self.api_calls),
3184            execution_time_ms: start.elapsed().as_millis() as u64,
3185        })
3186    }
3187
3188    /// Execute a single step, returning a `StepOutcome` for control flow.
3189    /// Uses Box::pin for recursive calls to avoid infinite future size.
3190    fn execute_step<'a>(
3191        &'a mut self,
3192        step: &'a PlanStep,
3193    ) -> std::pin::Pin<
3194        Box<dyn std::future::Future<Output = Result<StepOutcome, ExecutionError>> + Send + 'a>,
3195    > {
3196        Box::pin(async move {
3197            match step {
3198                PlanStep::ApiCall {
3199                    result_var,
3200                    method,
3201                    path,
3202                    body,
3203                } => {
3204                    self.api_call_count += 1;
3205                    if self.api_call_count > self.config.max_api_calls {
3206                        return Err(ExecutionError::RuntimeError {
3207                            message: format!(
3208                                "Too many API calls: {} (max: {})",
3209                                self.api_call_count, self.config.max_api_calls
3210                            ),
3211                        });
3212                    }
3213
3214                    // LAYER 1 — `${var}` / `${expr}` interpolation, each
3215                    // contribution floored inside `resolve_path` (FORK 2).
3216                    let templated_path = self.resolve_path(path)?;
3217                    let evaluated_body = match body {
3218                        Some(expr) => Some(self.evaluate(expr)?),
3219                        None => None,
3220                    };
3221                    // LAYER 2 — `{key}` resolution, moved ahead of dispatch (D-09).
3222                    let (resolved_path, resolved_body) = resolve_layer_two_placeholders(
3223                        &self.http,
3224                        method,
3225                        &templated_path,
3226                        evaluated_body,
3227                    )?;
3228                    // THE COMPOSED CHECK — the only check that can see an
3229                    // adjacency (T-128-20b). `.` + `.` composes to a traversal and
3230                    // 180 + 200 code points compose over the cap, from values that
3231                    // each passed both per-value checks above; a residual `{`/`}`
3232                    // from an unsubstituted placeholder is refused here too.
3233                    let checked_path = ResolvedPath::from_checked(&resolved_path)
3234                        .map_err(refusal_to_execution_error)?;
3235
3236                    let call_start = std::time::Instant::now();
3237                    let raw_response = self
3238                        .http
3239                        .execute_request(method, checked_path, resolved_body.clone())
3240                        .await
3241                        .map_err(|e| api_call_error(method, &result_var, e))?;
3242                    let duration_ms = call_start.elapsed().as_millis() as u64;
3243
3244                    // Filter blocked fields from API response before scripts can access them.
3245                    // This implements the "internal blocklist" - fields that are never accessible.
3246                    let response = filter_blocked_fields(raw_response, &self.config.blocked_fields);
3247
3248                    self.api_calls.push(ApiCallLog {
3249                        method: method.clone(),
3250                        path: resolved_path,
3251                        body: resolved_body,
3252                        response: response.clone(),
3253                        duration_ms,
3254                    });
3255
3256                    if result_var != "_" {
3257                        self.variables.insert(result_var.clone(), response);
3258                    }
3259                    Ok(StepOutcome::None)
3260                },
3261
3262                PlanStep::Assign { var, expr } => {
3263                    let value = self.evaluate(expr)?;
3264                    self.variables.insert(var.clone(), value);
3265                    Ok(StepOutcome::None)
3266                },
3267
3268                PlanStep::Conditional {
3269                    condition,
3270                    then_steps,
3271                    else_steps,
3272                } => {
3273                    let cond_value = self.evaluate(condition)?;
3274                    let steps = if shared_is_truthy(&cond_value) {
3275                        then_steps
3276                    } else {
3277                        else_steps
3278                    };
3279
3280                    for step in steps {
3281                        match self.execute_step(step).await? {
3282                            StepOutcome::None => {},
3283                            outcome => return Ok(outcome),
3284                        }
3285                    }
3286                    Ok(StepOutcome::None)
3287                },
3288
3289                PlanStep::BoundedLoop {
3290                    item_var,
3291                    collection,
3292                    max_iterations,
3293                    body,
3294                } => {
3295                    let collection_value = self.evaluate(collection)?;
3296                    let items = match collection_value {
3297                        JsonValue::Array(arr) => arr,
3298                        _ => {
3299                            return Err(ExecutionError::RuntimeError {
3300                                message: "Loop collection must be an array".into(),
3301                            })
3302                        },
3303                    };
3304
3305                    let limit = (*max_iterations).min(self.config.max_loop_iterations);
3306                    'outer: for item in items.into_iter().take(limit) {
3307                        self.variables.insert(item_var.clone(), item);
3308
3309                        for step in body {
3310                            match self.execute_step(step).await? {
3311                                StepOutcome::Return(value) => {
3312                                    return Ok(StepOutcome::Return(value))
3313                                },
3314                                StepOutcome::None => {},
3315                                StepOutcome::Continue => continue 'outer,
3316                                StepOutcome::Break => break 'outer,
3317                            }
3318                        }
3319                    }
3320                    Ok(StepOutcome::None)
3321                },
3322
3323                PlanStep::Return { value } => {
3324                    let result = self.evaluate(value)?;
3325                    Ok(StepOutcome::Return(result))
3326                },
3327
3328                PlanStep::TryCatch {
3329                    try_steps,
3330                    catch_var,
3331                    catch_steps,
3332                    finally_steps,
3333                } => {
3334                    // Execute try block
3335                    let try_result = async {
3336                        for step in try_steps {
3337                            match self.execute_step(step).await? {
3338                                StepOutcome::None => {},
3339                                outcome => return Ok::<StepOutcome, ExecutionError>(outcome),
3340                            }
3341                        }
3342                        Ok(StepOutcome::None)
3343                    }
3344                    .await;
3345
3346                    // If try succeeded, just run finally
3347                    let result = match try_result {
3348                        Ok(outcome) => {
3349                            // Try block succeeded
3350                            outcome
3351                        },
3352                        Err(error) => {
3353                            // Try block failed, run catch
3354                            if let Some(var) = catch_var {
3355                                // Store the error in the catch variable
3356                                let error_obj = JsonValue::Object(serde_json::Map::from_iter([(
3357                                    "message".to_string(),
3358                                    JsonValue::String(format!("{}", error)),
3359                                )]));
3360                                self.variables.insert(var.clone(), error_obj);
3361                            }
3362
3363                            // Execute catch block
3364                            let mut catch_outcome = StepOutcome::None;
3365                            for step in catch_steps {
3366                                match self.execute_step(step).await? {
3367                                    StepOutcome::None => {},
3368                                    outcome => {
3369                                        catch_outcome = outcome;
3370                                        break;
3371                                    },
3372                                }
3373                            }
3374                            catch_outcome
3375                        },
3376                    };
3377
3378                    // Execute finally block (always runs)
3379                    for step in finally_steps {
3380                        match self.execute_step(step).await? {
3381                            StepOutcome::None => {},
3382                            outcome => return Ok(outcome),
3383                        }
3384                    }
3385
3386                    Ok(result)
3387                },
3388
3389                // Parallel API calls: await Promise.all([api.get(...), ...])
3390                // Executed sequentially (true parallelism isn't needed for correctness),
3391                // results collected into an array assigned to result_var.
3392                PlanStep::ParallelApiCalls { result_var, calls } => {
3393                    let mut results = Vec::with_capacity(calls.len());
3394                    for (temp_var, method, path, body) in calls {
3395                        self.api_call_count += 1;
3396                        if self.api_call_count > self.config.max_api_calls {
3397                            return Err(ExecutionError::RuntimeError {
3398                                message: format!(
3399                                    "Maximum API calls exceeded ({})",
3400                                    self.config.max_api_calls
3401                                ),
3402                            });
3403                        }
3404
3405                        // LAYER 1, then LAYER 2, then the COMPOSED check — the same
3406                        // three steps as the single `ApiCall` arm. See that arm's
3407                        // comments; both arms must carry all three or the class is
3408                        // closed on only one of them.
3409                        let templated_path = self.resolve_path(path)?;
3410                        let evaluated_body = body.as_ref().map(|b| self.evaluate(b)).transpose()?;
3411                        let (resolved_path, resolved_body) = resolve_layer_two_placeholders(
3412                            &self.http,
3413                            method,
3414                            &templated_path,
3415                            evaluated_body,
3416                        )?;
3417                        let checked_path = ResolvedPath::from_checked(&resolved_path)
3418                            .map_err(refusal_to_execution_error)?;
3419                        let call_start = std::time::Instant::now();
3420                        let raw_response = self
3421                            .http
3422                            .execute_request(method, checked_path, resolved_body.clone())
3423                            .await
3424                            .map_err(|e| api_call_error(method, &temp_var, e))?;
3425                        let duration_ms = call_start.elapsed().as_millis() as u64;
3426                        let response =
3427                            filter_blocked_fields(raw_response, &self.config.blocked_fields);
3428
3429                        self.api_calls.push(ApiCallLog {
3430                            method: method.clone(),
3431                            path: resolved_path,
3432                            body: resolved_body,
3433                            response: response.clone(),
3434                            duration_ms,
3435                        });
3436
3437                        results.push(response);
3438                    }
3439                    self.variables
3440                        .insert(result_var.clone(), JsonValue::Array(results));
3441                    Ok(StepOutcome::None)
3442                },
3443
3444                // Continue: signal to skip to next loop iteration
3445                PlanStep::Continue => Ok(StepOutcome::Continue),
3446
3447                // Break: signal to exit the current loop
3448                PlanStep::Break => Ok(StepOutcome::Break),
3449
3450                // MCP tool call: await mcp.call('server', 'tool', { args })
3451                #[cfg(feature = "mcp-code-mode")]
3452                PlanStep::McpCall {
3453                    result_var,
3454                    server_id,
3455                    tool_name,
3456                    args,
3457                } => {
3458                    self.api_call_count += 1;
3459                    if self.api_call_count > self.config.max_api_calls {
3460                        return Err(ExecutionError::RuntimeError {
3461                            message: format!(
3462                                "Too many calls: {} (max: {})",
3463                                self.api_call_count, self.config.max_api_calls
3464                            ),
3465                        });
3466                    }
3467
3468                    let resolved_args = match args {
3469                        Some(expr) => self.evaluate(expr)?,
3470                        None => JsonValue::Object(Default::default()),
3471                    };
3472
3473                    let mcp_executor =
3474                        self.mcp
3475                            .as_ref()
3476                            .ok_or_else(|| ExecutionError::RuntimeError {
3477                                message: "MCP executor not configured".into(),
3478                            })?;
3479
3480                    let call_start = std::time::Instant::now();
3481                    let result = mcp_executor
3482                        .call_tool(server_id, tool_name, resolved_args.clone())
3483                        .await?;
3484                    let duration_ms = call_start.elapsed().as_millis() as u64;
3485
3486                    self.api_calls.push(ApiCallLog {
3487                        method: format!("MCP:{}.{}", server_id, tool_name),
3488                        path: format!("{}/{}", server_id, tool_name),
3489                        body: Some(resolved_args),
3490                        response: result.clone(),
3491                        duration_ms,
3492                    });
3493
3494                    if result_var != "_" {
3495                        self.variables.insert(result_var.clone(), result);
3496                    }
3497                    Ok(StepOutcome::None)
3498                },
3499
3500                // SDK call: await api.getCostAndUsage({ ... })
3501                PlanStep::SdkCall {
3502                    result_var,
3503                    operation,
3504                    args,
3505                } => {
3506                    self.api_call_count += 1;
3507                    if self.api_call_count > self.config.max_api_calls {
3508                        return Err(ExecutionError::RuntimeError {
3509                            message: format!(
3510                                "Too many calls: {} (max: {})",
3511                                self.api_call_count, self.config.max_api_calls
3512                            ),
3513                        });
3514                    }
3515
3516                    let resolved_args =
3517                        args.as_ref().map(|expr| self.evaluate(expr)).transpose()?;
3518
3519                    let sdk_executor =
3520                        self.sdk
3521                            .as_ref()
3522                            .ok_or_else(|| ExecutionError::RuntimeError {
3523                                message: "SDK executor not configured".into(),
3524                            })?;
3525
3526                    let call_start = std::time::Instant::now();
3527                    let result = sdk_executor
3528                        .execute_operation(operation, resolved_args.clone())
3529                        .await?;
3530                    let duration_ms = call_start.elapsed().as_millis() as u64;
3531
3532                    self.api_calls.push(ApiCallLog {
3533                        method: operation.clone(),
3534                        path: format!("sdk:{}", operation),
3535                        body: resolved_args,
3536                        response: result.clone(),
3537                        duration_ms,
3538                    });
3539
3540                    if result_var != "_" {
3541                        self.variables.insert(result_var.clone(), result);
3542                    }
3543                    Ok(StepOutcome::None)
3544                },
3545            }
3546        })
3547    }
3548
3549    /// Resolve a path template to a concrete path string — LAYER 1.
3550    ///
3551    /// # The FORK-2 floor (Phase 128, T-128-20a)
3552    ///
3553    /// Every DYNAMIC contribution is passed through
3554    /// [`validate_path_placeholder`](crate::validate_path_placeholder) before it is
3555    /// pushed, and the first refusal returns so nothing reaches `result`. This arm
3556    /// used to push the stringified value straight in, which meant a script
3557    /// writing `` api.get(`/search/${v}`) `` never touched a `{key}` placeholder,
3558    /// never entered layer 2, and was never floored at all. Code Mode scripts are
3559    /// model-authored, so that was the untrusted route.
3560    ///
3561    /// `PathPart::Literal` parts are deliberately NOT checked: they are the
3562    /// script's own literal text from the compiled template, not
3563    /// caller-substituted data, and flooring them would refuse every legitimate
3564    /// template whose literal segments contain `/`.
3565    ///
3566    /// # Errors
3567    ///
3568    /// Returns [`ExecutionError::RuntimeError`] on an undefined variable, an
3569    /// expression-evaluation failure, or a placeholder refusal. A refusal's
3570    /// message comes from the refusal's own value-free `Display`.
3571    fn resolve_path(&self, path: &PathTemplate) -> Result<String, ExecutionError> {
3572        let mut result = String::new();
3573        for (index, part) in path.parts.iter().enumerate() {
3574            match part {
3575                PathPart::Literal(s) => result.push_str(s),
3576                PathPart::Variable(var) => {
3577                    let value =
3578                        self.variables
3579                            .get(var)
3580                            .ok_or_else(|| ExecutionError::RuntimeError {
3581                                message: format!("Undefined variable in path: {}", var),
3582                            })?;
3583                    let rendered = shared_json_to_string_with_mode(value, JsonStringMode::Json);
3584                    // The refusal names the variable IDENTIFIER, which the part
3585                    // carries. This is safe because the identifier is CALLER-CHOSEN,
3586                    // not because it is trusted: echoing it back discloses nothing
3587                    // the caller does not already know, and the JS identifier grammar
3588                    // admits no whitespace, newline or punctuation beyond `$`/`_`, so
3589                    // it cannot carry a log-injection payload. The VALUE is always
3590                    // redacted. T-128-21b (accept, low) — Phase 128 security audit.
3591                    //
3592                    // An earlier revision justified this as "a script-chosen identifier
3593                    // is operator-shipped content, unlike the value". That was FALSE on
3594                    // the `execute_code` surface, whose own tool definition says it
3595                    // "runs caller-supplied code" (`handler.rs` `build_execute_tool`),
3596                    // and it contradicted this function's own doc 25 lines above
3597                    // ("Code Mode scripts are model-authored, so that was the untrusted
3598                    // route"). Correcting it is the same documented-but-untrue class
3599                    // Phase 128 exists to close, applied to this phase's own comment.
3600                    //
3601                    // Note the identifier class already reaches the client at three
3602                    // other sites (the `Undefined variable in path` message just above,
3603                    // and `result_var`/`temp_var` in the two `ApiCall` error wraps), so
3604                    // routing ONLY this arm through a positional descriptor would be a
3605                    // partial fix that reads as a complete one. The sibling
3606                    // `PathPart::Expression` arm below uses a fixed descriptor for a
3607                    // different reason: an expression has no name, and rendering its
3608                    // body could itself echo caller DATA.
3609                    floor_layer_one_contribution(var, &rendered)?;
3610                    result.push_str(&rendered);
3611                },
3612                PathPart::Expression(expr) => {
3613                    let value = self.evaluate(expr)?;
3614                    let rendered = shared_json_to_string_with_mode(&value, JsonStringMode::Json);
3615                    // An expression has NO name, and a rendering of the expression
3616                    // body could itself contain caller text — so the refusal
3617                    // carries a fixed positional descriptor and never interpolates
3618                    // either the body or the evaluated value.
3619                    floor_layer_one_contribution(&format!("path expression #{index}"), &rendered)?;
3620                    result.push_str(&rendered);
3621                },
3622            }
3623        }
3624        Ok(result)
3625    }
3626
3627    /// Evaluate an expression to a JSON value.
3628    /// Delegates to the shared evaluation module.
3629    fn evaluate(&self, expr: &ValueExpr) -> Result<JsonValue, ExecutionError> {
3630        shared_evaluate(expr, &self.variables)
3631    }
3632}
3633
3634// ============================================================================
3635// LEGACY COMPATIBILITY - Types for backward compatibility
3636// ============================================================================
3637
3638/// Legacy JsExecutor type alias for backward compatibility.
3639pub type JsExecutor = PlanCompiler;
3640
3641#[cfg(test)]
3642mod tests {
3643    use super::*;
3644
3645    #[test]
3646    fn test_execution_config_default() {
3647        let config = ExecutionConfig::default();
3648        assert_eq!(config.max_api_calls, 50);
3649        assert_eq!(config.timeout_seconds, 30);
3650        assert_eq!(config.max_loop_iterations, 100);
3651    }
3652
3653    #[test]
3654    fn test_path_template_static() {
3655        let path = PathTemplate::static_path("/users".into());
3656        assert!(!path.is_dynamic());
3657    }
3658
3659    #[test]
3660    fn test_path_template_dynamic() {
3661        let path = PathTemplate {
3662            parts: vec![
3663                PathPart::Literal("/users/".into()),
3664                PathPart::Variable("id".into()),
3665            ],
3666        };
3667        assert!(path.is_dynamic());
3668    }
3669
3670    #[test]
3671    fn test_plan_metadata() {
3672        let metadata = PlanMetadata {
3673            api_call_count: 2,
3674            has_mutations: false,
3675            endpoints: vec!["/users".into(), "/products".into()],
3676            methods_used: vec!["GET".into()],
3677        };
3678        assert_eq!(metadata.api_call_count, 2);
3679        assert!(!metadata.has_mutations);
3680    }
3681
3682    #[test]
3683    fn test_compile_simple_api_call() {
3684        let code = r#"
3685            const user = await api.get('/users/1');
3686            return user;
3687        "#;
3688
3689        let mut compiler = PlanCompiler::new();
3690        let plan = compiler.compile_code(code).expect("Should compile");
3691
3692        assert_eq!(plan.metadata.api_call_count, 1);
3693        assert!(!plan.metadata.has_mutations);
3694        assert_eq!(plan.steps.len(), 2); // ApiCall + Return
3695    }
3696
3697    #[test]
3698    fn test_compile_multiple_api_calls() {
3699        let code = r#"
3700            const users = await api.get('/users');
3701            const products = await api.get('/products');
3702            return { users, products };
3703        "#;
3704
3705        let mut compiler = PlanCompiler::new();
3706        let plan = compiler.compile_code(code).expect("Should compile");
3707
3708        assert_eq!(plan.metadata.api_call_count, 2);
3709        assert!(!plan.metadata.has_mutations);
3710    }
3711
3712    #[test]
3713    fn test_compile_mutation() {
3714        let code = r#"
3715            const result = await api.post('/users', { name: 'Test' });
3716            return result;
3717        "#;
3718
3719        let mut compiler = PlanCompiler::new();
3720        let plan = compiler.compile_code(code).expect("Should compile");
3721
3722        assert_eq!(plan.metadata.api_call_count, 1);
3723        assert!(plan.metadata.has_mutations);
3724    }
3725
3726    #[test]
3727    fn test_compile_dynamic_path() {
3728        let code = r#"
3729            const id = 123;
3730            const user = await api.get(`/users/${id}`);
3731            return user;
3732        "#;
3733
3734        let mut compiler = PlanCompiler::new();
3735        let plan = compiler.compile_code(code).expect("Should compile");
3736
3737        assert_eq!(plan.metadata.api_call_count, 1);
3738    }
3739
3740    #[test]
3741    fn test_compile_bounded_loop() {
3742        let code = r#"
3743            const items = [];
3744            const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
3745            for (const user of users.slice(0, 2)) {
3746                const detail = await api.get(`/users/${user.id}`);
3747                items.push(detail);
3748            }
3749            return items;
3750        "#;
3751
3752        let mut compiler = PlanCompiler::new();
3753        let plan = compiler.compile_code(code).expect("Should compile");
3754
3755        // The loop is bounded, so it should compile
3756        assert!(plan
3757            .steps
3758            .iter()
3759            .any(|s| matches!(s, PlanStep::BoundedLoop { .. })));
3760    }
3761
3762    #[test]
3763    fn test_compile_unbounded_loop_detection() {
3764        // Note: The current compiler allows for-of loops without explicit .slice() bounds
3765        // as long as the loop body doesn't exceed iteration limits at runtime.
3766        // This test documents the current behavior.
3767        let code = r#"
3768            const users = [{ id: 1 }, { id: 2 }, { id: 3 }];
3769            for (const user of users) {
3770                const detail = await api.get(`/users/${user.id}`);
3771            }
3772            return users;
3773        "#;
3774
3775        let mut compiler = PlanCompiler::new();
3776        let result = compiler.compile_code(code);
3777
3778        // Currently this compiles - runtime will enforce iteration limits
3779        // See #249 — investigate compile-time loop-bounds checking.
3780        assert!(result.is_ok(), "Loop compiled: {:?}", result);
3781    }
3782
3783    #[test]
3784    fn test_compile_conditional() {
3785        let code = r#"
3786            const user = await api.get('/users/1');
3787            if (user.active) {
3788                const orders = await api.get(`/users/${user.id}/orders`);
3789                return orders;
3790            } else {
3791                return [];
3792            }
3793        "#;
3794
3795        let mut compiler = PlanCompiler::new();
3796        let plan = compiler.compile_code(code).expect("Should compile");
3797
3798        assert!(plan
3799            .steps
3800            .iter()
3801            .any(|s| matches!(s, PlanStep::Conditional { .. })));
3802    }
3803
3804    // Mock HTTP executor for testing
3805    struct MockHttpExecutor {
3806        responses: std::collections::HashMap<String, JsonValue>,
3807    }
3808
3809    impl MockHttpExecutor {
3810        fn new() -> Self {
3811            Self {
3812                responses: std::collections::HashMap::new(),
3813            }
3814        }
3815
3816        fn add_response(&mut self, path: &str, response: JsonValue) {
3817            self.responses.insert(path.to_string(), response);
3818        }
3819    }
3820
3821    #[async_trait::async_trait]
3822    impl HttpExecutor for MockHttpExecutor {
3823        async fn execute_request(
3824            &self,
3825            _method: &str,
3826            path: ResolvedPath<'_>,
3827            _body: Option<JsonValue>,
3828        ) -> Result<JsonValue, ExecutionError> {
3829            let path = path.as_str();
3830            self.responses
3831                .get(path)
3832                .cloned()
3833                .ok_or_else(|| ExecutionError::RuntimeError {
3834                    message: format!("No mock response for path: {}", path),
3835                })
3836        }
3837    }
3838
3839    #[tokio::test]
3840    async fn test_execute_simple_api_call() {
3841        let code = r#"
3842            const user = await api.get('/users/1');
3843            return user;
3844        "#;
3845
3846        let mut compiler = PlanCompiler::new();
3847        let plan = compiler.compile_code(code).expect("Should compile");
3848
3849        let mut mock_http = MockHttpExecutor::new();
3850        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "name": "Alice" }));
3851
3852        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3853        let result = executor.execute(&plan).await.expect("Should execute");
3854
3855        assert_eq!(result.value["id"], 1);
3856        assert_eq!(result.value["name"], "Alice");
3857        assert_eq!(result.api_calls.len(), 1);
3858    }
3859
3860    #[tokio::test]
3861    async fn test_execute_multiple_api_calls() {
3862        let code = r#"
3863            const users = await api.get('/users');
3864            const products = await api.get('/products');
3865            return { users, products };
3866        "#;
3867
3868        let mut compiler = PlanCompiler::new();
3869        let plan = compiler.compile_code(code).expect("Should compile");
3870
3871        let mut mock_http = MockHttpExecutor::new();
3872        mock_http.add_response("/users", serde_json::json!([{ "id": 1, "name": "Alice" }]));
3873        mock_http.add_response(
3874            "/products",
3875            serde_json::json!([{ "id": 100, "name": "Widget" }]),
3876        );
3877
3878        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3879        let result = executor.execute(&plan).await.expect("Should execute");
3880
3881        assert!(result.value["users"].is_array());
3882        assert!(result.value["products"].is_array());
3883        assert_eq!(result.api_calls.len(), 2);
3884    }
3885
3886    #[tokio::test]
3887    async fn test_execute_with_template_path() {
3888        let code = r#"
3889            const userId = 42;
3890            const user = await api.get(`/users/${userId}`);
3891            return user;
3892        "#;
3893
3894        let mut compiler = PlanCompiler::new();
3895        let plan = compiler.compile_code(code).expect("Should compile");
3896
3897        let mut mock_http = MockHttpExecutor::new();
3898        mock_http.add_response("/users/42", serde_json::json!({ "id": 42, "name": "Bob" }));
3899
3900        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3901        let result = executor.execute(&plan).await.expect("Should execute");
3902
3903        assert_eq!(result.value["id"], 42);
3904        assert_eq!(result.value["name"], "Bob");
3905    }
3906
3907    #[tokio::test]
3908    async fn test_execute_conditional_true_branch() {
3909        let code = r#"
3910            const user = await api.get('/users/1');
3911            if (user.active) {
3912                return { status: "active", user: user };
3913            } else {
3914                return { status: "inactive" };
3915            }
3916        "#;
3917
3918        let mut compiler = PlanCompiler::new();
3919        let plan = compiler.compile_code(code).expect("Should compile");
3920
3921        let mut mock_http = MockHttpExecutor::new();
3922        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "active": true }));
3923
3924        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3925        let result = executor.execute(&plan).await.expect("Should execute");
3926
3927        assert_eq!(result.value["status"], "active");
3928    }
3929
3930    #[tokio::test]
3931    async fn test_execute_conditional_false_branch() {
3932        let code = r#"
3933            const user = await api.get('/users/1');
3934            if (user.active) {
3935                return { status: "active" };
3936            } else {
3937                return { status: "inactive", user: user };
3938            }
3939        "#;
3940
3941        let mut compiler = PlanCompiler::new();
3942        let plan = compiler.compile_code(code).expect("Should compile");
3943
3944        let mut mock_http = MockHttpExecutor::new();
3945        mock_http.add_response("/users/1", serde_json::json!({ "id": 1, "active": false }));
3946
3947        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3948        let result = executor.execute(&plan).await.expect("Should execute");
3949
3950        assert_eq!(result.value["status"], "inactive");
3951    }
3952
3953    #[tokio::test]
3954    async fn test_compile_and_execute_reduce() {
3955        let code = r#"
3956            const products = await api.get('/products');
3957            const totalPrice = products.reduce((sum, p) => sum + p.price, 0);
3958            return { total: totalPrice };
3959        "#;
3960
3961        let mut compiler = PlanCompiler::new();
3962        let plan = compiler.compile_code(code).expect("Should compile reduce");
3963
3964        let mut mock_http = MockHttpExecutor::new();
3965        mock_http.add_response(
3966            "/products",
3967            serde_json::json!([
3968                { "id": 1, "name": "Widget", "price": 10 },
3969                { "id": 2, "name": "Gadget", "price": 25 },
3970                { "id": 3, "name": "Gizmo", "price": 15 }
3971            ]),
3972        );
3973
3974        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
3975        let result = executor.execute(&plan).await.expect("Should execute");
3976
3977        // Result is f64, compare as number
3978        assert_eq!(result.value["total"].as_f64().unwrap(), 50.0);
3979    }
3980
3981    #[tokio::test]
3982    async fn test_compile_and_execute_to_fixed() {
3983        let code = r#"
3984            const products = await api.get('/products');
3985            const totalPrice = products.reduce((sum, p) => sum + p.price, 0);
3986            const averagePrice = products.length > 0 ? totalPrice / products.length : 0;
3987            return { averagePrice: averagePrice.toFixed(2) };
3988        "#;
3989
3990        let mut compiler = PlanCompiler::new();
3991        let plan = compiler.compile_code(code).expect("Should compile toFixed");
3992
3993        let mut mock_http = MockHttpExecutor::new();
3994        mock_http.add_response(
3995            "/products",
3996            serde_json::json!([
3997                { "id": 1, "name": "Widget", "price": 10 },
3998                { "id": 2, "name": "Gadget", "price": 25 },
3999                { "id": 3, "name": "Gizmo", "price": 15 }
4000            ]),
4001        );
4002
4003        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4004        let result = executor.execute(&plan).await.expect("Should execute");
4005
4006        // 50 / 3 = 16.666... toFixed(2) = "16.67"
4007        assert_eq!(result.value["averagePrice"], "16.67");
4008    }
4009
4010    // =========================================================================
4011    // Field Filtering Tests
4012    // =========================================================================
4013
4014    #[test]
4015    fn test_filter_blocked_fields_simple() {
4016        let value = serde_json::json!({
4017            "id": 1,
4018            "name": "Alice",
4019            "password": "secret123",
4020            "email": "alice@example.com"
4021        });
4022
4023        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4024        let filtered = filter_blocked_fields(value, &blocked);
4025
4026        assert_eq!(filtered["id"], 1);
4027        assert_eq!(filtered["name"], "Alice");
4028        assert_eq!(filtered["email"], "alice@example.com");
4029        assert!(filtered.get("password").is_none());
4030    }
4031
4032    #[test]
4033    fn test_filter_blocked_fields_multiple() {
4034        let value = serde_json::json!({
4035            "id": 1,
4036            "name": "Alice",
4037            "password": "secret123",
4038            "ssn": "123-45-6789",
4039            "apiKey": "key-abc123"
4040        });
4041
4042        let blocked: HashSet<String> = ["password", "ssn", "apiKey"]
4043            .iter()
4044            .map(|s| s.to_string())
4045            .collect();
4046        let filtered = filter_blocked_fields(value, &blocked);
4047
4048        assert_eq!(filtered["id"], 1);
4049        assert_eq!(filtered["name"], "Alice");
4050        assert!(filtered.get("password").is_none());
4051        assert!(filtered.get("ssn").is_none());
4052        assert!(filtered.get("apiKey").is_none());
4053    }
4054
4055    #[test]
4056    fn test_filter_blocked_fields_nested() {
4057        let value = serde_json::json!({
4058            "user": {
4059                "id": 1,
4060                "profile": {
4061                    "name": "Alice",
4062                    "password": "secret123"
4063                }
4064            }
4065        });
4066
4067        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4068        let filtered = filter_blocked_fields(value, &blocked);
4069
4070        assert_eq!(filtered["user"]["id"], 1);
4071        assert_eq!(filtered["user"]["profile"]["name"], "Alice");
4072        assert!(filtered["user"]["profile"].get("password").is_none());
4073    }
4074
4075    #[test]
4076    fn test_filter_blocked_fields_in_array() {
4077        let value = serde_json::json!([
4078            { "id": 1, "name": "Alice", "password": "secret1" },
4079            { "id": 2, "name": "Bob", "password": "secret2" }
4080        ]);
4081
4082        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4083        let filtered = filter_blocked_fields(value, &blocked);
4084
4085        let arr = filtered.as_array().unwrap();
4086        assert_eq!(arr.len(), 2);
4087        assert_eq!(arr[0]["id"], 1);
4088        assert_eq!(arr[0]["name"], "Alice");
4089        assert!(arr[0].get("password").is_none());
4090        assert_eq!(arr[1]["id"], 2);
4091        assert_eq!(arr[1]["name"], "Bob");
4092        assert!(arr[1].get("password").is_none());
4093    }
4094
4095    #[test]
4096    fn test_filter_blocked_fields_empty_blocklist() {
4097        let value = serde_json::json!({
4098            "id": 1,
4099            "password": "secret123"
4100        });
4101
4102        let blocked: HashSet<String> = HashSet::new();
4103        let filtered = filter_blocked_fields(value.clone(), &blocked);
4104
4105        // Should be unchanged
4106        assert_eq!(filtered, value);
4107    }
4108
4109    #[test]
4110    fn test_filter_blocked_fields_primitive_values() {
4111        // Primitives should pass through unchanged
4112        let blocked: HashSet<String> = ["password"].iter().map(|s| s.to_string()).collect();
4113
4114        assert_eq!(
4115            filter_blocked_fields(JsonValue::String("test".into()), &blocked),
4116            JsonValue::String("test".into())
4117        );
4118        assert_eq!(
4119            filter_blocked_fields(JsonValue::Number(42.into()), &blocked),
4120            JsonValue::Number(42.into())
4121        );
4122        assert_eq!(
4123            filter_blocked_fields(JsonValue::Bool(true), &blocked),
4124            JsonValue::Bool(true)
4125        );
4126        assert_eq!(
4127            filter_blocked_fields(JsonValue::Null, &blocked),
4128            JsonValue::Null
4129        );
4130    }
4131
4132    #[tokio::test]
4133    async fn test_execute_with_blocked_fields() {
4134        let code = r#"
4135            const user = await api.get('/users/1');
4136            return user;
4137        "#;
4138
4139        let mut compiler = PlanCompiler::new();
4140        let plan = compiler.compile_code(code).expect("Should compile");
4141
4142        let mut mock_http = MockHttpExecutor::new();
4143        mock_http.add_response(
4144            "/users/1",
4145            serde_json::json!({
4146                "id": 1,
4147                "name": "Alice",
4148                "password": "secret123",
4149                "apiKey": "key-abc"
4150            }),
4151        );
4152
4153        // Create config with blocked fields
4154        let config = ExecutionConfig::default().with_blocked_fields(["password", "apiKey"]);
4155
4156        let mut executor = PlanExecutor::new(mock_http, config);
4157        let result = executor.execute(&plan).await.expect("Should execute");
4158
4159        // Blocked fields should be filtered out
4160        assert_eq!(result.value["id"], 1);
4161        assert_eq!(result.value["name"], "Alice");
4162        assert!(result.value.get("password").is_none());
4163        assert!(result.value.get("apiKey").is_none());
4164    }
4165
4166    #[tokio::test]
4167    async fn test_execute_nested_blocked_fields() {
4168        let code = r#"
4169            const data = await api.get('/data');
4170            return data;
4171        "#;
4172
4173        let mut compiler = PlanCompiler::new();
4174        let plan = compiler.compile_code(code).expect("Should compile");
4175
4176        let mut mock_http = MockHttpExecutor::new();
4177        mock_http.add_response(
4178            "/data",
4179            serde_json::json!({
4180                "users": [
4181                    { "id": 1, "name": "Alice", "secret": "hidden1" },
4182                    { "id": 2, "name": "Bob", "secret": "hidden2" }
4183                ],
4184                "config": {
4185                    "setting": "value",
4186                    "secret": "also-hidden"
4187                }
4188            }),
4189        );
4190
4191        // Create config with blocked fields
4192        let config = ExecutionConfig::default().with_blocked_fields(["secret"]);
4193
4194        let mut executor = PlanExecutor::new(mock_http, config);
4195        let result = executor.execute(&plan).await.expect("Should execute");
4196
4197        // Secret should be filtered from all nested locations
4198        let users = result.value["users"].as_array().unwrap();
4199        assert_eq!(users[0]["name"], "Alice");
4200        assert!(users[0].get("secret").is_none());
4201        assert_eq!(users[1]["name"], "Bob");
4202        assert!(users[1].get("secret").is_none());
4203
4204        assert_eq!(result.value["config"]["setting"], "value");
4205        assert!(result.value["config"].get("secret").is_none());
4206    }
4207
4208    // =========================================================================
4209    // Output Validation Tests
4210    // =========================================================================
4211
4212    #[test]
4213    fn test_find_blocked_fields_in_output_simple() {
4214        let value = serde_json::json!({
4215            "id": 1,
4216            "name": "Alice",
4217            "ssn": "123-45-6789"
4218        });
4219
4220        let blocked: HashSet<String> = ["ssn"].iter().map(|s| s.to_string()).collect();
4221        let violations = find_blocked_fields_in_output(&value, &blocked);
4222
4223        assert_eq!(violations.len(), 1);
4224        assert_eq!(violations[0], "ssn");
4225    }
4226
4227    #[test]
4228    fn test_find_blocked_fields_in_output_nested() {
4229        let value = serde_json::json!({
4230            "user": {
4231                "profile": {
4232                    "name": "Alice",
4233                    "salary": 100000
4234                }
4235            }
4236        });
4237
4238        let blocked: HashSet<String> = ["salary"].iter().map(|s| s.to_string()).collect();
4239        let violations = find_blocked_fields_in_output(&value, &blocked);
4240
4241        assert_eq!(violations.len(), 1);
4242        assert!(violations[0].contains("salary"));
4243    }
4244
4245    #[test]
4246    fn test_find_blocked_fields_in_output_array() {
4247        let value = serde_json::json!([
4248            { "id": 1, "ssn": "111" },
4249            { "id": 2, "ssn": "222" }
4250        ]);
4251
4252        let blocked: HashSet<String> = ["ssn"].iter().map(|s| s.to_string()).collect();
4253        let violations = find_blocked_fields_in_output(&value, &blocked);
4254
4255        // Should find ssn in both array elements
4256        assert_eq!(violations.len(), 2);
4257    }
4258
4259    #[test]
4260    fn test_find_blocked_fields_in_output_empty_blocklist() {
4261        let value = serde_json::json!({
4262            "id": 1,
4263            "ssn": "123-45-6789"
4264        });
4265
4266        let blocked: HashSet<String> = HashSet::new();
4267        let violations = find_blocked_fields_in_output(&value, &blocked);
4268
4269        assert!(violations.is_empty());
4270    }
4271
4272    #[test]
4273    fn test_find_blocked_fields_in_output_no_violations() {
4274        let value = serde_json::json!({
4275            "id": 1,
4276            "name": "Alice"
4277        });
4278
4279        let blocked: HashSet<String> = ["ssn", "salary"].iter().map(|s| s.to_string()).collect();
4280        let violations = find_blocked_fields_in_output(&value, &blocked);
4281
4282        assert!(violations.is_empty());
4283    }
4284
4285    #[tokio::test]
4286    async fn test_execute_output_blocked_fields_rejected() {
4287        let code = r#"
4288            const user = await api.get('/users/1');
4289            return { name: user.name, ssn: user.ssn };
4290        "#;
4291
4292        let mut compiler = PlanCompiler::new();
4293        let plan = compiler.compile_code(code).expect("Should compile");
4294
4295        let mut mock_http = MockHttpExecutor::new();
4296        mock_http.add_response(
4297            "/users/1",
4298            serde_json::json!({
4299                "id": 1,
4300                "name": "Alice",
4301                "ssn": "123-45-6789"
4302            }),
4303        );
4304
4305        // Note: internal blocklist is empty, so ssn gets through to the script
4306        // But output blocklist should catch it in the return value
4307        let config = ExecutionConfig::default().with_output_blocked_fields(["ssn"]);
4308
4309        let mut executor = PlanExecutor::new(mock_http, config);
4310        let result = executor.execute(&plan).await;
4311
4312        // Should fail because output contains blocked field
4313        assert!(result.is_err());
4314        let err = result.unwrap_err();
4315        assert!(format!("{:?}", err).contains("ssn"));
4316    }
4317
4318    #[tokio::test]
4319    async fn test_execute_output_blocked_fields_internal_use_allowed() {
4320        // Script reads user data but only returns safe fields - should succeed
4321        let code = r#"
4322            const user = await api.get('/users/1');
4323            return { id: user.id, name: user.name };
4324        "#;
4325
4326        let mut compiler = PlanCompiler::new();
4327        let plan = compiler.compile_code(code).expect("Should compile");
4328
4329        let mut mock_http = MockHttpExecutor::new();
4330        mock_http.add_response(
4331            "/users/1",
4332            serde_json::json!({
4333                "id": 1,
4334                "name": "Alice",
4335                "ssn": "123-45-6789"
4336            }),
4337        );
4338
4339        // Output blocklist - ssn can be read but not returned
4340        // Note: This doesn't prevent script from accessing ssn, just returning it
4341        let config = ExecutionConfig::default().with_output_blocked_fields(["ssn"]);
4342
4343        let mut executor = PlanExecutor::new(mock_http, config);
4344        let result = executor.execute(&plan).await.expect("Should succeed");
4345
4346        // Script read user data but only returned safe fields
4347        assert_eq!(result.value["id"], 1);
4348        assert_eq!(result.value["name"], "Alice");
4349        assert!(result.value.get("ssn").is_none());
4350    }
4351
4352    #[tokio::test]
4353    async fn test_execute_both_blocklists() {
4354        // Test that internal blocklist AND output blocklist work together
4355        let code = r#"
4356            const user = await api.get('/users/1');
4357            return { name: user.name, dateOfBirth: user.dateOfBirth };
4358        "#;
4359
4360        let mut compiler = PlanCompiler::new();
4361        let plan = compiler.compile_code(code).expect("Should compile");
4362
4363        let mut mock_http = MockHttpExecutor::new();
4364        mock_http.add_response(
4365            "/users/1",
4366            serde_json::json!({
4367                "id": 1,
4368                "name": "Alice",
4369                "password": "secret123",
4370                "dateOfBirth": "1990-01-01"
4371            }),
4372        );
4373
4374        // Internal blocklist: password is stripped from API response
4375        // Output blocklist: dateOfBirth can be used but not returned
4376        let config = ExecutionConfig::default()
4377            .with_blocked_fields(["password"])
4378            .with_output_blocked_fields(["dateOfBirth"]);
4379
4380        let mut executor = PlanExecutor::new(mock_http, config);
4381        let result = executor.execute(&plan).await;
4382
4383        // Should fail because output contains dateOfBirth
4384        assert!(result.is_err());
4385        let err = result.unwrap_err();
4386        assert!(format!("{:?}", err).contains("dateOfBirth"));
4387    }
4388
4389    // ========================================================================
4390    // Tests for pre-bound variables (args) and conditionals
4391    // ========================================================================
4392
4393    #[tokio::test]
4394    async fn test_prebound_args_comparison() {
4395        // Test that `if (args.k > args.n)` works with pre-bound args
4396        let code = r#"
4397            if (args.k > args.n) {
4398                return { error: 'k must be <= n' };
4399            }
4400            return { ok: true };
4401        "#;
4402
4403        let mut compiler = PlanCompiler::new();
4404        let plan = compiler.compile_code(code).expect("Should compile");
4405
4406        let mock_http = MockHttpExecutor::new();
4407        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4408        executor.set_variable("args", serde_json::json!({"n": 3, "k": 5}));
4409
4410        let result = executor.execute(&plan).await.expect("Should execute");
4411        assert_eq!(
4412            result.value["error"], "k must be <= n",
4413            "Expected error for k > n, got: {:?}",
4414            result.value
4415        );
4416    }
4417
4418    #[tokio::test]
4419    async fn test_prebound_args_strict_equality() {
4420        // Test that `args.k === 0` works
4421        let code = r#"
4422            if (args.k === 0) {
4423                return { result: 1 };
4424            }
4425            return { result: 'not zero' };
4426        "#;
4427
4428        let mut compiler = PlanCompiler::new();
4429        let plan = compiler.compile_code(code).expect("Should compile");
4430
4431        let mock_http = MockHttpExecutor::new();
4432        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4433        executor.set_variable("args", serde_json::json!({"k": 0}));
4434
4435        let result = executor.execute(&plan).await.expect("Should execute");
4436        assert_eq!(result.value["result"], 1);
4437    }
4438
4439    #[tokio::test]
4440    async fn test_assignment_expression_in_statement() {
4441        // Test that `k = newValue` works as a statement (Expr::Assign)
4442        let code = r#"
4443            let k = 5;
4444            k = 2;
4445            return { k: k };
4446        "#;
4447
4448        let mut compiler = PlanCompiler::new();
4449        let plan = compiler.compile_code(code).expect("Should compile");
4450
4451        let mock_http = MockHttpExecutor::new();
4452        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4453
4454        let result = executor.execute(&plan).await.expect("Should execute");
4455        assert_eq!(result.value["k"], 2);
4456    }
4457
4458    #[tokio::test]
4459    async fn test_assignment_swap_variables() {
4460        // Test swapping two let-bound variables
4461        let code = r#"
4462            let a = 3;
4463            let b = 7;
4464            if (a < b) {
4465                const old_a = a;
4466                a = b;
4467                b = old_a;
4468            }
4469            return { a: a, b: b };
4470        "#;
4471
4472        let mut compiler = PlanCompiler::new();
4473        let plan = compiler.compile_code(code).expect("Should compile");
4474
4475        let mock_http = MockHttpExecutor::new();
4476        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4477
4478        let result = executor.execute(&plan).await.expect("Should execute");
4479        assert_eq!(result.value["a"], 7);
4480        assert_eq!(result.value["b"], 3);
4481    }
4482
4483    // ========================================================================
4484    // MCP call tests (require mcp-code-mode feature)
4485    // ========================================================================
4486
4487    #[cfg(feature = "mcp-code-mode")]
4488    mod mcp_tests {
4489        use super::*;
4490
4491        /// Mock MCP executor that simulates a calculator server.
4492        struct MockCalculatorExecutor;
4493
4494        #[async_trait::async_trait]
4495        impl McpExecutor for MockCalculatorExecutor {
4496            async fn call_tool(
4497                &self,
4498                _server_id: &str,
4499                tool_name: &str,
4500                args: JsonValue,
4501            ) -> Result<JsonValue, ExecutionError> {
4502                match tool_name {
4503                    "add" => {
4504                        let a = args["a"].as_f64().unwrap_or(0.0);
4505                        let b = args["b"].as_f64().unwrap_or(0.0);
4506                        Ok(serde_json::json!({"result": a + b}))
4507                    },
4508                    "subtract" => {
4509                        let a = args["a"].as_f64().unwrap_or(0.0);
4510                        let b = args["b"].as_f64().unwrap_or(0.0);
4511                        Ok(serde_json::json!({"result": a - b}))
4512                    },
4513                    "multiply" => {
4514                        let a = args["a"].as_f64().unwrap_or(0.0);
4515                        let b = args["b"].as_f64().unwrap_or(0.0);
4516                        Ok(serde_json::json!({"result": a * b}))
4517                    },
4518                    "divide" => {
4519                        let a = args["a"].as_f64().unwrap_or(0.0);
4520                        let b = args["b"].as_f64().unwrap_or(1.0);
4521                        Ok(serde_json::json!({"result": a / b}))
4522                    },
4523                    "power" => {
4524                        let base = args["base"].as_f64().unwrap_or(0.0);
4525                        let exponent = args["exponent"].as_f64().unwrap_or(1.0);
4526                        Ok(serde_json::json!({"result": base.powf(exponent)}))
4527                    },
4528                    "sqrt" => {
4529                        let n = args["n"].as_f64().unwrap_or(0.0);
4530                        Ok(serde_json::json!({"result": n.sqrt()}))
4531                    },
4532                    _ => Err(ExecutionError::RuntimeError {
4533                        message: format!("Unknown tool: {}", tool_name),
4534                    }),
4535                }
4536            }
4537        }
4538
4539        #[tokio::test]
4540        async fn test_mcp_call_simple() {
4541            let code = r#"
4542                const result = await mcp.call('calculator', 'add', { a: 5, b: 3 });
4543                return result;
4544            "#;
4545
4546            let mut compiler = PlanCompiler::new();
4547            let plan = compiler.compile_code(code).expect("Should compile");
4548
4549            let mock_http = MockHttpExecutor::new();
4550            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4551            executor.set_mcp_executor(MockCalculatorExecutor);
4552
4553            let result = executor.execute(&plan).await.expect("Should execute");
4554            assert_eq!(result.value["result"], 8.0);
4555        }
4556
4557        #[tokio::test]
4558        async fn test_mcp_call_with_args() {
4559            // Test mcp.call using pre-bound args variable
4560            let code = r#"
4561                const result = await mcp.call('calculator', 'add', { a: args.x, b: args.y });
4562                return { sum: result.result };
4563            "#;
4564
4565            let mut compiler = PlanCompiler::new();
4566            let plan = compiler.compile_code(code).expect("Should compile");
4567
4568            let mock_http = MockHttpExecutor::new();
4569            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4570            executor.set_mcp_executor(MockCalculatorExecutor);
4571            executor.set_variable("args", serde_json::json!({"x": 10, "y": 20}));
4572
4573            let result = executor.execute(&plan).await.expect("Should execute");
4574            assert_eq!(result.value["sum"], 30.0);
4575        }
4576
4577        #[tokio::test]
4578        async fn test_mcp_assignment_in_loop() {
4579            // Test `result = await mcp.call(...)` assignment inside a loop
4580            let code = r#"
4581                let result = { result: 1 };
4582                for (const i of [2, 3, 4, 5]) {
4583                    const mul = await mcp.call('calculator', 'multiply', { a: result.result, b: i });
4584                    result = mul;
4585                }
4586                return { factorial: result.result };
4587            "#;
4588
4589            let mut compiler = PlanCompiler::new();
4590            let plan = compiler.compile_code(code).expect("Should compile");
4591
4592            let mock_http = MockHttpExecutor::new();
4593            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4594            executor.set_mcp_executor(MockCalculatorExecutor);
4595
4596            let result = executor.execute(&plan).await.expect("Should execute");
4597            // 1 * 2 * 3 * 4 * 5 = 120
4598            assert_eq!(result.value["factorial"], 120.0);
4599        }
4600
4601        #[tokio::test]
4602        async fn test_combinations_c_5_3() {
4603            // Full combinations script: C(5,3) = 10
4604            let code = r#"
4605if (args.k > args.n) {
4606  return { error: 'k must be <= n', n: args.n, k: args.k };
4607}
4608if (args.k === 0 || args.k === args.n) {
4609  return { n: args.n, k: args.k, result: 1 };
4610}
4611let k = args.k;
4612const complement = await mcp.call('calculator', 'subtract', { a: args.n, b: args.k });
4613let nmk = complement.result;
4614if (nmk < k) {
4615  const old_k = k;
4616  k = nmk;
4617  nmk = old_k;
4618}
4619let result = { result: 1 };
4620for (const i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
4621  if (i > k) { break; }
4622  const nki = await mcp.call('calculator', 'add', { a: nmk, b: i });
4623  const num = await mcp.call('calculator', 'multiply', { a: result.result, b: nki.result });
4624  result = await mcp.call('calculator', 'divide', { a: num.result, b: i });
4625}
4626return { n: args.n, k: args.k, result: result.result };
4627            "#;
4628
4629            let mut compiler = PlanCompiler::new();
4630            let plan = compiler.compile_code(code).expect("Should compile");
4631
4632            let mock_http = MockHttpExecutor::new();
4633            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4634            executor.set_mcp_executor(MockCalculatorExecutor);
4635            executor.set_variable("args", serde_json::json!({"n": 5, "k": 3}));
4636
4637            let result = executor.execute(&plan).await.expect("Should execute");
4638            assert_eq!(
4639                result.value["result"], 10.0,
4640                "C(5,3) should be 10, got: {:?}",
4641                result.value
4642            );
4643        }
4644
4645        #[tokio::test]
4646        async fn test_combinations_k_greater_than_n() {
4647            // C(3,5) should return error
4648            let code = r#"
4649if (args.k > args.n) {
4650  return { error: 'k must be <= n', n: args.n, k: args.k };
4651}
4652return { result: 'should not reach here' };
4653            "#;
4654
4655            let mut compiler = PlanCompiler::new();
4656            let plan = compiler.compile_code(code).expect("Should compile");
4657
4658            let mock_http = MockHttpExecutor::new();
4659            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4660            executor.set_mcp_executor(MockCalculatorExecutor);
4661            executor.set_variable("args", serde_json::json!({"n": 3, "k": 5}));
4662
4663            let result = executor.execute(&plan).await.expect("Should execute");
4664            assert_eq!(
4665                result.value["error"], "k must be <= n",
4666                "C(3,5) should return error, got: {:?}",
4667                result.value
4668            );
4669        }
4670
4671        #[tokio::test]
4672        async fn test_combinations_c_5_2() {
4673            // C(5,2) = 10 — no swap needed
4674            let code = r#"
4675if (args.k > args.n) {
4676  return { error: 'k must be <= n', n: args.n, k: args.k };
4677}
4678if (args.k === 0 || args.k === args.n) {
4679  return { n: args.n, k: args.k, result: 1 };
4680}
4681let k = args.k;
4682const complement = await mcp.call('calculator', 'subtract', { a: args.n, b: args.k });
4683let nmk = complement.result;
4684if (nmk < k) {
4685  const old_k = k;
4686  k = nmk;
4687  nmk = old_k;
4688}
4689let result = { result: 1 };
4690for (const i of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]) {
4691  if (i > k) { break; }
4692  const nki = await mcp.call('calculator', 'add', { a: nmk, b: i });
4693  const num = await mcp.call('calculator', 'multiply', { a: result.result, b: nki.result });
4694  result = await mcp.call('calculator', 'divide', { a: num.result, b: i });
4695}
4696return { n: args.n, k: args.k, result: result.result };
4697            "#;
4698
4699            let mut compiler = PlanCompiler::new();
4700            let plan = compiler.compile_code(code).expect("Should compile");
4701
4702            let mock_http = MockHttpExecutor::new();
4703            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4704            executor.set_mcp_executor(MockCalculatorExecutor);
4705            executor.set_variable("args", serde_json::json!({"n": 5, "k": 2}));
4706
4707            let result = executor.execute(&plan).await.expect("Should execute");
4708            assert_eq!(
4709                result.value["result"], 10.0,
4710                "C(5,2) should be 10, got: {:?}",
4711                result.value
4712            );
4713        }
4714
4715        #[tokio::test]
4716        async fn test_combinations_edge_cases() {
4717            let code = r#"
4718if (args.k === 0 || args.k === args.n) {
4719  return { result: 1 };
4720}
4721return { result: 'not edge case' };
4722            "#;
4723
4724            let mut compiler = PlanCompiler::new();
4725            let plan = compiler.compile_code(code).expect("Should compile");
4726
4727            // Test k=0
4728            let mock_http = MockHttpExecutor::new();
4729            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4730            executor.set_variable("args", serde_json::json!({"n": 5, "k": 0}));
4731            let result = executor.execute(&plan).await.expect("Should execute");
4732            assert_eq!(result.value["result"], 1, "C(5,0) should be 1");
4733
4734            // Test k=n
4735            let mock_http = MockHttpExecutor::new();
4736            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4737            executor.set_variable("args", serde_json::json!({"n": 5, "k": 5}));
4738            let result = executor.execute(&plan).await.expect("Should execute");
4739            assert_eq!(result.value["result"], 1, "C(5,5) should be 1");
4740        }
4741
4742        #[tokio::test]
4743        async fn test_solve_quadratic() {
4744            // x² - 3x + 2 = 0 → roots [2, 1]
4745            let code = r#"
4746const b_sq = await mcp.call('calculator', 'power', { base: args.b, exponent: 2 });
4747const four_a = await mcp.call('calculator', 'multiply', { a: 4, b: args.a });
4748const four_ac = await mcp.call('calculator', 'multiply', { a: four_a.result, b: args.c });
4749const discriminant = await mcp.call('calculator', 'subtract', { a: b_sq.result, b: four_ac.result });
4750const root_type = discriminant.result > 0 ? 'two_real'
4751  : discriminant.result === 0 ? 'one_real' : 'complex';
4752if (discriminant.result < 0) {
4753  return { discriminant: discriminant.result, root_type: root_type, roots: [] };
4754}
4755const sqrt_disc = await mcp.call('calculator', 'sqrt', { n: discriminant.result });
4756const neg_b = await mcp.call('calculator', 'multiply', { a: -1, b: args.b });
4757const two_a = await mcp.call('calculator', 'multiply', { a: 2, b: args.a });
4758const x1_num = await mcp.call('calculator', 'add', { a: neg_b.result, b: sqrt_disc.result });
4759const x2_num = await mcp.call('calculator', 'subtract', { a: neg_b.result, b: sqrt_disc.result });
4760const x1 = await mcp.call('calculator', 'divide', { a: x1_num.result, b: two_a.result });
4761const x2 = await mcp.call('calculator', 'divide', { a: x2_num.result, b: two_a.result });
4762return { discriminant: discriminant.result, root_type: root_type, roots: [x1.result, x2.result] };
4763            "#;
4764
4765            let mut compiler = PlanCompiler::new();
4766            let plan = compiler.compile_code(code).expect("Should compile");
4767
4768            let mock_http = MockHttpExecutor::new();
4769            let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4770            executor.set_mcp_executor(MockCalculatorExecutor);
4771            executor.set_variable("args", serde_json::json!({"a": 1, "b": -3, "c": 2}));
4772
4773            let result = executor.execute(&plan).await.expect("Should execute");
4774            assert_eq!(result.value["root_type"], "two_real");
4775            assert_eq!(result.value["discriminant"], 1.0);
4776            let roots = result.value["roots"]
4777                .as_array()
4778                .expect("roots should be array");
4779            assert_eq!(roots.len(), 2);
4780            assert_eq!(roots[0], 2.0);
4781            assert_eq!(roots[1], 1.0);
4782        }
4783    }
4784
4785    // =========================================================================
4786    // String method integration tests (compile + execute)
4787    // =========================================================================
4788
4789    #[tokio::test]
4790    async fn test_string_includes() {
4791        let code = r#"
4792            const text = "hello world";
4793            return { found: text.includes("world"), miss: text.includes("xyz") };
4794        "#;
4795
4796        let mut compiler = PlanCompiler::new();
4797        let plan = compiler.compile_code(code).expect("Should compile");
4798
4799        let mock_http = MockHttpExecutor::new();
4800        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4801        let result = executor.execute(&plan).await.expect("Should execute");
4802
4803        assert_eq!(result.value["found"], true);
4804        assert_eq!(result.value["miss"], false);
4805    }
4806
4807    #[tokio::test]
4808    async fn test_string_index_of() {
4809        let code = r#"
4810            const text = "abcdef";
4811            return { idx: text.indexOf("cd"), miss: text.indexOf("xyz") };
4812        "#;
4813
4814        let mut compiler = PlanCompiler::new();
4815        let plan = compiler.compile_code(code).expect("Should compile");
4816
4817        let mock_http = MockHttpExecutor::new();
4818        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4819        let result = executor.execute(&plan).await.expect("Should execute");
4820
4821        assert_eq!(result.value["idx"], 2);
4822        assert_eq!(result.value["miss"], -1);
4823    }
4824
4825    #[tokio::test]
4826    async fn test_string_length() {
4827        let code = r#"
4828            const text = "hello";
4829            return { len: text.length };
4830        "#;
4831
4832        let mut compiler = PlanCompiler::new();
4833        let plan = compiler.compile_code(code).expect("Should compile");
4834
4835        let mock_http = MockHttpExecutor::new();
4836        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4837        let result = executor.execute(&plan).await.expect("Should execute");
4838
4839        assert_eq!(result.value["len"], 5);
4840    }
4841
4842    #[tokio::test]
4843    async fn test_string_slice() {
4844        let code = r#"
4845            const text = "hello world";
4846            return { first: text.slice(0, 5), rest: text.slice(6, 11) };
4847        "#;
4848
4849        let mut compiler = PlanCompiler::new();
4850        let plan = compiler.compile_code(code).expect("Should compile");
4851
4852        let mock_http = MockHttpExecutor::new();
4853        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4854        let result = executor.execute(&plan).await.expect("Should execute");
4855
4856        assert_eq!(result.value["first"], "hello");
4857        assert_eq!(result.value["rest"], "world");
4858    }
4859
4860    #[tokio::test]
4861    async fn test_string_concat() {
4862        let code = r#"
4863            const greeting = "hello";
4864            return { result: greeting.concat(" world") };
4865        "#;
4866
4867        let mut compiler = PlanCompiler::new();
4868        let plan = compiler.compile_code(code).expect("Should compile");
4869
4870        let mock_http = MockHttpExecutor::new();
4871        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4872        let result = executor.execute(&plan).await.expect("Should execute");
4873
4874        assert_eq!(result.value["result"], "hello world");
4875    }
4876
4877    #[tokio::test]
4878    async fn test_string_includes_in_filter() {
4879        // Real-world pattern: filter array items by string content
4880        let code = r#"
4881            const items = [
4882                { name: "TIMESTAMP_2024", desc: "A timestamped record" },
4883                { name: "PERSON_1", desc: "A person entity" },
4884                { name: "TIMESTAMP_2025", desc: "Another timestamped record" }
4885            ];
4886            const timestamped = items.filter(item => item.name.includes("TIMESTAMP"));
4887            return { count: timestamped.length, names: timestamped.map(t => t.name) };
4888        "#;
4889
4890        let mut compiler = PlanCompiler::new();
4891        let plan = compiler.compile_code(code).expect("Should compile");
4892
4893        let mock_http = MockHttpExecutor::new();
4894        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4895        let result = executor.execute(&plan).await.expect("Should execute");
4896
4897        assert_eq!(result.value["count"], 2);
4898        let names = result.value["names"].as_array().unwrap();
4899        assert_eq!(names[0], "TIMESTAMP_2024");
4900        assert_eq!(names[1], "TIMESTAMP_2025");
4901    }
4902
4903    #[tokio::test]
4904    async fn test_array_includes_still_works() {
4905        // Regression: array .includes() must still work
4906        let code = r#"
4907            const ids = ["alice", "bob", "charlie"];
4908            return { has_bob: ids.includes("bob"), has_dave: ids.includes("dave") };
4909        "#;
4910
4911        let mut compiler = PlanCompiler::new();
4912        let plan = compiler.compile_code(code).expect("Should compile");
4913
4914        let mock_http = MockHttpExecutor::new();
4915        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
4916        let result = executor.execute(&plan).await.expect("Should execute");
4917
4918        assert_eq!(result.value["has_bob"], true);
4919        assert_eq!(result.value["has_dave"], false);
4920    }
4921
4922    // =========================================================================
4923    // Built-in function compilation tests
4924    // =========================================================================
4925
4926    #[test]
4927    fn test_compile_parse_float() {
4928        let code = r#"
4929            const x = parseFloat("3.14");
4930            return x;
4931        "#;
4932        let mut compiler = PlanCompiler::new();
4933        let plan = compiler
4934            .compile_code(code)
4935            .expect("parseFloat should compile");
4936        assert_eq!(plan.steps.len(), 2); // Assign + Return
4937    }
4938
4939    #[test]
4940    fn test_compile_parse_int() {
4941        let code = r#"
4942            const x = parseInt("42");
4943            return x;
4944        "#;
4945        let mut compiler = PlanCompiler::new();
4946        compiler
4947            .compile_code(code)
4948            .expect("parseInt should compile");
4949    }
4950
4951    #[test]
4952    fn test_compile_math_abs() {
4953        let code = r#"
4954            const x = Math.abs(-5);
4955            return x;
4956        "#;
4957        let mut compiler = PlanCompiler::new();
4958        compiler
4959            .compile_code(code)
4960            .expect("Math.abs should compile");
4961    }
4962
4963    #[test]
4964    fn test_compile_math_max() {
4965        let code = r#"
4966            const x = Math.max(1, 2, 3);
4967            return x;
4968        "#;
4969        let mut compiler = PlanCompiler::new();
4970        compiler
4971            .compile_code(code)
4972            .expect("Math.max should compile");
4973    }
4974
4975    #[test]
4976    fn test_compile_object_keys() {
4977        let code = r#"
4978            const obj = { a: 1, b: 2 };
4979            const keys = Object.keys(obj);
4980            return keys;
4981        "#;
4982        let mut compiler = PlanCompiler::new();
4983        compiler
4984            .compile_code(code)
4985            .expect("Object.keys should compile");
4986    }
4987
4988    #[test]
4989    fn test_compile_object_entries() {
4990        let code = r#"
4991            const obj = { x: 10 };
4992            const entries = Object.entries(obj);
4993            return entries;
4994        "#;
4995        let mut compiler = PlanCompiler::new();
4996        compiler
4997            .compile_code(code)
4998            .expect("Object.entries should compile");
4999    }
5000
5001    #[test]
5002    fn test_compile_unary_plus() {
5003        let code = r#"
5004            const x = +"42";
5005            return x;
5006        "#;
5007        let mut compiler = PlanCompiler::new();
5008        compiler.compile_code(code).expect("unary + should compile");
5009    }
5010
5011    #[test]
5012    fn test_compile_sort_with_comparator() {
5013        let code = r#"
5014            const arr = [3, 1, 2];
5015            const sorted = arr.sort((a, b) => a - b);
5016            return sorted;
5017        "#;
5018        let mut compiler = PlanCompiler::new();
5019        compiler
5020            .compile_code(code)
5021            .expect("sort with comparator should compile");
5022    }
5023
5024    #[test]
5025    fn test_compile_sort_without_comparator() {
5026        let code = r#"
5027            const arr = ["b", "a", "c"];
5028            const sorted = arr.sort();
5029            return sorted;
5030        "#;
5031        let mut compiler = PlanCompiler::new();
5032        compiler
5033            .compile_code(code)
5034            .expect("sort without comparator should compile");
5035    }
5036
5037    // =========================================================================
5038    // End-to-end execution tests for new features
5039    // =========================================================================
5040
5041    #[tokio::test]
5042    async fn test_execute_parse_float() {
5043        let code = r#"
5044            const x = parseFloat("3.14");
5045            return x;
5046        "#;
5047        let mut compiler = PlanCompiler::new();
5048        let plan = compiler.compile_code(code).unwrap();
5049        let mock_http = MockHttpExecutor::new();
5050        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5051        let result = executor.execute(&plan).await.unwrap();
5052        // Why: test fixture uses 3.14 as a representative non-integer parse target,
5053        // not the mathematical PI constant — clippy::approx_constant is a false positive here.
5054        #[allow(clippy::approx_constant)]
5055        let expected = serde_json::json!(3.14);
5056        assert_eq!(result.value, expected);
5057    }
5058
5059    #[tokio::test]
5060    async fn test_execute_math_abs_and_sort() {
5061        let code = r#"
5062            const items = [
5063                { name: "a", val: -5 },
5064                { name: "b", val: 3 },
5065                { name: "c", val: -1 }
5066            ];
5067            const sorted = items.sort((a, b) => Math.abs(b.val) - Math.abs(a.val));
5068            return sorted.map(x => x.name);
5069        "#;
5070        let mut compiler = PlanCompiler::new();
5071        let plan = compiler.compile_code(code).unwrap();
5072        let mock_http = MockHttpExecutor::new();
5073        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5074        let result = executor.execute(&plan).await.unwrap();
5075        assert_eq!(result.value, serde_json::json!(["a", "b", "c"]));
5076    }
5077
5078    #[tokio::test]
5079    async fn test_execute_object_keys() {
5080        let code = r#"
5081            const obj = { x: 1, y: 2, z: 3 };
5082            return Object.keys(obj).length;
5083        "#;
5084        let mut compiler = PlanCompiler::new();
5085        let plan = compiler.compile_code(code).unwrap();
5086        let mock_http = MockHttpExecutor::new();
5087        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5088        let result = executor.execute(&plan).await.unwrap();
5089        assert_eq!(result.value, serde_json::json!(3));
5090    }
5091
5092    #[tokio::test]
5093    async fn test_execute_unary_plus() {
5094        let code = r#"
5095            const x = +"42";
5096            return x;
5097        "#;
5098        let mut compiler = PlanCompiler::new();
5099        let plan = compiler.compile_code(code).unwrap();
5100        let mock_http = MockHttpExecutor::new();
5101        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5102        let result = executor.execute(&plan).await.unwrap();
5103        assert_eq!(result.value, serde_json::json!(42.0));
5104    }
5105
5106    #[tokio::test]
5107    async fn test_execute_number_cast() {
5108        let code = r#"
5109            const x = Number("99.5");
5110            return x;
5111        "#;
5112        let mut compiler = PlanCompiler::new();
5113        let plan = compiler.compile_code(code).unwrap();
5114        let mock_http = MockHttpExecutor::new();
5115        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5116        let result = executor.execute(&plan).await.unwrap();
5117        assert_eq!(result.value, serde_json::json!(99.5));
5118    }
5119
5120    #[tokio::test]
5121    async fn test_execute_math_round_floor_ceil() {
5122        let code = r#"
5123            return {
5124                round: Math.round(3.7),
5125                floor: Math.floor(3.7),
5126                ceil: Math.ceil(3.2)
5127            };
5128        "#;
5129        let mut compiler = PlanCompiler::new();
5130        let plan = compiler.compile_code(code).unwrap();
5131        let mock_http = MockHttpExecutor::new();
5132        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5133        let result = executor.execute(&plan).await.unwrap();
5134        assert_eq!(result.value["round"], serde_json::json!(4.0));
5135        assert_eq!(result.value["floor"], serde_json::json!(3.0));
5136        assert_eq!(result.value["ceil"], serde_json::json!(4.0));
5137    }
5138
5139    // =========================================================================
5140    // Object Spread Tests
5141    // =========================================================================
5142
5143    #[test]
5144    fn test_compile_object_spread_basic() {
5145        let code = r#"
5146            const base = { id: 1, name: "Alice" };
5147            const extended = { ...base, age: 30 };
5148            return extended;
5149        "#;
5150        let mut compiler = PlanCompiler::new();
5151        let plan = compiler
5152            .compile_code(code)
5153            .expect("Object spread should compile");
5154        assert!(plan.steps.len() >= 2);
5155    }
5156
5157    #[tokio::test]
5158    async fn test_execute_object_spread_basic() {
5159        let code = r#"
5160            const base = { id: 1, name: "Alice" };
5161            const extended = { ...base, age: 30 };
5162            return extended;
5163        "#;
5164        let mut compiler = PlanCompiler::new();
5165        let plan = compiler.compile_code(code).unwrap();
5166        let mock_http = MockHttpExecutor::new();
5167        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5168        let result = executor.execute(&plan).await.unwrap();
5169        assert_eq!(result.value["id"], serde_json::json!(1));
5170        assert_eq!(result.value["name"], serde_json::json!("Alice"));
5171        assert_eq!(result.value["age"], serde_json::json!(30));
5172    }
5173
5174    #[tokio::test]
5175    async fn test_execute_object_spread_override() {
5176        // Later properties should override spread properties (JS semantics)
5177        let code = r#"
5178            const obj = { id: 1, name: "old" };
5179            const updated = { ...obj, name: "new" };
5180            return updated;
5181        "#;
5182        let mut compiler = PlanCompiler::new();
5183        let plan = compiler.compile_code(code).unwrap();
5184        let mock_http = MockHttpExecutor::new();
5185        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5186        let result = executor.execute(&plan).await.unwrap();
5187        assert_eq!(result.value["id"], serde_json::json!(1));
5188        assert_eq!(result.value["name"], serde_json::json!("new"));
5189    }
5190
5191    #[tokio::test]
5192    async fn test_execute_object_spread_multiple() {
5193        let code = r#"
5194            const a = { x: 1 };
5195            const b = { y: 2 };
5196            const merged = { ...a, ...b, z: 3 };
5197            return merged;
5198        "#;
5199        let mut compiler = PlanCompiler::new();
5200        let plan = compiler.compile_code(code).unwrap();
5201        let mock_http = MockHttpExecutor::new();
5202        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5203        let result = executor.execute(&plan).await.unwrap();
5204        assert_eq!(result.value["x"], serde_json::json!(1));
5205        assert_eq!(result.value["y"], serde_json::json!(2));
5206        assert_eq!(result.value["z"], serde_json::json!(3));
5207    }
5208
5209    #[tokio::test]
5210    async fn test_execute_object_spread_with_api_result() {
5211        // Primary use case: spread API result into a new object
5212        let code = r#"
5213            const config = await api.get('/config');
5214            const result = { ...config, extra: "added" };
5215            return result;
5216        "#;
5217        let mut compiler = PlanCompiler::new();
5218        let plan = compiler.compile_code(code).unwrap();
5219        let mut mock_http = MockHttpExecutor::new();
5220        mock_http.add_response(
5221            "/config",
5222            serde_json::json!({ "key": "value", "enabled": true }),
5223        );
5224        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5225        let result = executor.execute(&plan).await.unwrap();
5226        assert_eq!(result.value["key"], serde_json::json!("value"));
5227        assert_eq!(result.value["enabled"], serde_json::json!(true));
5228        assert_eq!(result.value["extra"], serde_json::json!("added"));
5229    }
5230
5231    #[tokio::test]
5232    async fn test_execute_object_spread_non_object_noop() {
5233        // Spreading a non-object should be a no-op (matches JS behavior)
5234        let code = r#"
5235            const x = 42;
5236            const obj = { ...x, name: "test" };
5237            return obj;
5238        "#;
5239        let mut compiler = PlanCompiler::new();
5240        let plan = compiler.compile_code(code).unwrap();
5241        let mock_http = MockHttpExecutor::new();
5242        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5243        let result = executor.execute(&plan).await.unwrap();
5244        assert_eq!(result.value["name"], serde_json::json!("test"));
5245        // x (number) should not add any properties
5246        assert!(result.value.as_object().unwrap().len() == 1);
5247    }
5248
5249    #[tokio::test]
5250    async fn test_execute_object_spread_preserves_order() {
5251        // Spread before explicit property: explicit wins
5252        // Explicit before spread: spread wins
5253        let code = r#"
5254            const obj = { a: 1, b: 2 };
5255            const result = { b: 99, ...obj, a: 100 };
5256            return result;
5257        "#;
5258        let mut compiler = PlanCompiler::new();
5259        let plan = compiler.compile_code(code).unwrap();
5260        let mock_http = MockHttpExecutor::new();
5261        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5262        let result = executor.execute(&plan).await.unwrap();
5263        // { b: 99 } then { ...obj } → b overridden to 2, then { a: 100 } → a overridden to 100
5264        assert_eq!(result.value["a"], serde_json::json!(100));
5265        assert_eq!(result.value["b"], serde_json::json!(2));
5266    }
5267
5268    // =========================================================================
5269    // Object Destructuring Tests
5270    // =========================================================================
5271
5272    #[test]
5273    fn test_compile_object_destructuring_simple() {
5274        let code = r#"
5275            const obj = { id: 1, name: "Alice" };
5276            const { id, name } = obj;
5277            return { id, name };
5278        "#;
5279        let mut compiler = PlanCompiler::new();
5280        let plan = compiler
5281            .compile_code(code)
5282            .expect("Object destructuring should compile");
5283        // Should have: Assign(obj), Assign(__destructure_0), Assign(id), Assign(name), Return
5284        assert!(plan.steps.len() >= 4);
5285    }
5286
5287    #[tokio::test]
5288    async fn test_execute_object_destructuring_simple() {
5289        let code = r#"
5290            const obj = { id: 1, name: "Alice", extra: "ignored" };
5291            const { id, name } = obj;
5292            return { id, name };
5293        "#;
5294        let mut compiler = PlanCompiler::new();
5295        let plan = compiler.compile_code(code).unwrap();
5296        let mock_http = MockHttpExecutor::new();
5297        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5298        let result = executor.execute(&plan).await.unwrap();
5299        assert_eq!(result.value["id"], serde_json::json!(1));
5300        assert_eq!(result.value["name"], serde_json::json!("Alice"));
5301        // "extra" should not be in output since we only destructured id and name
5302        assert!(result.value.get("extra").is_none());
5303    }
5304
5305    #[tokio::test]
5306    async fn test_execute_object_destructuring_renamed() {
5307        let code = r#"
5308            const user = { id: 1, name: "Alice" };
5309            const { id: userId, name: userName } = user;
5310            return { userId, userName };
5311        "#;
5312        let mut compiler = PlanCompiler::new();
5313        let plan = compiler.compile_code(code).unwrap();
5314        let mock_http = MockHttpExecutor::new();
5315        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5316        let result = executor.execute(&plan).await.unwrap();
5317        assert_eq!(result.value["userId"], serde_json::json!(1));
5318        assert_eq!(result.value["userName"], serde_json::json!("Alice"));
5319    }
5320
5321    #[tokio::test]
5322    async fn test_execute_object_destructuring_with_api_call() {
5323        // The primary use case: destructure API response
5324        let code = r#"
5325            const { data, status } = await api.get('/users');
5326            return { data, status };
5327        "#;
5328        let mut compiler = PlanCompiler::new();
5329        let plan = compiler.compile_code(code).unwrap();
5330        let mut mock_http = MockHttpExecutor::new();
5331        mock_http.add_response(
5332            "/users",
5333            serde_json::json!({ "data": [{"id": 1}], "status": "ok", "meta": "hidden" }),
5334        );
5335        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5336        let result = executor.execute(&plan).await.unwrap();
5337        assert_eq!(result.value["data"], serde_json::json!([{"id": 1}]));
5338        assert_eq!(result.value["status"], serde_json::json!("ok"));
5339    }
5340
5341    #[tokio::test]
5342    async fn test_execute_object_destructuring_missing_property() {
5343        // Missing properties should be null (matches JS behavior)
5344        let code = r#"
5345            const obj = { id: 1 };
5346            const { id, name } = obj;
5347            return { id, name };
5348        "#;
5349        let mut compiler = PlanCompiler::new();
5350        let plan = compiler.compile_code(code).unwrap();
5351        let mock_http = MockHttpExecutor::new();
5352        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5353        let result = executor.execute(&plan).await.unwrap();
5354        assert_eq!(result.value["id"], serde_json::json!(1));
5355        assert_eq!(result.value["name"], serde_json::json!(null));
5356    }
5357
5358    // =========================================================================
5359    // Array Destructuring Tests
5360    // =========================================================================
5361
5362    #[tokio::test]
5363    async fn test_execute_array_destructuring_simple() {
5364        let code = r#"
5365            const arr = [10, 20, 30];
5366            const [a, b] = arr;
5367            return { a, b };
5368        "#;
5369        let mut compiler = PlanCompiler::new();
5370        let plan = compiler.compile_code(code).unwrap();
5371        let mock_http = MockHttpExecutor::new();
5372        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5373        let result = executor.execute(&plan).await.unwrap();
5374        assert_eq!(result.value["a"], serde_json::json!(10));
5375        assert_eq!(result.value["b"], serde_json::json!(20));
5376    }
5377
5378    #[tokio::test]
5379    async fn test_execute_array_destructuring_with_promise_all() {
5380        // Common pattern: destructure Promise.all results
5381        let code = r#"
5382            const [users, products] = await Promise.all([
5383                api.get('/users'),
5384                api.get('/products')
5385            ]);
5386            return { users, products };
5387        "#;
5388        let mut compiler = PlanCompiler::new();
5389        let plan = compiler.compile_code(code).unwrap();
5390        let mut mock_http = MockHttpExecutor::new();
5391        mock_http.add_response("/users", serde_json::json!([{"id": 1}]));
5392        mock_http.add_response("/products", serde_json::json!([{"sku": "A"}]));
5393        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5394        let result = executor.execute(&plan).await.unwrap();
5395        assert_eq!(result.value["users"], serde_json::json!([{"id": 1}]));
5396        assert_eq!(result.value["products"], serde_json::json!([{"sku": "A"}]));
5397    }
5398
5399    // =========================================================================
5400    // For-of Loop Destructuring Tests
5401    // =========================================================================
5402
5403    #[test]
5404    fn test_compile_for_of_destructuring() {
5405        let code = r#"
5406            const items = [{ id: 1, name: "A" }, { id: 2, name: "B" }];
5407            const results = [];
5408            for (const { id, name } of items.slice(0, 10)) {
5409                results.push({ id, name });
5410            }
5411            return results;
5412        "#;
5413        let mut compiler = PlanCompiler::new();
5414        compiler
5415            .compile_code(code)
5416            .expect("For-of with destructuring should compile");
5417    }
5418
5419    #[tokio::test]
5420    async fn test_execute_for_of_destructuring() {
5421        let code = r#"
5422            const items = [{ id: 1, name: "A" }, { id: 2, name: "B" }];
5423            const results = [];
5424            for (const { id, name } of items.slice(0, 10)) {
5425                results.push({ label: name, num: id });
5426            }
5427            return results;
5428        "#;
5429        let mut compiler = PlanCompiler::new();
5430        let plan = compiler.compile_code(code).unwrap();
5431        let mock_http = MockHttpExecutor::new();
5432        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5433        let result = executor.execute(&plan).await.unwrap();
5434        let arr = result.value.as_array().unwrap();
5435        assert_eq!(arr.len(), 2);
5436        assert_eq!(arr[0]["label"], serde_json::json!("A"));
5437        assert_eq!(arr[0]["num"], serde_json::json!(1));
5438        assert_eq!(arr[1]["label"], serde_json::json!("B"));
5439        assert_eq!(arr[1]["num"], serde_json::json!(2));
5440    }
5441
5442    #[tokio::test]
5443    async fn test_execute_for_of_destructuring_with_api_calls() {
5444        // Real-world pattern: destructure loop items, use properties in API calls
5445        let code = r#"
5446            const users = [{ id: 1, role: "admin" }, { id: 2, role: "user" }];
5447            const results = [];
5448            for (const { id, role } of users.slice(0, 10)) {
5449                const detail = await api.get(`/users/${id}`);
5450                results.push({ role, detail });
5451            }
5452            return results;
5453        "#;
5454        let mut compiler = PlanCompiler::new();
5455        let plan = compiler.compile_code(code).unwrap();
5456        let mut mock_http = MockHttpExecutor::new();
5457        mock_http.add_response("/users/1", serde_json::json!({ "name": "Alice" }));
5458        mock_http.add_response("/users/2", serde_json::json!({ "name": "Bob" }));
5459        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5460        let result = executor.execute(&plan).await.unwrap();
5461        let arr = result.value.as_array().unwrap();
5462        assert_eq!(arr.len(), 2);
5463        assert_eq!(arr[0]["role"], serde_json::json!("admin"));
5464        assert_eq!(arr[0]["detail"]["name"], serde_json::json!("Alice"));
5465        assert_eq!(arr[1]["role"], serde_json::json!("user"));
5466        assert_eq!(arr[1]["detail"]["name"], serde_json::json!("Bob"));
5467    }
5468
5469    // =========================================================================
5470    // Combined Spread + Destructuring Tests
5471    // =========================================================================
5472
5473    #[tokio::test]
5474    async fn test_execute_spread_and_destructuring_combined() {
5475        // Realistic pattern: destructure API response, spread into new request
5476        let code = r#"
5477            const { data, token } = await api.get('/auth');
5478            const result = await api.post('/action', { ...data, token });
5479            return result;
5480        "#;
5481        let mut compiler = PlanCompiler::new();
5482        let plan = compiler.compile_code(code).unwrap();
5483        let mut mock_http = MockHttpExecutor::new();
5484        mock_http.add_response(
5485            "/auth",
5486            serde_json::json!({ "data": { "user": "alice" }, "token": "abc123" }),
5487        );
5488        mock_http.add_response("/action", serde_json::json!({ "success": true }));
5489        let mut executor = PlanExecutor::new(mock_http, ExecutionConfig::default());
5490        let result = executor.execute(&plan).await.unwrap();
5491        assert_eq!(result.value["success"], serde_json::json!(true));
5492    }
5493}
5494
5495// ============================================================================
5496// PHASE 128 D-09 — resolve-and-check-before-dispatch test support
5497// ============================================================================
5498
5499/// Shared fixtures for the `layer_one` / `layer_two` / `composition` /
5500/// `error_does_not_echo_path` modules below.
5501///
5502/// Those four modules sit at THIS level (siblings of `tests`) rather than inside
5503/// it on purpose: the plan's verify selections are `executor::layer_one`,
5504/// `executor::composition` and `executor::error_does_not_echo_path`, and libtest
5505/// matches the FULL test path. A test nested in `tests` would be
5506/// `executor::tests::layer_one_…`, which those filters do NOT match — a
5507/// zero-selection that exits 0 and measures nothing.
5508#[cfg(test)]
5509mod d09_support {
5510    use super::*;
5511    use std::sync::{Arc, Mutex};
5512
5513    /// One observation of an `execute_request` call.
5514    #[derive(Debug, Clone)]
5515    pub(super) struct Seen {
5516        pub(super) method: String,
5517        pub(super) path: String,
5518        pub(super) body: Option<JsonValue>,
5519    }
5520
5521    /// A recording `HttpExecutor` whose log stays observable after the executor
5522    /// has been moved into a `PlanExecutor`.
5523    pub(super) struct RecordingHttp {
5524        seen: Arc<Mutex<Vec<Seen>>>,
5525        response: JsonValue,
5526    }
5527
5528    impl RecordingHttp {
5529        /// Returns the executor plus a handle onto its call log.
5530        pub(super) fn new() -> (Self, Arc<Mutex<Vec<Seen>>>) {
5531            let seen = Arc::new(Mutex::new(Vec::new()));
5532            (
5533                Self {
5534                    seen: Arc::clone(&seen),
5535                    response: JsonValue::Object(serde_json::Map::new()),
5536                },
5537                seen,
5538            )
5539        }
5540    }
5541
5542    #[async_trait::async_trait]
5543    impl HttpExecutor for RecordingHttp {
5544        async fn execute_request(
5545            &self,
5546            method: &str,
5547            path: ResolvedPath<'_>,
5548            body: Option<JsonValue>,
5549        ) -> Result<JsonValue, ExecutionError> {
5550            self.seen.lock().unwrap().push(Seen {
5551                method: method.to_string(),
5552                path: path.as_str().to_string(),
5553                body,
5554            });
5555            Ok(self.response.clone())
5556        }
5557    }
5558
5559    /// An `HttpExecutor` that always fails, for the Pitfall-7 wrap tests.
5560    pub(super) struct FailingHttp;
5561
5562    #[async_trait::async_trait]
5563    impl HttpExecutor for FailingHttp {
5564        async fn execute_request(
5565            &self,
5566            _method: &str,
5567            _path: ResolvedPath<'_>,
5568            _body: Option<JsonValue>,
5569        ) -> Result<JsonValue, ExecutionError> {
5570            Err(ExecutionError::RuntimeError {
5571                message: "simulated transport failure".to_string(),
5572            })
5573        }
5574    }
5575
5576    /// An `HttpExecutor` that REFUSES every request, as an embedder's outbound
5577    /// policy does. The message is a fixed constant so a test can assert it survives
5578    /// the plan executor and that nothing else of the request is added to it.
5579    pub(super) struct RefusingHttp;
5580
5581    pub(super) const REFUSAL_MESSAGE: &str = "outbound request refused by policy: fixed text";
5582
5583    #[async_trait::async_trait]
5584    impl HttpExecutor for RefusingHttp {
5585        async fn execute_request(
5586            &self,
5587            _method: &str,
5588            _path: ResolvedPath<'_>,
5589            _body: Option<JsonValue>,
5590        ) -> Result<JsonValue, ExecutionError> {
5591            Err(ExecutionError::RequestRefused {
5592                message: REFUSAL_MESSAGE.to_string(),
5593            })
5594        }
5595    }
5596
5597    pub(super) fn plan(steps: Vec<PlanStep>) -> ExecutionPlan {
5598        ExecutionPlan {
5599            steps,
5600            metadata: PlanMetadata {
5601                api_call_count: 0,
5602                has_mutations: false,
5603                endpoints: Vec::new(),
5604                methods_used: Vec::new(),
5605            },
5606        }
5607    }
5608
5609    /// A GET `ApiCall` step over the given path parts and optional body literal.
5610    pub(super) fn get_step(parts: Vec<PathPart>, body: Option<JsonValue>) -> PlanStep {
5611        PlanStep::ApiCall {
5612            result_var: "out".to_string(),
5613            method: "GET".to_string(),
5614            path: PathTemplate { parts },
5615            body: body.map(ValueExpr::Literal),
5616        }
5617    }
5618
5619    /// `literal` is the common case: a string-literal path template.
5620    pub(super) fn literal(path: &str) -> Vec<PathPart> {
5621        vec![PathPart::Literal(path.to_string())]
5622    }
5623}
5624
5625/// FORK 2 — LAYER-1 `${var}` template-literal interpolation is floored.
5626///
5627/// `PathPart::Variable` and `PathPart::Expression` are what the JS compiler emits
5628/// for `` api.get(`/search/${v}`) ``, and their rendered values used to be pushed
5629/// into the path unchecked. A script taking that route never touches a `{key}`
5630/// placeholder, so before this phase it was never floored at all (T-128-20a).
5631#[cfg(test)]
5632mod layer_one {
5633    use super::d09_support::{get_step, literal, plan, RecordingHttp};
5634    use super::*;
5635
5636    async fn run_with_var(
5637        var: &str,
5638        value: JsonValue,
5639    ) -> (Result<ExecutionResult, ExecutionError>, usize) {
5640        let (http, seen) = RecordingHttp::new();
5641        let step = get_step(
5642            vec![
5643                PathPart::Literal("/search/".to_string()),
5644                PathPart::Variable(var.to_string()),
5645            ],
5646            None,
5647        );
5648        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5649        executor.set_variable(var, value);
5650        let result = executor.execute(&plan(vec![step])).await;
5651        let count = seen.lock().unwrap().len();
5652        (result, count)
5653    }
5654
5655    #[tokio::test]
5656    async fn layer_one_refuses_a_query_separator_in_a_variable_part() {
5657        let payload = format!("2026AA?string={}", "z".repeat(60));
5658        let (result, calls) = run_with_var("lookupKey", JsonValue::String(payload.clone())).await;
5659        let err = result.expect_err("a query separator in a ${var} part must be refused");
5660        let rendered = err.to_string();
5661        assert_eq!(
5662            calls, 0,
5663            "no upstream request may be dispatched: {rendered}"
5664        );
5665        assert!(
5666            rendered.contains("lookupKey"),
5667            "a Variable part's refusal names its identifier, which is what proves \
5668             the refusal came from LAYER 1 and not from the composed check: {rendered}"
5669        );
5670        assert!(
5671            !rendered.contains("2026AA") && !rendered.contains('?'),
5672            "the refusal must carry no byte of the value: {rendered}"
5673        );
5674    }
5675
5676    #[tokio::test]
5677    async fn layer_one_refuses_parent_traversal_in_a_variable_part() {
5678        let (result, calls) =
5679            run_with_var("lookupKey", JsonValue::String("../etc".to_string())).await;
5680        let rendered = result
5681            .expect_err("parent traversal in a ${var} part must be refused")
5682            .to_string();
5683        assert_eq!(
5684            calls, 0,
5685            "no upstream request may be dispatched: {rendered}"
5686        );
5687        assert!(rendered.contains("lookupKey"), "{rendered}");
5688    }
5689
5690    #[tokio::test]
5691    async fn layer_one_refuses_an_over_cap_variable_part() {
5692        let over = "a".repeat(crate::PLACEHOLDER_MAX_LENGTH + 1);
5693        let (result, calls) = run_with_var("lookupKey", JsonValue::String(over)).await;
5694        let rendered = result
5695            .expect_err("an over-cap ${var} part must be refused")
5696            .to_string();
5697        assert_eq!(
5698            calls, 0,
5699            "no upstream request may be dispatched: {rendered}"
5700        );
5701        assert!(rendered.contains("lookupKey"), "{rendered}");
5702    }
5703
5704    #[tokio::test]
5705    async fn layer_one_refuses_a_query_separator_in_an_expression_part() {
5706        // An Expression part has no name, and a rendering of the expression body
5707        // could itself contain caller text — so the refusal must use a FIXED
5708        // positional descriptor and never interpolate either one.
5709        let (http, seen) = RecordingHttp::new();
5710        let step = get_step(
5711            vec![
5712                PathPart::Literal("/search/".to_string()),
5713                PathPart::Expression(ValueExpr::PropertyAccess {
5714                    object: Box::new(ValueExpr::Variable("holder".to_string())),
5715                    property: "secretField".to_string(),
5716                }),
5717            ],
5718            None,
5719        );
5720        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5721        executor.set_variable(
5722            "holder",
5723            serde_json::json!({ "secretField": "2026AA?string=payload" }),
5724        );
5725        let rendered = executor
5726            .execute(&plan(vec![step]))
5727            .await
5728            .expect_err("a query separator in an ${expr} part must be refused")
5729            .to_string();
5730        assert_eq!(seen.lock().unwrap().len(), 0, "{rendered}");
5731        assert!(
5732            rendered.contains("path expression"),
5733            "an Expression part uses a fixed positional descriptor: {rendered}"
5734        );
5735        for forbidden in ["2026AA", "payload", "secretField", "holder", "?"] {
5736            assert!(
5737                !rendered.contains(forbidden),
5738                "the refusal must carry neither the evaluated value nor a rendering \
5739                 of the expression body; found {forbidden:?} in {rendered:?}"
5740            );
5741        }
5742    }
5743
5744    #[tokio::test]
5745    async fn layer_one_accepts_a_literal_only_template_including_slashes() {
5746        // Literal parts are the SCRIPT's own compiled text, not caller-substituted
5747        // data. Flooring them would refuse every legitimate multi-segment template.
5748        let (http, seen) = RecordingHttp::new();
5749        let step = get_step(literal("/Line/Mode/tube/Status"), None);
5750        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5751        executor
5752            .execute(&plan(vec![step]))
5753            .await
5754            .expect("a literal-only template with slashes must still resolve");
5755        let seen = seen.lock().unwrap();
5756        assert_eq!(seen.len(), 1);
5757        assert_eq!(seen[0].path, "/Line/Mode/tube/Status");
5758    }
5759}
5760
5761/// LAYER-2 `{key}` placeholder resolution, moved ahead of dispatch by D-09.
5762#[cfg(test)]
5763mod layer_two {
5764    use super::d09_support::{get_step, literal, plan, RecordingHttp};
5765    use super::*;
5766
5767    async fn run(
5768        path: &str,
5769        body: JsonValue,
5770    ) -> (
5771        Result<ExecutionResult, ExecutionError>,
5772        Vec<super::d09_support::Seen>,
5773    ) {
5774        let (http, seen) = RecordingHttp::new();
5775        let step = get_step(literal(path), Some(body));
5776        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
5777        let result = executor.execute(&plan(vec![step])).await;
5778        let seen = seen.lock().unwrap().clone();
5779        (result, seen)
5780    }
5781
5782    /// A refused request is a `RequestRefused`, NOT a `RuntimeError`.
5783    ///
5784    /// The two have different owners: a runtime fault is the server's, a refused
5785    /// request is the caller's to fix, and a tool handler reports them on different
5786    /// channels. Until 0.7 every refusal here was a `RuntimeError`, so a model was
5787    /// told a policy refusal was an internal error. Fails if the variant reverts.
5788    #[tokio::test]
5789    async fn a_refused_placeholder_is_request_refused_not_runtime_error() {
5790        let (result, seen) =
5791            run("/search/{v}", serde_json::json!({"v": "2026AA?string=zzz"})).await;
5792        let err = result.expect_err("a query separator in a placeholder value is refused");
5793        assert!(
5794            matches!(err, ExecutionError::RequestRefused { .. }),
5795            "a placeholder-floor refusal must be RequestRefused, got: {err:?}"
5796        );
5797        assert!(seen.is_empty(), "a refused call dispatches nothing");
5798    }
5799
5800    #[tokio::test]
5801    async fn a_non_scalar_path_value_is_request_refused() {
5802        let (result, seen) = run("/x/{v}", serde_json::json!({"v": {"nested": 1}})).await;
5803        let err = result.expect_err("an object cannot be a path value");
5804        assert!(
5805            matches!(err, ExecutionError::RequestRefused { .. }),
5806            "a non-scalar path value is the script's to fix: {err:?}"
5807        );
5808        assert!(seen.is_empty());
5809    }
5810
5811    /// The variant must SURVIVE the two `api call ... failed` wrappers. They are
5812    /// where it used to be flattened into a `RuntimeError` string, so this is the
5813    /// row that makes the whole change real: a refusal raised by an embedder's
5814    /// `HttpExecutor` reaches the caller still classified as a refusal.
5815    #[tokio::test]
5816    async fn an_embedder_refusal_survives_the_api_call_wrapper() {
5817        use super::d09_support::{RefusingHttp, REFUSAL_MESSAGE};
5818        let step = get_step(literal("/anything"), None);
5819        let mut executor = PlanExecutor::new(RefusingHttp, ExecutionConfig::default());
5820        let err = executor
5821            .execute(&plan(vec![step]))
5822            .await
5823            .expect_err("the executor refuses");
5824        let ExecutionError::RequestRefused { message } = &err else {
5825            panic!("a refusal must stay a RequestRefused through the wrapper, got: {err:?}");
5826        };
5827        assert!(
5828            message.contains(REFUSAL_MESSAGE),
5829            "the embedder's own message must be carried through: {message}"
5830        );
5831        assert!(
5832            message.contains("GET api call") && message.contains("was refused"),
5833            "the wrapper adds the method and result variable: {message}"
5834        );
5835        assert!(
5836            !message.contains("/anything"),
5837            "the resolved path must NOT be re-attached (Pitfall 7 / SC-7): {message}"
5838        );
5839    }
5840
5841    /// The other half of the same contract: a genuine transport FAULT is still a
5842    /// `RuntimeError`. The variant split must not reclassify real failures as
5843    /// refusals, or every backend outage would read to a model as its own mistake.
5844    #[tokio::test]
5845    async fn a_transport_failure_is_still_a_runtime_error() {
5846        use super::d09_support::FailingHttp;
5847        let step = get_step(literal("/anything"), None);
5848        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
5849        let err = executor
5850            .execute(&plan(vec![step]))
5851            .await
5852            .expect_err("the transport fails");
5853        assert!(
5854            matches!(err, ExecutionError::RuntimeError { .. }),
5855            "a real fault must stay a RuntimeError, got: {err:?}"
5856        );
5857    }
5858
5859    #[tokio::test]
5860    async fn layer_two_substitutes_and_removes_the_consumed_key_from_the_body() {
5861        let (result, seen) = run("/users/{v}", serde_json::json!({"v": "7", "q": "keep"})).await;
5862        result.expect("a conforming placeholder must resolve");
5863        assert_eq!(seen.len(), 1);
5864        assert_eq!(seen[0].path, "/users/7");
5865        assert_eq!(
5866            seen[0].body,
5867            Some(serde_json::json!({"q": "keep"})),
5868            "the path-consumed key must not also reach the body"
5869        );
5870    }
5871
5872    /// The `# Ordering` invariant on [`resolve_layer_two_placeholders`], asserted
5873    /// end-to-end: "a value that itself contains `{`/`}` cannot manufacture a
5874    /// placeholder for a later key to fill."
5875    ///
5876    /// It did NOT hold while PASS 2 was a sequence of `String::replace` calls over a
5877    /// progressively substituted string — `a`'s literal `{b}` was expanded by the
5878    /// next iteration, composing `/p/xzzzy/q/zzz`. Nothing caught it: `{`/`}` are
5879    /// not denied bytes, so `x{b}y` passes the per-value floor, and a MANUFACTURED
5880    /// placeholder leaves no residual brace for `ResolvedPath::from_checked` to
5881    /// refuse. `apply_substitutions`' single scan over the TEMPLATE is what makes
5882    /// the claim true; this row is what keeps it true.
5883    #[tokio::test]
5884    async fn layer_two_value_cannot_manufacture_a_placeholder_for_a_later_key() {
5885        let (result, seen) = run(
5886            "/p/{a}/q/{b}",
5887            serde_json::json!({"a": "x{b}y", "b": "zzz"}),
5888        )
5889        .await;
5890        let rendered = result
5891            .expect_err("a value's literal `{b}` must never be expanded as a placeholder")
5892            .to_string();
5893        assert!(
5894            seen.is_empty(),
5895            "no upstream request may be dispatched: {rendered}"
5896        );
5897        // `a`'s `{b}` survives PASS 2 as a LITERAL, so the composed string still
5898        // carries a brace and `ResolvedPath::from_checked` refuses it as an
5899        // unsubstituted placeholder. Under the old sequential `String::replace` the
5900        // brace was CONSUMED — composing `/p/xzzzy/q/zzz`, with `b`'s value inside
5901        // `a`'s segment and nothing left for the composed check to refuse. Turning a
5902        // silent injection into a refusal is the point.
5903        assert!(
5904            !rendered.contains("zzz"),
5905            "the refusal must carry no byte of any value: {rendered}"
5906        );
5907    }
5908
5909    #[tokio::test]
5910    async fn layer_two_conforming_call_produces_exactly_one_request() {
5911        let (result, seen) = run("/users/{v}", serde_json::json!({"v": "ada"})).await;
5912        result.expect("a conforming placeholder must resolve");
5913        assert_eq!(seen.len(), 1, "exactly one upstream request");
5914        assert_eq!(seen[0].method, "GET");
5915        assert_eq!(seen[0].path, "/users/ada");
5916    }
5917
5918    #[tokio::test]
5919    async fn layer_two_refuses_a_query_separator_in_a_placeholder_value() {
5920        let payload = format!("2026AA?string={}", "z".repeat(60));
5921        let (result, seen) = run("/search/{v}", serde_json::json!({"v": payload})).await;
5922        let rendered = result
5923            .expect_err("a query separator in a {key} value must be refused")
5924            .to_string();
5925        assert!(
5926            seen.is_empty(),
5927            "no upstream request may be dispatched: {rendered}"
5928        );
5929        assert!(
5930            !rendered.contains("2026AA") && !rendered.contains('?'),
5931            "the refusal must carry no byte of the value: {rendered}"
5932        );
5933    }
5934
5935    #[tokio::test]
5936    async fn layer_two_refuses_parent_traversal_in_a_placeholder_value() {
5937        let (result, seen) = run("/files/{v}", serde_json::json!({"v": "../../etc/passwd"})).await;
5938        let rendered = result
5939            .expect_err("parent traversal in a {key} value must be refused")
5940            .to_string();
5941        assert!(seen.is_empty(), "{rendered}");
5942        assert!(!rendered.contains("passwd"), "{rendered}");
5943    }
5944
5945    #[tokio::test]
5946    async fn layer_two_refuses_an_object_valued_placeholder_naming_the_key_only() {
5947        // Preserves the WR-03 rule the toolkit's `scalar_str` used to apply, now
5948        // that resolution has moved up.
5949        let (result, seen) = run(
5950            "/users/{userId}",
5951            serde_json::json!({"userId": {"nested": [1, 2]}}),
5952        )
5953        .await;
5954        let rendered = result
5955            .expect_err("a non-scalar {key} value must be refused")
5956            .to_string();
5957        assert!(seen.is_empty(), "{rendered}");
5958        assert!(rendered.contains("userId"), "must name the key: {rendered}");
5959        for forbidden in ['{', '[', '"'] {
5960            assert!(
5961                !rendered.contains(forbidden),
5962                "must not echo JSON: {rendered}"
5963            );
5964        }
5965    }
5966
5967    #[tokio::test]
5968    async fn layer_two_refuses_an_unsubstituted_placeholder_before_dispatch() {
5969        // No body key matches `{missing}`, so the placeholder survives. It must be
5970        // refused rather than sent upstream as literal braces.
5971        let (result, seen) = run("/users/{missing}", serde_json::json!({"other": "x"})).await;
5972        let rendered = result
5973            .expect_err("an unsubstituted placeholder must be refused")
5974            .to_string();
5975        assert!(
5976            seen.is_empty(),
5977            "literal braces must never reach the wire: {rendered}"
5978        );
5979    }
5980
5981    #[tokio::test]
5982    async fn layer_two_aborts_before_any_substitution_reaches_a_request() {
5983        // Ordering: one conforming value and one refused value in the same path.
5984        // The refusal must abort the step, not dispatch a half-substituted path.
5985        let (result, seen) = run(
5986            "/a/{good}/b/{bad}",
5987            serde_json::json!({"good": "ok", "bad": "../escape"}),
5988        )
5989        .await;
5990        let rendered = result
5991            .expect_err("a refusal on any placeholder aborts the step")
5992            .to_string();
5993        assert!(seen.is_empty(), "{rendered}");
5994    }
5995}
5996
5997/// The COMPOSED path check — the only check that can see an adjacency.
5998///
5999/// Per-value checking is insufficient BY CONSTRUCTION: 180 plus 200 code points
6000/// compose to an over-cap segment and `.` plus `.` composes to a traversal, from
6001/// contributions that each pass on their own (T-128-20b).
6002#[cfg(test)]
6003mod composition {
6004    use super::d09_support::{get_step, plan, RecordingHttp};
6005    use super::*;
6006
6007    #[tokio::test]
6008    async fn composition_refuses_two_adjacent_values_over_the_cap_that_each_pass_alone() {
6009        let first = "a".repeat(180);
6010        let second = "b".repeat(200);
6011
6012        // HALF ONE — each value passes `validate_path_placeholder` in isolation.
6013        // Without this assertion the test would still pass with the composed check
6014        // deleted, because some other rule could be doing the refusing.
6015        let rules = crate::PlaceholderRules::default();
6016        crate::validate_path_placeholder("a", &first, &rules)
6017            .expect("180 code points is under the cap and must pass alone");
6018        crate::validate_path_placeholder("b", &second, &rules)
6019            .expect("200 code points is under the cap and must pass alone");
6020
6021        // HALF TWO — composed, the same two values are refused.
6022        let (http, seen) = RecordingHttp::new();
6023        let step = get_step(
6024            super::d09_support::literal("/search/{a}{b}"),
6025            Some(serde_json::json!({"a": first, "b": second})),
6026        );
6027        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6028        let rendered = executor
6029            .execute(&plan(vec![step]))
6030            .await
6031            .expect_err("380 composed code points exceed the cap and must be refused")
6032            .to_string();
6033        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6034        assert!(
6035            !rendered.contains("aaaa") && !rendered.contains("bbbb"),
6036            "the composed refusal must carry no byte of either value: {rendered}"
6037        );
6038    }
6039
6040    #[tokio::test]
6041    async fn composition_refuses_traversal_assembled_from_unchecked_literal_parts() {
6042        // The ISOLATING row. `PathPart::Literal` parts are deliberately NOT
6043        // floored, so NO per-value check runs here at all — the refusal can only
6044        // come from the composed check. This is the row that fails if
6045        // `validate_resolved_path` is removed.
6046        let (http, seen) = RecordingHttp::new();
6047        let step = get_step(
6048            vec![
6049                PathPart::Literal("/a/.".to_string()),
6050                PathPart::Literal(".".to_string()),
6051                PathPart::Literal("/b".to_string()),
6052            ],
6053            None,
6054        );
6055        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6056        let rendered = executor
6057            .execute(&plan(vec![step]))
6058            .await
6059            .expect_err("a composed `..` segment must be refused")
6060            .to_string();
6061        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6062    }
6063
6064    #[tokio::test]
6065    async fn composition_refuses_two_adjacent_single_dot_placeholders() {
6066        // The change request's row. After plan 02's single-dot floor this is
6067        // refused TWICE over — once per value, once composed — so it is the
6068        // `…_unchecked_literal_parts` sibling above that isolates the mechanism.
6069        let (http, seen) = RecordingHttp::new();
6070        let step = get_step(
6071            super::d09_support::literal("/a/{x}{y}/b"),
6072            Some(serde_json::json!({"x": ".", "y": "."})),
6073        );
6074        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6075        let rendered = executor
6076            .execute(&plan(vec![step]))
6077            .await
6078            .expect_err("`.` + `.` composes to traversal and must be refused")
6079            .to_string();
6080        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6081    }
6082
6083    #[tokio::test]
6084    async fn composition_refuses_a_mixed_layer_one_and_layer_two_over_cap_segment() {
6085        // One `${var}` part and one `{key}` placeholder adjacent in the SAME
6086        // segment. Neither layer alone can see this composition.
6087        let first = "a".repeat(180);
6088        let second = "b".repeat(200);
6089        let rules = crate::PlaceholderRules::default();
6090        crate::validate_path_placeholder("lookupKey", &first, &rules)
6091            .expect("the layer-1 contribution passes alone");
6092        crate::validate_path_placeholder("b", &second, &rules)
6093            .expect("the layer-2 contribution passes alone");
6094
6095        let (http, seen) = RecordingHttp::new();
6096        let step = get_step(
6097            vec![
6098                PathPart::Literal("/search/".to_string()),
6099                PathPart::Variable("lookupKey".to_string()),
6100                PathPart::Literal("{b}".to_string()),
6101            ],
6102            Some(serde_json::json!({"b": second})),
6103        );
6104        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6105        executor.set_variable("lookupKey", JsonValue::String(first));
6106        let rendered = executor
6107            .execute(&plan(vec![step]))
6108            .await
6109            .expect_err("a mixed-layer composed segment over the cap must be refused")
6110            .to_string();
6111        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6112    }
6113}
6114
6115/// RESEARCH Pitfall 7 / SC-7 — no refusal reaching a client carries the path.
6116///
6117/// The module is named for the invariant so the plan's
6118/// `executor::error_does_not_echo_path` verify selection resolves.
6119#[cfg(test)]
6120mod error_does_not_echo_path {
6121    use super::d09_support::{get_step, literal, plan, FailingHttp};
6122    use super::*;
6123
6124    #[tokio::test]
6125    async fn error_does_not_echo_path_when_the_executor_itself_fails() {
6126        let step = get_step(literal("/secret/inventory/endpoint"), None);
6127        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
6128        let rendered = executor
6129            .execute(&plan(vec![step]))
6130            .await
6131            .expect_err("the failing executor must surface an error")
6132            .to_string();
6133        assert!(
6134            rendered.contains("GET"),
6135            "the wrap must still name the method: {rendered}"
6136        );
6137        assert!(
6138            rendered.contains("simulated transport failure"),
6139            "the wrap must still carry the underlying cause: {rendered}"
6140        );
6141        assert!(
6142            !rendered.contains("/secret/inventory/endpoint"),
6143            "the wrap must NOT re-attach the resolved path: {rendered}"
6144        );
6145    }
6146
6147    #[tokio::test]
6148    async fn error_does_not_echo_path_in_the_parallel_arm() {
6149        let step = PlanStep::ParallelApiCalls {
6150            result_var: "out".to_string(),
6151            calls: vec![(
6152                "t0".to_string(),
6153                "GET".to_string(),
6154                PathTemplate {
6155                    parts: literal("/secret/inventory/endpoint"),
6156                },
6157                None,
6158            )],
6159        };
6160        let mut executor = PlanExecutor::new(FailingHttp, ExecutionConfig::default());
6161        let rendered = executor
6162            .execute(&plan(vec![step]))
6163            .await
6164            .expect_err("the failing executor must surface an error")
6165            .to_string();
6166        assert!(rendered.contains("GET"), "{rendered}");
6167        assert!(
6168            !rendered.contains("/secret/inventory/endpoint"),
6169            "the parallel arm's wrap must NOT re-attach the resolved path: {rendered}"
6170        );
6171    }
6172
6173    #[tokio::test]
6174    async fn error_does_not_echo_path_on_a_refused_placeholder() {
6175        let (http, seen) = super::d09_support::RecordingHttp::new();
6176        let step = get_step(
6177            literal("/inventory/{sku}"),
6178            Some(serde_json::json!({"sku": "../../etc/shadow"})),
6179        );
6180        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6181        let rendered = executor
6182            .execute(&plan(vec![step]))
6183            .await
6184            .expect_err("a refused placeholder must error")
6185            .to_string();
6186        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6187        for forbidden in ["shadow", "/inventory/", ".."] {
6188            assert!(
6189                !rendered.contains(forbidden),
6190                "a refusal carries neither the value nor the resolved path; \
6191                 found {forbidden:?} in {rendered:?}"
6192            );
6193        }
6194    }
6195}
6196
6197/// The `?` narrowing, pinned in BOTH directions so it cannot become a hole.
6198///
6199/// `ResolvedPath::from_checked` calls core's `validate_resolved_target`, which
6200/// splits at the first `?` and applies the full rule set to each side, exempting
6201/// exactly one author-written query separator.
6202/// These rows assert what that buys AND everything it does not relax. A narrowing
6203/// with only accept-rows is indistinguishable from a deleted check.
6204#[cfg(test)]
6205mod query_separator {
6206    use super::d09_support::{get_step, literal, plan, RecordingHttp};
6207    use super::*;
6208
6209    async fn run(
6210        parts: Vec<PathPart>,
6211        body: Option<JsonValue>,
6212    ) -> (
6213        Result<ExecutionResult, ExecutionError>,
6214        Vec<super::d09_support::Seen>,
6215    ) {
6216        let (http, seen) = RecordingHttp::new();
6217        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6218        let result = executor.execute(&plan(vec![get_step(parts, body)])).await;
6219        let seen = seen.lock().unwrap().clone();
6220        (result, seen)
6221    }
6222
6223    // ---- ACCEPTED: the separator an author wrote into a literal ----
6224
6225    #[tokio::test]
6226    async fn query_separator_accepts_an_author_written_query_string() {
6227        // The row the operator's narrowing exists for: a `?` in the script's own
6228        // literal path text reaches the wire instead of being refused.
6229        let (result, seen) = run(literal("/Line/Mode/tube/Status?detail=true"), None).await;
6230        result.expect("an author-written query string must be accepted");
6231        assert_eq!(seen.len(), 1);
6232        assert_eq!(seen[0].path, "/Line/Mode/tube/Status?detail=true");
6233    }
6234
6235    #[tokio::test]
6236    async fn query_separator_accepts_a_literal_query_alongside_a_floored_placeholder() {
6237        // The separator is author-written; the `{v}` value still goes through the
6238        // per-value floor. Both mechanisms coexist on one path.
6239        let (result, seen) = run(
6240            literal("/content/{version}/CUI?string=headache"),
6241            Some(serde_json::json!({"version": "current"})),
6242        )
6243        .await;
6244        result.expect("an author query plus a conforming placeholder must be accepted");
6245        assert_eq!(seen.len(), 1);
6246        assert_eq!(seen[0].path, "/content/current/CUI?string=headache");
6247    }
6248
6249    #[tokio::test]
6250    async fn query_separator_accepts_a_graph_style_dollar_projection() {
6251        // The exact shape the in-tree Contoso M365 scripts author.
6252        let (result, seen) = run(
6253            literal("/drives/D/items/I/workbook/worksheets/Customers/range(address='A2:D7')?$select=values"),
6254            None,
6255        )
6256        .await;
6257        result.expect("a Graph $select projection in the path must be accepted");
6258        assert_eq!(seen.len(), 1);
6259        assert!(
6260            seen[0].path.ends_with("?$select=values"),
6261            "{:?}",
6262            seen[0].path
6263        );
6264    }
6265
6266    // ---- STILL REFUSED: everything the split does not relax ----
6267
6268    #[tokio::test]
6269    async fn query_separator_still_refuses_traversal_in_the_path_portion() {
6270        // `/a/../b?x=1` — the traversal rule applies to the whole string, so
6271        // appending a query does NOT launder a traversal past the composed check.
6272        let (result, seen) = run(literal("/a/../b?x=1"), None).await;
6273        let rendered = result
6274            .expect_err("traversal must still be refused when a query follows it")
6275            .to_string();
6276        assert!(seen.is_empty(), "{rendered}");
6277    }
6278
6279    #[tokio::test]
6280    async fn query_separator_still_refuses_traversal_in_the_query_portion() {
6281        let (result, seen) = run(literal("/search?next=../../etc/passwd"), None).await;
6282        let rendered = result
6283            .expect_err("traversal inside the query portion must still be refused")
6284            .to_string();
6285        assert!(seen.is_empty(), "{rendered}");
6286        assert!(!rendered.contains("passwd"), "{rendered}");
6287    }
6288
6289    #[tokio::test]
6290    async fn query_separator_still_refuses_a_control_byte_in_the_query_portion() {
6291        // Percent-encoded NUL, so the decode-once pass is what has to catch it.
6292        let (result, seen) = run(literal("/search?x=a%00b"), None).await;
6293        assert!(
6294            result.is_err(),
6295            "a control byte in the query portion must still be refused"
6296        );
6297        assert!(seen.is_empty());
6298    }
6299
6300    #[tokio::test]
6301    async fn query_separator_still_refuses_an_over_cap_query_portion() {
6302        let long = "z".repeat(crate::PLACEHOLDER_MAX_LENGTH + 1);
6303        let (result, seen) = run(literal(&format!("/search?q={long}")), None).await;
6304        assert!(
6305            result.is_err(),
6306            "an over-cap query portion must still be refused"
6307        );
6308        assert!(seen.is_empty());
6309    }
6310
6311    #[tokio::test]
6312    async fn query_separator_still_refuses_a_second_question_mark() {
6313        // Only the FIRST `?` is split off, so the query portion faces the
6314        // unmodified rule — which denies `?`. One exemption, not a general licence.
6315        let (result, seen) = run(literal("/search?a=1?b=2"), None).await;
6316        assert!(result.is_err(), "a second `?` must still be refused");
6317        assert!(seen.is_empty());
6318    }
6319
6320    #[tokio::test]
6321    async fn query_separator_still_refuses_an_empty_query_portion() {
6322        // A dangling `/x?` is a trailing separator — the same class as a trailing
6323        // `/`, which plan 02 refuses deliberately.
6324        let (result, seen) = run(literal("/search?"), None).await;
6325        assert!(result.is_err(), "a dangling `?` must still be refused");
6326        assert!(seen.is_empty());
6327    }
6328
6329    #[tokio::test]
6330    async fn query_separator_still_refuses_a_fragment_marker() {
6331        // The split is `?`-only. `#` is never sent to a server and stays denied.
6332        let (result, seen) = run(literal("/search#frag"), None).await;
6333        assert!(result.is_err(), "a fragment marker must still be refused");
6334        assert!(seen.is_empty());
6335    }
6336
6337    #[tokio::test]
6338    async fn query_separator_still_refuses_an_injected_separator_from_a_value() {
6339        // THE row that proves the narrowing is not a hole. The template carries an
6340        // author-written `?` (now legal) AND a placeholder value carries an
6341        // injected one (still refused, by the per-value floor — which is the
6342        // mechanism the narrowing relies on for its safety argument).
6343        let payload = format!("2026AA?string={}", "z".repeat(60));
6344        let (result, seen) = run(
6345            literal("/search/{v}?detail=true"),
6346            Some(serde_json::json!({"v": payload})),
6347        )
6348        .await;
6349        let rendered = result
6350            .expect_err("an injected `?` in a VALUE must still be refused")
6351            .to_string();
6352        assert!(seen.is_empty(), "{rendered}");
6353        assert!(
6354            !rendered.contains("2026AA") && !rendered.contains('?'),
6355            "the refusal must still carry no byte of the value: {rendered}"
6356        );
6357    }
6358
6359    #[tokio::test]
6360    async fn query_separator_still_refuses_an_injected_separator_from_a_layer_one_variable() {
6361        // Same boundary, layer-1 route: `${v}` rather than `{v}`.
6362        let (http, seen) = RecordingHttp::new();
6363        let mut executor = PlanExecutor::new(http, ExecutionConfig::default());
6364        executor.set_variable(
6365            "lookupKey",
6366            JsonValue::String("2026AA?string=x".to_string()),
6367        );
6368        let rendered = executor
6369            .execute(&plan(vec![get_step(
6370                vec![
6371                    PathPart::Literal("/search/".to_string()),
6372                    PathPart::Variable("lookupKey".to_string()),
6373                    PathPart::Literal("?detail=true".to_string()),
6374                ],
6375                None,
6376            )]))
6377            .await
6378            .expect_err("an injected `?` in a ${var} part must still be refused")
6379            .to_string();
6380        assert!(seen.lock().unwrap().is_empty(), "{rendered}");
6381        assert!(rendered.contains("lookupKey"), "{rendered}");
6382    }
6383}