Skip to main content

flutmax_codegen/
builder.rs

1/// AST -> PatchGraph conversion
2///
3/// Converts `Program` (AST) to `PatchGraph`.
4/// Each InDecl/OutDecl/Wire/OutAssignment is converted to corresponding nodes and edges,
5/// then `insert_triggers()` auto-inserts triggers for fanouts.
6use std::collections::{HashMap, HashSet};
7
8#[allow(unused_imports)] // CallArg used in tests
9use flutmax_ast::{
10    CallArg, DestructuringWire, DirectConnection, Expr, FeedbackAssignment, FeedbackDecl, InDecl,
11    LitValue, MsgDecl, OutAssignment, OutDecl, PortType, Program, StateAssignment, StateDecl, Wire,
12};
13use flutmax_objdb::{InletSpec, ObjectDb, OutletSpec};
14use flutmax_sema::graph::{NodePurity, PatchEdge, PatchGraph, PatchNode};
15use flutmax_sema::registry::AbstractionRegistry;
16use flutmax_sema::trigger::insert_triggers;
17
18/// Code file mapping. Filename -> code content.
19/// Used when referencing external code files in `v8.codebox` and `codebox` (gen~).
20pub type CodeFiles = HashMap<String, String>;
21
22/// Build error
23#[derive(Debug)]
24pub enum BuildError {
25    /// Referenced an undefined variable
26    UndefinedRef(String),
27    /// Output port index out of range
28    OutletIndexOutOfRange(u32),
29    /// E004: no out declaration corresponding to out[N]
30    NoOutDeclaration(u32),
31    /// E006: destructuring LHS count does not match RHS outlet count
32    DestructuringCountMismatch { expected: usize, got: usize },
33    /// E009: Abstraction argument count does not match in_ports count
34    AbstractionArgCountMismatch {
35        name: String,
36        expected: usize,
37        got: usize,
38    },
39    /// E013: multiple assignments to the same feedback variable
40    DuplicateFeedbackAssignment(String),
41    /// E007: port index out of range
42    InvalidPortIndex {
43        node: String,
44        port: String,
45        index: u32,
46        max: u32,
47    },
48    /// E020: bare reference to multi-outlet node (.out[N] required)
49    BareMultiOutletRef { name: String, num_outlets: u32 },
50    /// E019: multiple assignments to the same state
51    DuplicateStateAssignment(String),
52}
53
54impl std::fmt::Display for BuildError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            BuildError::UndefinedRef(name) => write!(f, "undefined reference: {}", name),
58            BuildError::OutletIndexOutOfRange(idx) => {
59                write!(f, "outlet index out of range: {}", idx)
60            }
61            BuildError::NoOutDeclaration(idx) => {
62                write!(f, "E004: out[{}] has no corresponding out declaration", idx)
63            }
64            BuildError::DestructuringCountMismatch { expected, got } => {
65                write!(
66                    f,
67                    "E006: destructuring count mismatch: expected {} names, got {}",
68                    expected, got
69                )
70            }
71            BuildError::AbstractionArgCountMismatch {
72                name,
73                expected,
74                got,
75            } => {
76                write!(
77                    f,
78                    "E009: abstraction '{}' expects {} arguments, got {}",
79                    name, expected, got
80                )
81            }
82            BuildError::DuplicateFeedbackAssignment(name) => {
83                write!(f, "E013: duplicate feedback assignment to '{}'", name)
84            }
85            BuildError::InvalidPortIndex {
86                node,
87                port,
88                index,
89                max,
90            } => {
91                write!(
92                    f,
93                    "E007: port index out of range: {}.{}[{}] (max: {})",
94                    node, port, index, max
95                )
96            }
97            BuildError::BareMultiOutletRef { name, num_outlets } => {
98                write!(
99                    f,
100                    "E020: bare reference to multi-outlet node '{}' ({} outlets); use .out[N] to specify which outlet",
101                    name, num_outlets
102                )
103            }
104            BuildError::DuplicateStateAssignment(name) => {
105                write!(f, "E019: duplicate state assignment to '{}'", name)
106            }
107        }
108    }
109}
110
111impl std::error::Error for BuildError {}
112
113/// Build warning
114#[derive(Debug, Clone)]
115pub enum BuildWarning {
116    /// W001: duplicate connection to the same inlet
117    DuplicateInletConnection {
118        node_id: String,
119        inlet: u32,
120        count: usize,
121    },
122}
123
124impl std::fmt::Display for BuildWarning {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            BuildWarning::DuplicateInletConnection {
128                node_id,
129                inlet,
130                count,
131            } => {
132                write!(
133                    f,
134                    "W001: {} connections to {}.in[{}]",
135                    count, node_id, inlet
136                )
137            }
138        }
139    }
140}
141
142/// Build result (graph + warnings)
143pub struct BuildResult {
144    pub graph: PatchGraph,
145    pub warnings: Vec<BuildWarning>,
146}
147
148/// Builder that constructs a PatchGraph from AST.
149struct GraphBuilder<'a> {
150    graph: PatchGraph,
151    /// Sequential node ID counter
152    next_id: u32,
153    /// Name -> (node_id, outlet_index) mapping
154    /// Used to look up nodes from inlet names and wire names
155    name_map: HashMap<String, (String, u32)>,
156    /// out_decl index -> node_id
157    outlet_nodes: HashMap<u32, String>,
158    /// Abstraction registry (used during multi-file compilation)
159    registry: Option<&'a AbstractionRegistry>,
160    /// feedback name -> tapin~ node ID mapping
161    feedback_map: HashMap<String, String>,
162    /// Set of already-assigned feedback names (E013: duplicate detection)
163    assigned_feedbacks: HashSet<String>,
164    /// Set of names generated by destructuring assignments (excluded from E020)
165    destructured_names: HashSet<String>,
166    /// Set of already-assigned state names (E019: duplicate detection)
167    assigned_states: HashSet<String>,
168    /// Tuple wire name -> pack type arguments for each element ("i", "f", "s")
169    /// Used for typed unpack generation during destructuring
170    tuple_type_args: HashMap<String, Vec<String>>,
171    /// Code file mapping (for codebox)
172    code_files: Option<&'a CodeFiles>,
173    /// Object definition database (used for inlet/outlet count inference)
174    objdb: Option<&'a ObjectDb>,
175}
176
177impl<'a> GraphBuilder<'a> {
178    fn new(
179        registry: Option<&'a AbstractionRegistry>,
180        code_files: Option<&'a CodeFiles>,
181        objdb: Option<&'a ObjectDb>,
182    ) -> Self {
183        Self {
184            graph: PatchGraph::new(),
185            next_id: 1,
186            name_map: HashMap::new(),
187            outlet_nodes: HashMap::new(),
188            registry,
189            feedback_map: HashMap::new(),
190            assigned_feedbacks: HashSet::new(),
191            destructured_names: HashSet::new(),
192            assigned_states: HashSet::new(),
193            tuple_type_args: HashMap::new(),
194            code_files,
195            objdb,
196        }
197    }
198
199    /// Generate a new node ID.
200    fn gen_id(&mut self) -> String {
201        let id = format!("obj-{}", self.next_id);
202        self.next_id += 1;
203        id
204    }
205
206    /// Convert an InDecl to a node.
207    fn add_inlet(&mut self, decl: &InDecl) {
208        let id = self.gen_id();
209        let is_signal = decl.port_type.is_signal();
210        let object_name = if is_signal { "inlet~" } else { "inlet" };
211        let num_inlets = if is_signal { 1 } else { 0 };
212        let node = PatchNode {
213            id: id.clone(),
214            object_name: object_name.to_string(),
215            args: vec![],
216            num_inlets,
217            num_outlets: 1,
218            is_signal,
219            varname: None,
220            hot_inlets: default_hot_inlets(object_name, num_inlets),
221            purity: classify_purity(object_name),
222            attrs: vec![],
223            code: None,
224        };
225        self.graph.add_node(node);
226        // Register inlet name for lookup
227        self.name_map.insert(decl.name.clone(), (id, 0));
228    }
229
230    /// Convert an OutDecl to a node.
231    fn add_outlet(&mut self, decl: &OutDecl) {
232        let id = self.gen_id();
233        let is_signal = decl.port_type.is_signal();
234        let object_name = if is_signal { "outlet~" } else { "outlet" };
235        let node = PatchNode {
236            id: id.clone(),
237            object_name: object_name.to_string(),
238            args: vec![],
239            num_inlets: 1,
240            num_outlets: 0,
241            is_signal,
242            varname: None,
243            hot_inlets: default_hot_inlets(object_name, 1),
244            purity: classify_purity(object_name),
245            attrs: vec![],
246            code: None,
247        };
248        self.graph.add_node(node);
249        self.outlet_nodes.insert(decl.index, id);
250    }
251
252    /// Process a MsgDecl. Generate a Max message box node.
253    fn add_msg(&mut self, decl: &MsgDecl) {
254        let id = self.gen_id();
255        let attrs = decl
256            .attrs
257            .iter()
258            .map(|a| (a.key.clone(), format_attr_value(&a.value)))
259            .collect();
260        let node = PatchNode {
261            id: id.clone(),
262            object_name: "message".to_string(),
263            args: vec![decl.content.clone()],
264            num_inlets: 2, // inlet 0 = hot (bang/message), inlet 1 = cold (set)
265            num_outlets: 1,
266            is_signal: false,
267            varname: Some(decl.name.clone()),
268            hot_inlets: vec![true, false],
269            purity: classify_purity("message"),
270            attrs,
271            code: None,
272        };
273        self.graph.add_node(node);
274        self.name_map.insert(decl.name.clone(), (id, 0));
275    }
276
277    /// Process a Wire. Evaluate the expression and generate nodes/edges.
278    fn add_wire(&mut self, wire: &Wire) -> Result<(), BuildError> {
279        // For Tuples, record each element's type argument (for propagation during destructuring)
280        if let Expr::Tuple(elements) = &wire.value {
281            let type_args: Vec<String> = elements.iter().map(infer_pack_type_arg).collect();
282            self.tuple_type_args.insert(wire.name.clone(), type_args);
283        }
284
285        let (node_id, outlet) = self.resolve_expr(&wire.value)?;
286        // Set wire name as varname (for debugging in Max)
287        if let Some(node) = self.graph.nodes.iter_mut().find(|n| n.id == node_id) {
288            node.varname = Some(wire.name.clone());
289        }
290        // Transfer .attr() chain attributes to the node
291        if !wire.attrs.is_empty() {
292            if let Some(node) = self.graph.nodes.iter_mut().find(|n| n.id == node_id) {
293                node.attrs = wire
294                    .attrs
295                    .iter()
296                    .map(|a| (a.key.clone(), format_attr_value(&a.value)))
297                    .collect();
298            }
299        }
300        self.name_map.insert(wire.name.clone(), (node_id, outlet));
301        Ok(())
302    }
303
304    /// Process an OutAssignment. Connect the wire's output to the outlet node.
305    fn add_out_assignment(&mut self, assign: &OutAssignment) -> Result<(), BuildError> {
306        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
307        let dest_id = self
308            .outlet_nodes
309            .get(&assign.index)
310            .ok_or(BuildError::NoOutDeclaration(assign.index))?
311            .clone();
312
313        self.graph.add_edge(PatchEdge {
314            source_id,
315            source_outlet,
316            dest_id,
317            dest_inlet: 0,
318            is_feedback: false,
319            order: None,
320        });
321        Ok(())
322    }
323
324    /// Resolve an expression. Generate nodes if needed, and return (node_id, outlet_index).
325    fn resolve_expr(&mut self, expr: &Expr) -> Result<(String, u32), BuildError> {
326        match expr {
327            Expr::Ref(name) => {
328                let (node_id, outlet_index) = self
329                    .name_map
330                    .get(name)
331                    .ok_or_else(|| BuildError::UndefinedRef(name.clone()))?
332                    .clone();
333
334                // Bare references are always interpreted as outlet 0 (E020 removed).
335                // Bare references are OK even for multi-outlet nodes.
336                // Use .out[N] to access outlet 1+.
337
338                Ok((node_id, outlet_index))
339            }
340            Expr::Call { object, args } => {
341                let id = self.gen_id();
342                let max_name = resolve_max_object_name(object);
343                let is_signal = max_name.ends_with('~');
344
345                // Collect literal arguments as part of object text
346                let mut lit_args: Vec<String> = Vec::new();
347                // Collect Ref arguments to create edges later
348                let mut ref_connections: Vec<(String, u32, u32)> = Vec::new(); // (source_node, source_outlet, dest_inlet)
349
350                // gen~, mc.gen~ and rnbo~ take a literal first argument
351                // (the subpatcher name) that becomes part of the object text
352                // rather than consuming an inlet. For example,
353                // `gen~("exciter_pluck", vel_sig, brightness)` produces:
354                //   object text: "gen~ exciter_pluck"
355                //   vel_sig → inlet 0, brightness → inlet 1
356                let has_name_arg = matches!(max_name, "gen~" | "mc.gen~" | "rnbo~");
357                let mut lit_count: u32 = 0;
358
359                for (i, arg) in args.iter().enumerate() {
360                    // Named argument → resolve inlet index from objdb or AbstractionRegistry;
361                    // positional argument → use index directly.
362                    let inlet_idx = if let Some(ref name) = arg.name {
363                        resolve_inlet_name(max_name, name, self.objdb)
364                            .or_else(|| resolve_abstraction_inlet_name(object, name, self.registry))
365                            .unwrap_or(i as u32)
366                    } else if has_name_arg {
367                        // Literal name args don't consume inlets, so subtract their count.
368                        (i as u32).saturating_sub(lit_count)
369                    } else {
370                        i as u32
371                    };
372
373                    match &arg.value {
374                        Expr::Lit(lit) => {
375                            lit_args.push(format_lit(lit));
376                            if has_name_arg {
377                                lit_count += 1;
378                            }
379                        }
380                        Expr::Ref(name) => {
381                            let (ref_node_id, ref_outlet) = self
382                                .name_map
383                                .get(name)
384                                .ok_or_else(|| BuildError::UndefinedRef(name.clone()))?
385                                .clone();
386                            ref_connections.push((ref_node_id, ref_outlet, inlet_idx));
387                        }
388                        Expr::Call { .. } => {
389                            // Recursively resolve nested calls
390                            let (nested_id, nested_outlet) = self.resolve_expr(&arg.value)?;
391                            ref_connections.push((nested_id, nested_outlet, inlet_idx));
392                        }
393                        Expr::OutputPortAccess(opa) => {
394                            // output_port_access: resolve name.out[N]
395                            let (ref_node_id, _) = self
396                                .name_map
397                                .get(&opa.object)
398                                .ok_or_else(|| BuildError::UndefinedRef(opa.object.clone()))?
399                                .clone();
400                            ref_connections.push((ref_node_id, opa.index, inlet_idx));
401                        }
402                        Expr::Tuple(_) => {
403                            // Recursively resolve nested tuple expressions
404                            let (nested_id, nested_outlet) = self.resolve_expr(&arg.value)?;
405                            ref_connections.push((nested_id, nested_outlet, inlet_idx));
406                        }
407                    }
408                }
409
410                // Check the Abstraction registry.
411                // If object (name before alias resolution) is registered in the registry,
412                // determine numinlets/numoutlets from its interface.
413                // However, if the name changed through alias resolution (sub->-, add->+, etc.),
414                // it is a built-in object and not treated as an Abstraction.
415                let abstraction_info = if max_name == object {
416                    self.registry.and_then(|reg| reg.lookup(object))
417                } else {
418                    // Alias-resolved name — this is a built-in Max object, not an abstraction
419                    None
420                };
421
422                // E009: Abstraction argument count check
423                if let Some(iface) = abstraction_info {
424                    let expected = iface.in_ports.len();
425                    let got = args.len();
426                    if expected != got {
427                        return Err(BuildError::AbstractionArgCountMismatch {
428                            name: object.clone(),
429                            expected,
430                            got,
431                        });
432                    }
433                }
434
435                // For gen~/mc.gen~/rnbo~, literal name args are part of the object
436                // text rather than inlets, so subtract them from the arg count.
437                let effective_arg_count = if has_name_arg {
438                    (args.len() as u32).saturating_sub(lit_count)
439                } else {
440                    args.len() as u32
441                };
442
443                // Estimate inlet/outlet count
444                let (max_inlet, num_outlets, is_signal) = if let Some(iface) = abstraction_info {
445                    // Abstraction: determined from interface
446                    let num_in = iface.in_ports.len() as u32;
447                    let num_out = iface.out_ports.len() as u32;
448                    let sig = iface
449                        .out_ports
450                        .first()
451                        .map(|p| p.port_type.is_signal())
452                        .unwrap_or(false);
453                    // Inlet count: use at least the number of interface in_ports
454                    let max_from_refs = ref_connections
455                        .iter()
456                        .map(|(_, _, inlet)| *inlet + 1)
457                        .max()
458                        .unwrap_or(0);
459                    let inlets =
460                        std::cmp::max(std::cmp::max(max_from_refs, effective_arg_count), num_in);
461                    (inlets, num_out, sig)
462                } else {
463                    // Normal Max object
464                    let inlet_count = if ref_connections.is_empty() && lit_args.is_empty() {
465                        infer_num_inlets(max_name, &lit_args, self.objdb)
466                    } else {
467                        let max_from_refs = ref_connections
468                            .iter()
469                            .map(|(_, _, inlet)| *inlet + 1)
470                            .max()
471                            .unwrap_or(0);
472                        std::cmp::max(
473                            std::cmp::max(max_from_refs, effective_arg_count),
474                            infer_num_inlets(max_name, &lit_args, self.objdb),
475                        )
476                    };
477                    let outlet_count = infer_num_outlets(max_name, &lit_args, self.objdb);
478                    (inlet_count, outlet_count, is_signal)
479                };
480
481                // For Abstractions, use the name before alias resolution.
482                // Max references the filename as the object name, like `[oscillator 440]`.
483                let object_name = if abstraction_info.is_some() {
484                    object.to_string()
485                } else {
486                    max_name.to_string()
487                };
488
489                let mut node = PatchNode {
490                    id: id.clone(),
491                    object_name: object_name.clone(),
492                    args: lit_args.clone(),
493                    num_inlets: max_inlet,
494                    num_outlets,
495                    is_signal,
496                    varname: None,
497                    hot_inlets: default_hot_inlets(&object_name, max_inlet),
498                    purity: classify_purity(&object_name),
499                    attrs: vec![],
500                    code: None,
501                };
502
503                // Codebox: resolve code file reference and infer port counts
504                if matches!(max_name, "v8.codebox" | "codebox") {
505                    if let Some(code_files) = self.code_files {
506                        if let Some(filename) = lit_args.first() {
507                            if let Some(code_content) = code_files.get(filename.as_str()) {
508                                node.code = Some(code_content.clone());
509                                node.args.clear();
510                                // gen~ codebox: infer inlet/outlet counts from in1..inN / out1..outN
511                                if max_name == "codebox" {
512                                    let (inlets, outlets) = infer_codebox_ports(code_content);
513                                    node.num_inlets = inlets;
514                                    node.num_outlets = outlets;
515                                }
516                            }
517                        }
518                    }
519                }
520
521                self.graph.add_node(node);
522
523                // Create edges from Ref arguments
524                for (source_id, source_outlet, dest_inlet) in ref_connections {
525                    self.graph.add_edge(PatchEdge {
526                        source_id,
527                        source_outlet,
528                        dest_id: id.clone(),
529                        dest_inlet,
530                        is_feedback: false,
531                        order: None,
532                    });
533                }
534
535                Ok((id, 0))
536            }
537            Expr::Lit(lit) => {
538                // Generate literal expression as message/number box node
539                let id = self.gen_id();
540                let (object_name, arg_str, is_signal) = match lit {
541                    LitValue::Int(v) => ("message".to_string(), v.to_string(), false),
542                    LitValue::Float(_) => ("message".to_string(), format_lit(lit), false),
543                    LitValue::Str(s) => ("message".to_string(), s.clone(), false),
544                };
545                let node = PatchNode {
546                    id: id.clone(),
547                    object_name,
548                    args: vec![arg_str],
549                    num_inlets: 1,
550                    num_outlets: 1,
551                    is_signal,
552                    varname: None,
553                    hot_inlets: default_hot_inlets("message", 1),
554                    purity: classify_purity("message"),
555                    attrs: vec![],
556                    code: None,
557                };
558                self.graph.add_node(node);
559                Ok((id, 0))
560            }
561            Expr::OutputPortAccess(opa) => {
562                // output_port_access: resolve name.out[N]
563                let (node_id, _) = self
564                    .name_map
565                    .get(&opa.object)
566                    .ok_or_else(|| BuildError::UndefinedRef(opa.object.clone()))?
567                    .clone();
568                Ok((node_id, opa.index))
569            }
570            Expr::Tuple(elements) => {
571                let id = self.gen_id();
572                let num_elements = elements.len() as u32;
573
574                // Resolve each element and create edges
575                let mut ref_connections: Vec<(String, u32, u32)> = Vec::new();
576                let mut type_args: Vec<String> = Vec::new();
577                for (i, elem) in elements.iter().enumerate() {
578                    let (elem_id, elem_outlet) = self.resolve_expr(elem)?;
579                    ref_connections.push((elem_id, elem_outlet, i as u32));
580                    // Infer type from expression to determine type argument
581                    type_args.push(infer_pack_type_arg(elem));
582                }
583
584                let node = PatchNode {
585                    id: id.clone(),
586                    object_name: "pack".to_string(),
587                    args: type_args,
588                    num_inlets: num_elements,
589                    num_outlets: 1,
590                    is_signal: false,
591                    varname: None,
592                    hot_inlets: default_hot_inlets("pack", num_elements),
593                    purity: classify_purity("pack"),
594                    attrs: vec![],
595                    code: None,
596                };
597                self.graph.add_node(node);
598
599                for (source_id, source_outlet, dest_inlet) in ref_connections {
600                    self.graph.add_edge(PatchEdge {
601                        source_id,
602                        source_outlet,
603                        dest_id: id.clone(),
604                        dest_inlet,
605                        is_feedback: false,
606                        order: None,
607                    });
608                }
609
610                Ok((id, 0))
611            }
612        }
613    }
614
615    /// Process a DestructuringWire.
616    /// Resolve the value expression and map each name to a different outlet of that node.
617    ///
618    /// For `wire (a, b) = unpack(coords);`:
619    ///   - `unpack(coords)` generates an unpack node
620    ///   - a → (unpack_id, 0), b → (unpack_id, 1)
621    ///
622    /// For `wire (a, b) = some_ref;`:
623    ///   - After resolving some_ref, automatically insert an unpack node
624    ///   - a → (unpack_id, 0), b → (unpack_id, 1)
625    fn add_destructuring_wire(&mut self, dw: &DestructuringWire) -> Result<(), BuildError> {
626        let (source_id, _source_outlet) = self.resolve_expr(&dw.value)?;
627        let num_names = dw.names.len() as u32;
628
629        // E006: Destructuring count check
630        // If the RHS node num_outlets is known (not the default 1),
631        // check if it matches the LHS name count.
632        let resolved_node = self.graph.nodes.iter().find(|n| n.id == source_id);
633        if let Some(node) = resolved_node {
634            let outlet_count = node.num_outlets;
635            // If default value is 1, treat as unknown and skip
636            let is_known = outlet_count != 1
637                || node.object_name == "unpack"
638                || node.object_name == "inlet"
639                || node.object_name == "inlet~";
640            if is_known && outlet_count != num_names {
641                return Err(BuildError::DestructuringCountMismatch {
642                    expected: outlet_count as usize,
643                    got: num_names as usize,
644                });
645            }
646        }
647
648        // Check if the resolved expression is already unpack (or has enough outlets)
649        let source_has_enough_outlets = resolved_node
650            .map(|n| n.num_outlets >= num_names)
651            .unwrap_or(false);
652
653        let target_id = if source_has_enough_outlets {
654            // If the resolved node has enough outlets, map directly
655            source_id.clone()
656        } else {
657            // Auto-insert unpack node
658            let id = self.gen_id();
659            // If source is from a tuple, use its type arguments
660            let type_args = self.lookup_tuple_type_args(&dw.value, num_names);
661
662            let node = PatchNode {
663                id: id.clone(),
664                object_name: "unpack".to_string(),
665                args: type_args,
666                num_inlets: 1,
667                num_outlets: num_names,
668                is_signal: false,
669                varname: None,
670                hot_inlets: default_hot_inlets("unpack", 1),
671                purity: classify_purity("unpack"),
672                attrs: vec![],
673                code: None,
674            };
675            self.graph.add_node(node);
676
677            self.graph.add_edge(PatchEdge {
678                source_id,
679                source_outlet: _source_outlet,
680                dest_id: id.clone(),
681                dest_inlet: 0,
682                is_feedback: false,
683                order: None,
684            });
685
686            id
687        };
688
689        // Map each name to its corresponding outlet
690        for (i, name) in dw.names.iter().enumerate() {
691            self.name_map
692                .insert(name.clone(), (target_id.clone(), i as u32));
693            self.destructured_names.insert(name.clone());
694        }
695
696        Ok(())
697    }
698
699    /// Look up tuple type arguments from the destructuring source.
700    ///
701    /// If source is `Expr::Ref(name)` and `name` is from a tuple,
702    /// return the recorded type arguments.
703    /// If source is `Expr::Call { object: "unpack", args: [Expr::Ref(name)] }` and
704    /// `name` is from a tuple, return similarly.
705    /// Falls back to all elements "f" if not found.
706    fn lookup_tuple_type_args(&self, value: &Expr, num_names: u32) -> Vec<String> {
707        let source_name = match value {
708            Expr::Ref(name) => Some(name.as_str()),
709            Expr::Call { object, args } if object == "unpack" => args.first().and_then(|arg| {
710                if let Expr::Ref(name) = &arg.value {
711                    Some(name.as_str())
712                } else {
713                    None
714                }
715            }),
716            _ => None,
717        };
718        if let Some(name) = source_name {
719            if let Some(type_args) = self.tuple_type_args.get(name) {
720                return type_args.clone();
721            }
722        }
723        (0..num_names).map(|_| "f".to_string()).collect()
724    }
725
726    /// Process a FeedbackDecl.
727    ///
728    /// Forward-declare the feedback name and register it in name_map.
729    /// The actual tapin~ node is generated during feedback_assignment.
730    /// Here we tentatively register the feedback name in name_map (so tapout~ can reference it later).
731    fn add_feedback_decl(&mut self, decl: &FeedbackDecl) {
732        // Register feedback name in feedback_map (tapin~ node ID is determined at assignment)
733        // Insert dummy entry in name_map to make it referenceable
734        // When fb is referenced in tapout~(fb, delay), a connection to the tapin~ node is needed
735        // Generate the tapin~ node first and register it in name_map
736        let tapin_id = self.gen_id();
737        let node = PatchNode {
738            id: tapin_id.clone(),
739            object_name: "tapin~".to_string(),
740            args: vec![],
741            num_inlets: 1,
742            num_outlets: 1,
743            is_signal: true,
744            varname: None,
745            hot_inlets: default_hot_inlets("tapin~", 1),
746            purity: classify_purity("tapin~"),
747            attrs: vec![],
748            code: None,
749        };
750        self.graph.add_node(node);
751        self.feedback_map
752            .insert(decl.name.clone(), tapin_id.clone());
753        // Map tapin~ outlet 0 to the feedback name
754        // When fb is referenced in tapout~(fb, 500), an edge from tapin~ outlet 0 -> tapout~ inlet 0 is created
755        self.name_map.insert(decl.name.clone(), (tapin_id, 0));
756    }
757
758    /// Process a FeedbackAssignment.
759    ///
760    /// Evaluate the assignment value as in `feedback fb = tapin~(mixed, 1000);`
761    /// and connect it to tapin~ node inlet 0.
762    fn add_feedback_assignment(&mut self, assign: &FeedbackAssignment) -> Result<(), BuildError> {
763        // E013: Duplicate assignment check
764        if !self.assigned_feedbacks.insert(assign.target.clone()) {
765            return Err(BuildError::DuplicateFeedbackAssignment(
766                assign.target.clone(),
767            ));
768        }
769
770        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
771
772        // Get tapin~ node ID
773        if let Some(tapin_id) = self.feedback_map.get(&assign.target).cloned() {
774            // Connection source -> tapin~ (feedback edge)
775            self.graph.add_edge(PatchEdge {
776                source_id,
777                source_outlet,
778                dest_id: tapin_id,
779                dest_inlet: 0,
780                is_feedback: true,
781                order: None,
782            });
783        }
784
785        Ok(())
786    }
787
788    /// Process a StateDecl.
789    ///
790    /// `state counter: int = 0;` -> Max `[int 0]` node
791    /// `state volume: float = 0.5;` -> Max `[float 0.5]` node
792    fn add_state_decl(&mut self, decl: &StateDecl) -> Result<(), BuildError> {
793        let id = self.gen_id();
794
795        let (object_name, init_arg) = match decl.port_type {
796            PortType::Int => (
797                "int".to_string(),
798                match &decl.init_value {
799                    Expr::Lit(LitValue::Int(v)) => v.to_string(),
800                    Expr::Lit(LitValue::Float(v)) => format!("{}", *v as i64),
801                    _ => "0".to_string(),
802                },
803            ),
804            PortType::Float => (
805                "float".to_string(),
806                match &decl.init_value {
807                    Expr::Lit(LitValue::Float(v)) => format_lit(&LitValue::Float(*v)),
808                    Expr::Lit(LitValue::Int(v)) => format!("{}.", v),
809                    _ => "0.".to_string(),
810                },
811            ),
812            // Use int as fallback for Bang, List, Symbol
813            _ => ("int".to_string(), "0".to_string()),
814        };
815
816        let node = PatchNode {
817            id: id.clone(),
818            object_name: object_name.clone(),
819            args: vec![init_arg],
820            num_inlets: 2, // inlet 0 = hot (bang/output), inlet 1 = cold (set value)
821            num_outlets: 1,
822            is_signal: false,
823            varname: Some(decl.name.clone()),
824            hot_inlets: vec![true, false], // inlet 0 hot, inlet 1 cold
825            purity: classify_purity(&object_name),
826            attrs: vec![],
827            code: None,
828        };
829        self.graph.add_node(node);
830
831        // Register state name in name_map
832        self.name_map.insert(decl.name.clone(), (id, 0));
833
834        Ok(())
835    }
836
837    /// Process a StateAssignment.
838    ///
839    /// `state counter = next;` -> connect `next` output to state node inlet 1 (cold)
840    fn add_state_assignment(&mut self, assign: &StateAssignment) -> Result<(), BuildError> {
841        // E019: Duplicate assignment check
842        if !self.assigned_states.insert(assign.name.clone()) {
843            return Err(BuildError::DuplicateStateAssignment(assign.name.clone()));
844        }
845
846        // Get node from state name
847        let (state_node_id, _) = self
848            .name_map
849            .get(&assign.name)
850            .ok_or_else(|| BuildError::UndefinedRef(assign.name.clone()))?
851            .clone();
852
853        // Resolve the expression
854        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
855
856        // Connect source -> state node inlet 1 (cold inlet)
857        self.graph.add_edge(PatchEdge {
858            source_id,
859            source_outlet,
860            dest_id: state_node_id,
861            dest_inlet: 1, // cold inlet for value update
862            is_feedback: false,
863            order: None,
864        });
865
866        Ok(())
867    }
868
869    /// Process a DirectConnection.
870    ///
871    /// Process direct connections like `node.in[N] = expr;`,
872    /// connecting the expression output to the specified inlet of the target node.
873    fn add_direct_connection(&mut self, conn: &DirectConnection) -> Result<(), BuildError> {
874        let target_name = &conn.target.object;
875        let index = conn.target.index;
876
877        // Look up node from wire name
878        let (node_id, _) = self
879            .name_map
880            .get(target_name)
881            .ok_or_else(|| BuildError::UndefinedRef(target_name.clone()))?
882            .clone();
883
884        // Get node numinlets and validate port index
885        // If index is out of range, expand the node numinlets
886        // (can occur with back-edge direct_connections generated by the decompiler)
887        if let Some(node) = self.graph.find_node_mut(&node_id) {
888            if index >= node.num_inlets {
889                node.num_inlets = index + 1;
890            }
891        }
892
893        // Resolve the expression and create the edge
894        let (source_id, source_outlet) = self.resolve_expr(&conn.value)?;
895
896        self.graph.add_edge(PatchEdge {
897            source_id,
898            source_outlet,
899            dest_id: node_id,
900            dest_inlet: index,
901            is_feedback: false,
902            order: None,
903        });
904
905        Ok(())
906    }
907}
908
909/// Infer pack type argument for a tuple element from an expression.
910///
911/// - `Expr::Lit(Int(_))` → `"i"`
912/// - `Expr::Lit(Float(_))` → `"f"`
913/// - `Expr::Lit(Str(_))` → `"s"`
914/// - Others -> `"f"` fallback (cannot determine without type context)
915fn infer_pack_type_arg(expr: &Expr) -> String {
916    match expr {
917        Expr::Lit(LitValue::Int(_)) => "i".to_string(),
918        Expr::Lit(LitValue::Float(_)) => "f".to_string(),
919        Expr::Lit(LitValue::Str(_)) => "s".to_string(),
920        _ => "f".to_string(), // Ref, Call, OutputPortAccess, Tuple -> fallback
921    }
922}
923
924/// Classify purity from object name.
925fn classify_purity(object_name: &str) -> NodePurity {
926    match object_name {
927        // Signal objects are generally Pure (with exceptions)
928        name if name.ends_with('~') => match name {
929            "tapin~" | "tapout~" | "line~" | "delay~" | "phasor~" | "count~" | "index~"
930            | "buffer~" | "groove~" | "play~" | "record~" | "sfplay~" | "sfrecord~" | "sig~" => {
931                NodePurity::Stateful
932            }
933            _ => NodePurity::Pure,
934        },
935        // Known stateful Control objects
936        "pack" | "unpack" | "int" | "float" | "toggle" | "gate" | "counter" | "message" | "zl"
937        | "coll" | "dict" | "regexp" | "value" | "table" | "funbuff" | "bag" | "borax"
938        | "bucket" | "histo" | "mousestate" | "spray" | "switch" | "if" | "expr" | "vexpr"
939        | "button" | "number" | "flonum" | "slider" | "dial" | "umenu" | "preset" | "pattr"
940        | "autopattr" | "pattrstorage" => NodePurity::Stateful,
941        // Known pure Control objects
942        "+" | "-" | "*" | "/" | "%" | "trigger" | "t" | "route" | "select" | "prepend"
943        | "append" | "stripnote" | "makenote" | "scale" | "split" | "swap" | "clip" | "minimum"
944        | "maximum" | "inlet" | "inlet~" | "outlet" | "outlet~" | "loadbang" | "print" | "send"
945        | "receive" | "forward" | "ezdac~" | "dac~" | "adc~" => NodePurity::Pure,
946        _ => NodePurity::Unknown,
947    }
948}
949
950/// Generate default hot/cold inlets from object name and inlet count.
951/// Max default rule: inlet 0 is hot, others are cold.
952fn default_hot_inlets(_object_name: &str, num_inlets: u32) -> Vec<bool> {
953    if num_inlets == 0 {
954        return vec![];
955    }
956    // trigger has only inlet 0 as hot (no other inlets)
957    // Most objects have inlet 0 as hot, the rest as cold
958    (0..num_inlets).map(|i| i == 0).collect()
959}
960
961/// Assign order to fanout edges.
962/// Assign 0, 1, 2... when multiple edges share the same (source_id, source_outlet).
963/// Remain None for single edges.
964fn assign_edge_orders(graph: &mut PatchGraph) {
965    use std::collections::HashMap;
966
967    // Group edge indices by (source_id, source_outlet)
968    let mut groups: HashMap<(String, u32), Vec<usize>> = HashMap::new();
969    for (i, edge) in graph.edges.iter().enumerate() {
970        let key = (edge.source_id.clone(), edge.source_outlet);
971        groups.entry(key).or_default().push(i);
972    }
973
974    // Only assign order to groups with 2+ edges
975    for indices in groups.values() {
976        if indices.len() >= 2 {
977            for (order, &edge_idx) in indices.iter().enumerate() {
978                graph.edges[edge_idx].order = Some(order as u32);
979            }
980        }
981    }
982}
983
984/// Convert LitValue to string.
985fn format_lit(lit: &LitValue) -> String {
986    match lit {
987        LitValue::Int(v) => v.to_string(),
988        LitValue::Float(v) => {
989            // Float always preserves the decimal point. In Max, 1. (float) and 1 (int) have different meanings.
990            // e.g., [* 1.] is float multiplication, [* 1] is int multiplication.
991            if v.fract() == 0.0 {
992                format!("{}.", *v as i64)
993            } else {
994                format!("{}", v)
995            }
996        }
997        LitValue::Str(s) => s.clone(),
998    }
999}
1000
1001/// Convert AttrValue to string.
1002/// Used for Max `@key value` format and box JSON fields.
1003fn format_attr_value(val: &flutmax_ast::AttrValue) -> String {
1004    match val {
1005        flutmax_ast::AttrValue::Int(v) => v.to_string(),
1006        flutmax_ast::AttrValue::Float(v) => {
1007            // Max accepts trailing dot (e.g., "100.").
1008            // Format integer-like floats with trailing dot.
1009            if v.fract() == 0.0 {
1010                format!("{}.", *v as i64)
1011            } else {
1012                format!("{}", v)
1013            }
1014        }
1015        flutmax_ast::AttrValue::Str(s) => s.clone(),
1016        flutmax_ast::AttrValue::Ident(s) => s.clone(),
1017    }
1018}
1019
1020/// Convert flutmax aliases to Max object names.
1021/// Only converts arithmetic operators. Returns others as-is.
1022fn resolve_max_object_name(flutmax_name: &str) -> &str {
1023    match flutmax_name {
1024        "add" => "+",
1025        "sub" => "-",
1026        "mul" => "*",
1027        "dvd" => "/",
1028        "mod" => "%",
1029        "add~" => "+~",
1030        "sub~" => "-~",
1031        "mul~" => "*~",
1032        "dvd~" => "/~",
1033        "mod~" => "%~",
1034        // Reversed arithmetic
1035        "rsub" => "!-",
1036        "rdvd" => "!/",
1037        "rmod" => "!%",
1038        "rsub~" => "!-~",
1039        "rdvd~" => "!/~",
1040        "rmod~" => "!%~",
1041        // Comparison
1042        "gt" => ">",
1043        "lt" => "<",
1044        "gte" => ">=",
1045        "lte" => "<=",
1046        "eq" => "==",
1047        "neq" => "!=",
1048        "gt~" => ">~",
1049        "lt~" => "<~",
1050        "gte~" => ">=~",
1051        "lte~" => "<=~",
1052        "eq~" => "==~",
1053        "neq~" => "!=~",
1054        // Logical/bitwise
1055        "and" => "&&",
1056        "or" => "||",
1057        "lshift" => "<<",
1058        "rshift" => ">>",
1059        other => other,
1060    }
1061}
1062
1063/// Match named argument parameter name against objdb inlet definitions and return inlet index.
1064///
1065/// Returns `None` if not registered in objdb or if the name does not match.
1066/// Name matching is case-insensitive with spaces normalized to underscores.
1067fn resolve_inlet_name(object_name: &str, arg_name: &str, objdb: Option<&ObjectDb>) -> Option<u32> {
1068    let db = objdb?;
1069    let def = db.lookup(object_name)?;
1070    let inlets = match &def.inlets {
1071        InletSpec::Fixed(ports) => ports.as_slice(),
1072        InletSpec::Variable { defaults, .. } => defaults.as_slice(),
1073    };
1074    let arg_lower = arg_name.to_lowercase();
1075    for port in inlets {
1076        let normalized = normalize_port_description(&port.description);
1077        if let Some(ref n) = normalized {
1078            if *n == arg_lower {
1079                return Some(port.id);
1080            }
1081        }
1082    }
1083    None
1084}
1085
1086/// Normalize an objdb port description to a valid flutmax identifier.
1087///
1088/// This must match the normalization in `flutmax-decompile`'s `normalize_inlet_name`
1089/// to ensure roundtrip consistency (decompile → named args → compile → resolve).
1090fn normalize_port_description(description: &str) -> Option<String> {
1091    let trimmed = description.trim();
1092    // Strip leading type prefix like "(signal)", "(signal/float)", "(float)"
1093    let stripped = if trimmed.starts_with('(') {
1094        if let Some(end) = trimmed.find(')') {
1095            trimmed[end + 1..].trim()
1096        } else {
1097            trimmed
1098        }
1099    } else {
1100        trimmed
1101    };
1102    let s: String = stripped
1103        .to_lowercase()
1104        .chars()
1105        .map(|c| if c == ' ' { '_' } else { c })
1106        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1107        .collect();
1108    let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
1109    let result = parts.join("_");
1110    let result = result
1111        .trim_start_matches(|c: char| c.is_ascii_digit())
1112        .to_string();
1113    if result.is_empty() || result.len() > 20 {
1114        None
1115    } else {
1116        Some(result)
1117    }
1118}
1119
1120/// Resolve a named argument against the AbstractionRegistry.
1121///
1122/// Abstractions define their inlets via `in freq: float;` declarations.
1123/// This function matches the argument name against those declarations.
1124fn resolve_abstraction_inlet_name(
1125    object_name: &str,
1126    arg_name: &str,
1127    registry: Option<&AbstractionRegistry>,
1128) -> Option<u32> {
1129    let reg = registry?;
1130    let iface = reg.lookup(object_name)?;
1131    let arg_lower = arg_name.to_lowercase();
1132    for port in &iface.in_ports {
1133        if port.name.to_lowercase() == arg_lower {
1134            return Some(port.index);
1135        }
1136    }
1137    None
1138}
1139
1140/// Estimate inlet count from object name and arguments.
1141/// Prioritizes objdb when provided; falls back to hardcoded table for unregistered objects.
1142/// Variable-inlet objects are inferred from argument count.
1143fn infer_num_inlets(object_name: &str, args: &[String], objdb: Option<&ObjectDb>) -> u32 {
1144    // Prioritize objdb lookup
1145    if let Some(db) = objdb {
1146        if let Some(def) = db.lookup(object_name) {
1147            return match &def.inlets {
1148                InletSpec::Fixed(ports) => ports.len() as u32,
1149                InletSpec::Variable {
1150                    defaults,
1151                    min_inlets,
1152                } => {
1153                    if args.is_empty() {
1154                        defaults.len().max(*min_inlets as usize) as u32
1155                    } else {
1156                        args.len() as u32
1157                    }
1158                }
1159            };
1160        }
1161    }
1162    // Hardcoded fallback
1163    match object_name {
1164        // Signal arithmetic
1165        "cycle~" => 2,
1166        "*~" | "+~" | "-~" | "/~" | "%~" | "!-~" | "!/~" | "!%~" => 2,
1167        ">~" | "<~" | ">=~" | "<=~" | "==~" | "!=~" => 2,
1168        // Control arithmetic
1169        "*" | "+" | "-" | "/" | "%" | "!-" | "!/" | "!%" => 2,
1170        ">" | "<" | ">=" | "<=" | "==" | "!=" => 2,
1171        "&&" | "||" | "<<" | ">>" => 2,
1172        // Audio I/O
1173        "ezdac~" => 2,
1174        "dac~" => 2,
1175        "adc~" => 0,
1176        // Triggers / UI
1177        "loadbang" => 1,
1178        "button" => 1,
1179        "print" => 1,
1180        // Signal processing
1181        "biquad~" => 6,
1182        "line~" => 2,
1183        "tapin~" => 1,
1184        "tapout~" => 2,
1185        "noise~" | "phasor~" => 1,
1186        "snapshot~" | "peakamp~" | "meter~" => 1,
1187        "edge~" => 1,
1188        "dspstate~" => 1,
1189        "fftinfo~" => 1,
1190        "fftin~" => 1,
1191        "fftout~" => 1,
1192        "cartopol~" | "poltocar~" => 2,
1193        "freqshift~" => 2,
1194        "curve~" => 2,
1195        "adsr~" => 5,
1196        "filtercoeff~" => 4,
1197        "filtergraph~" => 8,
1198        // Data
1199        "int" | "float" => 2,
1200        "inlet" | "inlet~" => 0,
1201        "outlet" | "outlet~" => 1,
1202        // Variable inlets (arg-dependent)
1203        "trigger" | "t" => 1,
1204        "select" | "sel" => {
1205            if args.is_empty() {
1206                2
1207            } else {
1208                1
1209            }
1210        }
1211        "route" => 1,
1212        "gate" => 2,
1213        "pack" | "pak" => {
1214            if args.is_empty() {
1215                2
1216            } else {
1217                args.len() as u32
1218            }
1219        }
1220        "unpack" => 1,
1221        "buddy" => {
1222            if args.is_empty() {
1223                2
1224            } else {
1225                args.first()
1226                    .and_then(|a| a.parse::<u32>().ok())
1227                    .unwrap_or(2)
1228            }
1229        }
1230        // MIDI
1231        "makenote" => 3,
1232        "notein" => 1,
1233        "noteout" => 3,
1234        "ctlin" => 1,
1235        "ctlout" => 3,
1236        "midiin" => 1,
1237        "midiout" => 1,
1238        "borax" => 1,
1239        // RNBO / gen~ I/O ports
1240        "param" => 2,
1241        "in~" => 1,
1242        "out~" => 1,
1243        "inport" => 1,
1244        "outport" => 1,
1245        // Timing / control
1246        "line" => 2,
1247        "function" => 2,
1248        "counter" => 3,
1249        "metro" => 2,
1250        "delay" => 2,
1251        "pipe" => {
1252            if args.is_empty() {
1253                2
1254            } else {
1255                args.len() as u32 + 1
1256            }
1257        }
1258        "speedlim" => 2,
1259        "thresh" => 2,
1260        // Data structures
1261        "coll" => 1,
1262        "urn" => 2,
1263        "drunk" => 2,
1264        "random" => 2,
1265        // List / string
1266        "match" => 1,
1267        "zl" => 2,
1268        "regexp" => 1,
1269        "sprintf" => {
1270            if args.is_empty() {
1271                1
1272            } else {
1273                args.len() as u32
1274            }
1275        }
1276        "fromsymbol" => 1,
1277        "tosymbol" => 1,
1278        "iter" => 1,
1279        // Codebox
1280        "v8.codebox" => 1,
1281        "codebox" => 1,
1282        // gen~ ternary conditional operator
1283        "?" => 3,
1284        _ => 1,
1285    }
1286}
1287
1288/// Estimate outlet count from object name and arguments.
1289/// Prioritizes objdb when provided; falls back to hardcoded table for unregistered objects.
1290/// Variable-outlet objects are dynamically inferred from argument count.
1291fn infer_num_outlets(object_name: &str, args: &[String], objdb: Option<&ObjectDb>) -> u32 {
1292    // Prioritize objdb lookup
1293    if let Some(db) = objdb {
1294        if let Some(def) = db.lookup(object_name) {
1295            return match &def.outlets {
1296                OutletSpec::Fixed(ports) => ports.len() as u32,
1297                OutletSpec::Variable {
1298                    defaults,
1299                    min_outlets,
1300                } => {
1301                    if args.is_empty() {
1302                        defaults.len().max(*min_outlets as usize) as u32
1303                    } else {
1304                        args.len() as u32
1305                    }
1306                }
1307            };
1308        }
1309    }
1310    // Hardcoded fallback
1311    match object_name {
1312        // Signal processing
1313        "cycle~" => 1,
1314        "*~" | "+~" | "-~" | "/~" => 1,
1315        "biquad~" => 1,
1316        "line~" => 2,
1317        "tapin~" => 1,
1318        "tapout~" => 1,
1319        "noise~" | "phasor~" => 1,
1320        "snapshot~" | "peakamp~" | "meter~" => 1,
1321        "edge~" => 2,
1322        "dspstate~" => 4,
1323        "fftinfo~" => 4,
1324        "fftin~" => 3,
1325        "fftout~" => 1,
1326        "cartopol~" | "poltocar~" => 2,
1327        "freqshift~" => 2,
1328        "curve~" => 2,
1329        "adsr~" => 4,
1330        "filtercoeff~" => 5,
1331        "filtergraph~" => 7,
1332        // Control arithmetic
1333        "*" | "+" | "-" | "/" | "%" => 1,
1334        // Audio I/O
1335        "ezdac~" | "dac~" => 0,
1336        "adc~" => 1,
1337        // Triggers / UI
1338        "loadbang" => 1,
1339        "button" => 1,
1340        "print" => 0,
1341        // Data
1342        "int" | "float" => 1,
1343        "inlet" | "inlet~" => 1,
1344        "outlet" | "outlet~" => 0,
1345        // Variable outlets (arg-dependent)
1346        "select" | "sel" => {
1347            if args.is_empty() {
1348                2
1349            } else {
1350                args.len() as u32 + 1
1351            }
1352        }
1353        "route" => {
1354            if args.is_empty() {
1355                2
1356            } else {
1357                args.len() as u32 + 1
1358            }
1359        }
1360        "gate" => args
1361            .first()
1362            .and_then(|a| a.parse::<u32>().ok())
1363            .unwrap_or(2),
1364        "trigger" | "t" => {
1365            if args.is_empty() {
1366                1
1367            } else {
1368                args.len() as u32
1369            }
1370        }
1371        "unpack" => {
1372            if args.is_empty() {
1373                2
1374            } else {
1375                args.len() as u32
1376            }
1377        }
1378        "pack" | "pak" => 1,
1379        "buddy" => {
1380            if args.is_empty() {
1381                2
1382            } else {
1383                args.first()
1384                    .and_then(|a| a.parse::<u32>().ok())
1385                    .unwrap_or(2)
1386            }
1387        }
1388        // Timing / control
1389        "function" => 2,
1390        "line" => 2,
1391        "counter" => 4,
1392        "metro" => 1,
1393        "delay" => 1,
1394        "pipe" => {
1395            if args.is_empty() {
1396                1
1397            } else {
1398                args.len() as u32
1399            }
1400        }
1401        "speedlim" => 1,
1402        "thresh" => 2,
1403        // MIDI
1404        "makenote" => 2,
1405        "borax" => 8,
1406        "notein" => 3,
1407        "noteout" => 0,
1408        "ctlin" => 3,
1409        "ctlout" => 0,
1410        "midiin" => 1,
1411        "midiout" => 0,
1412        // RNBO / gen~ I/O ports
1413        "param" => 2,
1414        "in~" => 1,
1415        "out~" => 0,
1416        "inport" => 1,
1417        "outport" => 0,
1418        // Data structures
1419        "coll" => 4,
1420        "urn" => 2,
1421        "drunk" => 1,
1422        "random" => 1,
1423        // List / string / pattern
1424        "match" => 2,
1425        "zl" => 2,
1426        "regexp" => 5,
1427        "sprintf" => 1,
1428        "fromsymbol" => 1,
1429        "tosymbol" => 1,
1430        "iter" => 1,
1431        // UI objects
1432        "textbutton" => 3,
1433        "live.text" => 2,
1434        "live.dial" => 2,
1435        "live.toggle" => 1,
1436        "live.menu" => 3,
1437        "live.numbox" => 2,
1438        "live.tab" => 3,
1439        "live.comment" => 0,
1440        "umenu" => 3,
1441        "flonum" => 2,
1442        "number" => 2,
1443        "slider" | "dial" | "rslider" => 1,
1444        "multislider" | "kslider" => 2,
1445        "tab" => 3,
1446        "toggle" => 1,
1447        // Codebox
1448        "v8.codebox" => 1,
1449        "codebox" => 1,
1450        _ => 1,
1451    }
1452}
1453
1454/// Infer inlet/outlet count from gen~ codebox code.
1455///
1456/// GenExpr code references inputs with `in1`, `in2`, ..., `inN`,
1457/// and defines outputs with `out1`, `out2`, ..., `outN`.
1458/// Detects the maximum N and returns inlet/outlet counts.
1459fn infer_codebox_ports(code: &str) -> (u32, u32) {
1460    let mut max_in: u32 = 0;
1461    let mut max_out: u32 = 0;
1462
1463    // Scan for in1..inN and out1..outN patterns
1464    // Use simple byte scanning to avoid regex dependency
1465    let bytes = code.as_bytes();
1466    let len = bytes.len();
1467    let mut i = 0;
1468    while i < len {
1469        // Check for "in" or "out" at word boundary
1470        let at_word_start = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
1471        if at_word_start {
1472            if i + 2 < len && bytes[i] == b'o' && bytes[i + 1] == b'u' && bytes[i + 2] == b't' {
1473                // Parse "outN"
1474                let mut j = i + 3;
1475                let mut num: u32 = 0;
1476                let mut has_digit = false;
1477                while j < len && bytes[j].is_ascii_digit() {
1478                    num = num * 10 + (bytes[j] - b'0') as u32;
1479                    has_digit = true;
1480                    j += 1;
1481                }
1482                // Must have digits and NOT be followed by alphanumeric (word boundary)
1483                if has_digit && (j >= len || !bytes[j].is_ascii_alphanumeric()) && num > max_out {
1484                    max_out = num;
1485                }
1486                i = j;
1487                continue;
1488            } else if i + 1 < len && bytes[i] == b'i' && bytes[i + 1] == b'n' {
1489                // Parse "inN" — but not "int", "into", etc.
1490                let mut j = i + 2;
1491                let mut num: u32 = 0;
1492                let mut has_digit = false;
1493                while j < len && bytes[j].is_ascii_digit() {
1494                    num = num * 10 + (bytes[j] - b'0') as u32;
1495                    has_digit = true;
1496                    j += 1;
1497                }
1498                if has_digit && (j >= len || !bytes[j].is_ascii_alphanumeric()) && num > max_in {
1499                    max_in = num;
1500                }
1501                if has_digit {
1502                    i = j;
1503                    continue;
1504                }
1505            }
1506        }
1507        i += 1;
1508    }
1509
1510    // gen~ uses 1-based indexing: in1..inN means N inlets
1511    (max_in.max(1), max_out.max(1))
1512}
1513
1514/// Convert Program (AST) to PatchGraph.
1515///
1516/// After conversion, calls `insert_triggers()` to auto-insert triggers for fanouts.
1517pub fn build_graph(program: &Program) -> Result<PatchGraph, BuildError> {
1518    build_graph_with_registry(program, None)
1519}
1520
1521/// Convert Program (AST) to PatchGraph (with Abstraction registry).
1522///
1523/// When `registry` is `Some` and the `Expr::Call` object name is
1524/// registered in the registry, `numinlets`/`numoutlets` are determined from its interface.
1525pub fn build_graph_with_registry(
1526    program: &Program,
1527    registry: Option<&AbstractionRegistry>,
1528) -> Result<PatchGraph, BuildError> {
1529    build_graph_with_code_files(program, registry, None)
1530}
1531
1532/// Convert Program (AST) to PatchGraph (with Abstraction registry + code files).
1533///
1534/// When `code_files` is `Some`, resolves `v8.codebox` and `codebox` filename arguments
1535/// to code content and stores it in `PatchNode.code`.
1536pub fn build_graph_with_code_files(
1537    program: &Program,
1538    registry: Option<&AbstractionRegistry>,
1539    code_files: Option<&CodeFiles>,
1540) -> Result<PatchGraph, BuildError> {
1541    build_graph_with_objdb(program, registry, code_files, None)
1542}
1543
1544/// Build options shared by the various `build_graph_*` entry points.
1545#[derive(Debug, Clone, Copy, Default)]
1546struct BuildOptions {
1547    /// When true, the trigger auto-insertion pass is skipped. This is required
1548    /// for the gen~ domain, which executes synchronously per-sample and has
1549    /// no `trigger` object.
1550    skip_triggers: bool,
1551}
1552
1553/// Convert Program (AST) to PatchGraph (with all parameters).
1554///
1555/// When `objdb` is `Some`, `infer_num_inlets`/`infer_num_outlets`
1556/// prioritize the object definition database, falling back to hardcoded tables for unregistered objects.
1557pub fn build_graph_with_objdb(
1558    program: &Program,
1559    registry: Option<&AbstractionRegistry>,
1560    code_files: Option<&CodeFiles>,
1561    objdb: Option<&ObjectDb>,
1562) -> Result<PatchGraph, BuildError> {
1563    build_graph_inner(
1564        program,
1565        registry,
1566        code_files,
1567        objdb,
1568        BuildOptions::default(),
1569    )
1570}
1571
1572/// Convert Program (AST) to PatchGraph without auto-inserting triggers.
1573///
1574/// gen~ executes synchronously per-sample, so triggers are unnecessary.
1575/// The trigger object does not exist in the gen~ domain — inserting one
1576/// would produce an invalid patch.
1577pub fn build_graph_without_triggers(program: &Program) -> Result<PatchGraph, BuildError> {
1578    build_graph_inner(
1579        program,
1580        None,
1581        None,
1582        None,
1583        BuildOptions {
1584            skip_triggers: true,
1585        },
1586    )
1587}
1588
1589/// Shared implementation behind `build_graph_with_objdb` and
1590/// `build_graph_without_triggers`. Walks the program in declaration order,
1591/// optionally inserts triggers, then assigns edge order.
1592fn build_graph_inner(
1593    program: &Program,
1594    registry: Option<&AbstractionRegistry>,
1595    code_files: Option<&CodeFiles>,
1596    objdb: Option<&ObjectDb>,
1597    options: BuildOptions,
1598) -> Result<PatchGraph, BuildError> {
1599    let mut builder = GraphBuilder::new(registry, code_files, objdb);
1600
1601    // 1. InDecl -> inlet nodes
1602    for decl in &program.in_decls {
1603        builder.add_inlet(decl);
1604    }
1605
1606    // 2. OutDecl -> outlet nodes
1607    for decl in &program.out_decls {
1608        builder.add_outlet(decl);
1609    }
1610
1611    // 2b. FeedbackDecl -> tapin~ nodes (forward declaration)
1612    for decl in &program.feedback_decls {
1613        builder.add_feedback_decl(decl);
1614    }
1615
1616    // 2c. StateDecl -> int/float nodes (forward declaration)
1617    for decl in &program.state_decls {
1618        builder.add_state_decl(decl)?;
1619    }
1620
1621    // 2d. MsgDecl -> message box nodes
1622    for decl in &program.msg_decls {
1623        builder.add_msg(decl);
1624    }
1625
1626    // 3. Wire -> object nodes + edges
1627    for wire in &program.wires {
1628        builder.add_wire(wire)?;
1629    }
1630
1631    // 3b. DestructuringWire -> unpack nodes + edges
1632    for dw in &program.destructuring_wires {
1633        builder.add_destructuring_wire(dw)?;
1634    }
1635
1636    // 3c. FeedbackAssignment -> connections to tapin~
1637    for assign in &program.feedback_assignments {
1638        builder.add_feedback_assignment(assign)?;
1639    }
1640
1641    // 3d. StateAssignment -> connections to state node cold inlets
1642    for assign in &program.state_assignments {
1643        builder.add_state_assignment(assign)?;
1644    }
1645
1646    // 4. OutAssignment -> edges
1647    for assign in &program.out_assignments {
1648        builder.add_out_assignment(assign)?;
1649    }
1650
1651    // 4a. OutDecl with inline value → implicit OutAssignment
1652    for decl in &program.out_decls {
1653        if let Some(ref value) = decl.value {
1654            let implicit_assign = OutAssignment {
1655                index: decl.index,
1656                value: value.clone(),
1657                span: None,
1658            };
1659            builder.add_out_assignment(&implicit_assign)?;
1660        }
1661    }
1662
1663    // 4b. DirectConnection -> edges
1664    for conn in &program.direct_connections {
1665        builder.add_direct_connection(conn)?;
1666    }
1667
1668    // 5. Auto-insert triggers (skipped for the gen~ domain).
1669    if !options.skip_triggers {
1670        insert_triggers(&mut builder.graph);
1671    }
1672
1673    // 6. Assign order to fanout edges
1674    assign_edge_orders(&mut builder.graph);
1675
1676    Ok(builder.graph)
1677}
1678
1679/// Convert Program (AST) to PatchGraph + warnings.
1680pub fn build_graph_with_warnings(program: &Program) -> Result<BuildResult, BuildError> {
1681    build_graph_with_registry_and_warnings(program, None)
1682}
1683
1684/// Convert Program (AST) to PatchGraph + warnings (with Abstraction registry).
1685pub fn build_graph_with_registry_and_warnings(
1686    program: &Program,
1687    registry: Option<&AbstractionRegistry>,
1688) -> Result<BuildResult, BuildError> {
1689    let graph = build_graph_with_registry(program, registry)?;
1690    let warnings = detect_duplicate_inlets(&graph);
1691    Ok(BuildResult { graph, warnings })
1692}
1693
1694/// Detect duplicate connections to the same inlet.
1695fn detect_duplicate_inlets(graph: &PatchGraph) -> Vec<BuildWarning> {
1696    let mut inlet_counts: HashMap<(String, u32), usize> = HashMap::new();
1697    for edge in &graph.edges {
1698        if !edge.is_feedback {
1699            *inlet_counts
1700                .entry((edge.dest_id.clone(), edge.dest_inlet))
1701                .or_insert(0) += 1;
1702        }
1703    }
1704    let mut warnings: Vec<BuildWarning> = inlet_counts
1705        .into_iter()
1706        .filter(|(_, count)| *count > 1)
1707        .map(
1708            |((node_id, inlet), count)| BuildWarning::DuplicateInletConnection {
1709                node_id,
1710                inlet,
1711                count,
1712            },
1713        )
1714        .collect();
1715    // Sort to make output order deterministic
1716    warnings.sort_by(|a, b| {
1717        let (a_id, a_inlet) = match a {
1718            BuildWarning::DuplicateInletConnection { node_id, inlet, .. } => (node_id, inlet),
1719        };
1720        let (b_id, b_inlet) = match b {
1721            BuildWarning::DuplicateInletConnection { node_id, inlet, .. } => (node_id, inlet),
1722        };
1723        a_id.cmp(b_id).then(a_inlet.cmp(b_inlet))
1724    });
1725    warnings
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730    use super::*;
1731    use flutmax_ast::*;
1732
1733    /// L1: `cycle~ 440` -> `ezdac~` (minimal patch)
1734    fn make_l1_program() -> Program {
1735        Program {
1736            in_decls: vec![],
1737            out_decls: vec![],
1738            wires: vec![Wire {
1739                name: "osc".to_string(),
1740                value: Expr::Call {
1741                    object: "cycle~".to_string(),
1742                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
1743                },
1744                span: None,
1745                attrs: vec![],
1746            }],
1747            destructuring_wires: vec![],
1748            msg_decls: vec![],
1749            out_assignments: vec![],
1750            direct_connections: vec![],
1751            feedback_decls: vec![],
1752            feedback_assignments: vec![],
1753            state_decls: vec![],
1754            state_assignments: vec![],
1755        }
1756    }
1757
1758    /// L2: `in freq: float → cycle~(freq) → *~(osc, 0.5) → out audio: signal`
1759    fn make_l2_program() -> Program {
1760        Program {
1761            in_decls: vec![InDecl {
1762                index: 0,
1763                name: "freq".to_string(),
1764                port_type: PortType::Float,
1765            }],
1766            out_decls: vec![OutDecl {
1767                index: 0,
1768                name: "audio".to_string(),
1769                port_type: PortType::Signal,
1770                value: None,
1771            }],
1772            wires: vec![
1773                Wire {
1774                    name: "osc".to_string(),
1775                    value: Expr::Call {
1776                        object: "cycle~".to_string(),
1777                        args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
1778                    },
1779                    span: None,
1780                    attrs: vec![],
1781                },
1782                Wire {
1783                    name: "amp".to_string(),
1784                    value: Expr::Call {
1785                        object: "mul~".to_string(),
1786                        args: vec![
1787                            CallArg::positional(Expr::Ref("osc".to_string())),
1788                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
1789                        ],
1790                    },
1791                    span: None,
1792                    attrs: vec![],
1793                },
1794            ],
1795            destructuring_wires: vec![],
1796            msg_decls: vec![],
1797            out_assignments: vec![OutAssignment {
1798                index: 0,
1799                value: Expr::Ref("amp".to_string()),
1800                span: None,
1801            }],
1802            direct_connections: vec![],
1803            feedback_decls: vec![],
1804            feedback_assignments: vec![],
1805            state_decls: vec![],
1806            state_assignments: vec![],
1807        }
1808    }
1809
1810    #[test]
1811    fn test_build_l1_nodes() {
1812        let prog = make_l1_program();
1813        let graph = build_graph(&prog).unwrap();
1814
1815        // One cycle~ node
1816        assert_eq!(graph.nodes.len(), 1);
1817        let node = &graph.nodes[0];
1818        assert_eq!(node.object_name, "cycle~");
1819        assert_eq!(node.args, vec!["440"]);
1820        assert!(node.is_signal);
1821        assert_eq!(node.num_inlets, 2);
1822        assert_eq!(node.num_outlets, 1);
1823    }
1824
1825    #[test]
1826    fn test_build_l1_no_edges() {
1827        let prog = make_l1_program();
1828        let graph = build_graph(&prog).unwrap();
1829
1830        // No edges since cycle~ is standalone
1831        assert_eq!(graph.edges.len(), 0);
1832    }
1833
1834    #[test]
1835    fn test_build_l2_nodes() {
1836        let prog = make_l2_program();
1837        let graph = build_graph(&prog).unwrap();
1838
1839        // 4 nodes: inlet, outlet~, cycle~, *~
1840        assert_eq!(graph.nodes.len(), 4);
1841
1842        let names: Vec<&str> = graph.nodes.iter().map(|n| n.object_name.as_str()).collect();
1843        assert!(names.contains(&"inlet"));
1844        assert!(names.contains(&"outlet~"));
1845        assert!(names.contains(&"cycle~"));
1846        assert!(names.contains(&"*~"));
1847    }
1848
1849    #[test]
1850    fn test_build_l2_edges() {
1851        let prog = make_l2_program();
1852        let graph = build_graph(&prog).unwrap();
1853
1854        // Edges: inlet->cycle~, cycle~->*~, *~->outlet~
1855        assert_eq!(graph.edges.len(), 3);
1856
1857        // inlet → cycle~ (inlet 0)
1858        let inlet_node = graph
1859            .nodes
1860            .iter()
1861            .find(|n| n.object_name == "inlet")
1862            .unwrap();
1863        let cycle_node = graph
1864            .nodes
1865            .iter()
1866            .find(|n| n.object_name == "cycle~")
1867            .unwrap();
1868        let inlet_to_cycle = graph
1869            .edges
1870            .iter()
1871            .find(|e| e.source_id == inlet_node.id && e.dest_id == cycle_node.id)
1872            .expect("edge from inlet to cycle~ should exist");
1873        assert_eq!(inlet_to_cycle.source_outlet, 0);
1874        assert_eq!(inlet_to_cycle.dest_inlet, 0);
1875
1876        // cycle~ → *~ (inlet 0)
1877        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
1878        let cycle_to_mul = graph
1879            .edges
1880            .iter()
1881            .find(|e| e.source_id == cycle_node.id && e.dest_id == mul_node.id)
1882            .expect("edge from cycle~ to *~ should exist");
1883        assert_eq!(cycle_to_mul.dest_inlet, 0);
1884
1885        // *~ → outlet~
1886        let outlet_node = graph
1887            .nodes
1888            .iter()
1889            .find(|n| n.object_name == "outlet~")
1890            .unwrap();
1891        let mul_to_outlet = graph
1892            .edges
1893            .iter()
1894            .find(|e| e.source_id == mul_node.id && e.dest_id == outlet_node.id)
1895            .expect("edge from *~ to outlet~ should exist");
1896        assert_eq!(mul_to_outlet.dest_inlet, 0);
1897    }
1898
1899    #[test]
1900    fn test_build_l2_mul_args() {
1901        let prog = make_l2_program();
1902        let graph = build_graph(&prog).unwrap();
1903
1904        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
1905        // *~(osc, 0.5) -> args contains "0.5"
1906        assert_eq!(mul_node.args, vec!["0.5"]);
1907    }
1908
1909    #[test]
1910    fn test_undefined_ref_error() {
1911        let prog = Program {
1912            in_decls: vec![],
1913            out_decls: vec![],
1914            wires: vec![Wire {
1915                name: "x".to_string(),
1916                value: Expr::Call {
1917                    object: "cycle~".to_string(),
1918                    args: vec![CallArg::positional(Expr::Ref("nonexistent".to_string()))],
1919                },
1920                span: None,
1921                attrs: vec![],
1922            }],
1923            destructuring_wires: vec![],
1924            msg_decls: vec![],
1925            out_assignments: vec![],
1926            direct_connections: vec![],
1927            feedback_decls: vec![],
1928            feedback_assignments: vec![],
1929            state_decls: vec![],
1930            state_assignments: vec![],
1931        };
1932
1933        let result = build_graph(&prog);
1934        assert!(result.is_err());
1935        match result.unwrap_err() {
1936            BuildError::UndefinedRef(name) => assert_eq!(name, "nonexistent"),
1937            _ => panic!("expected UndefinedRef error"),
1938        }
1939    }
1940
1941    #[test]
1942    fn test_outlet_index_out_of_range() {
1943        let prog = Program {
1944            in_decls: vec![],
1945            out_decls: vec![OutDecl {
1946                index: 0,
1947                name: "out".to_string(),
1948                port_type: PortType::Float,
1949                value: None,
1950            }],
1951            wires: vec![Wire {
1952                name: "x".to_string(),
1953                value: Expr::Call {
1954                    object: "button".to_string(),
1955                    args: vec![],
1956                },
1957                span: None,
1958                attrs: vec![],
1959            }],
1960            destructuring_wires: vec![],
1961            msg_decls: vec![],
1962            out_assignments: vec![OutAssignment {
1963                index: 5, // out_decls only has index 0
1964                value: Expr::Ref("x".to_string()),
1965                span: None,
1966            }],
1967            direct_connections: vec![],
1968            feedback_decls: vec![],
1969            feedback_assignments: vec![],
1970            state_decls: vec![],
1971            state_assignments: vec![],
1972        };
1973
1974        let result = build_graph(&prog);
1975        assert!(result.is_err());
1976        match result.unwrap_err() {
1977            BuildError::NoOutDeclaration(idx) => assert_eq!(idx, 5),
1978            _ => panic!("expected NoOutDeclaration error"),
1979        }
1980    }
1981
1982    #[test]
1983    fn test_format_lit_int() {
1984        assert_eq!(format_lit(&LitValue::Int(440)), "440");
1985        assert_eq!(format_lit(&LitValue::Int(-1)), "-1");
1986        assert_eq!(format_lit(&LitValue::Int(0)), "0");
1987    }
1988
1989    #[test]
1990    fn test_format_lit_float() {
1991        assert_eq!(format_lit(&LitValue::Float(0.5)), "0.5");
1992        assert_eq!(format_lit(&LitValue::Float(440.0)), "440.");
1993        assert_eq!(format_lit(&LitValue::Float(3.14)), "3.14");
1994    }
1995
1996    #[test]
1997    fn test_format_lit_str() {
1998        assert_eq!(format_lit(&LitValue::Str("hello".to_string())), "hello");
1999    }
2000
2001    #[test]
2002    fn test_signal_inlet_is_signal() {
2003        let prog = Program {
2004            in_decls: vec![InDecl {
2005                index: 0,
2006                name: "sig_in".to_string(),
2007                port_type: PortType::Signal,
2008            }],
2009            out_decls: vec![],
2010            wires: vec![],
2011            destructuring_wires: vec![],
2012            msg_decls: vec![],
2013            out_assignments: vec![],
2014            direct_connections: vec![],
2015            feedback_decls: vec![],
2016            feedback_assignments: vec![],
2017            state_decls: vec![],
2018            state_assignments: vec![],
2019        };
2020
2021        let graph = build_graph(&prog).unwrap();
2022        let inlet_node = &graph.nodes[0];
2023        assert_eq!(inlet_node.object_name, "inlet~");
2024        assert!(inlet_node.is_signal);
2025        assert_eq!(inlet_node.num_inlets, 1);
2026        assert_eq!(inlet_node.num_outlets, 1);
2027    }
2028
2029    #[test]
2030    fn test_control_inlet_not_signal() {
2031        let prog = Program {
2032            in_decls: vec![InDecl {
2033                index: 0,
2034                name: "ctrl_in".to_string(),
2035                port_type: PortType::Float,
2036            }],
2037            out_decls: vec![],
2038            wires: vec![],
2039            destructuring_wires: vec![],
2040            msg_decls: vec![],
2041            out_assignments: vec![],
2042            direct_connections: vec![],
2043            feedback_decls: vec![],
2044            feedback_assignments: vec![],
2045            state_decls: vec![],
2046            state_assignments: vec![],
2047        };
2048
2049        let graph = build_graph(&prog).unwrap();
2050        let inlet_node = &graph.nodes[0];
2051        assert_eq!(inlet_node.object_name, "inlet");
2052        assert!(!inlet_node.is_signal);
2053        assert_eq!(inlet_node.num_inlets, 0);
2054        assert_eq!(inlet_node.num_outlets, 1);
2055    }
2056
2057    #[test]
2058    fn test_signal_outlet() {
2059        let prog = Program {
2060            in_decls: vec![],
2061            out_decls: vec![OutDecl {
2062                index: 0,
2063                name: "audio".to_string(),
2064                port_type: PortType::Signal,
2065                value: None,
2066            }],
2067            wires: vec![],
2068            destructuring_wires: vec![],
2069            msg_decls: vec![],
2070            out_assignments: vec![],
2071            direct_connections: vec![],
2072            feedback_decls: vec![],
2073            feedback_assignments: vec![],
2074            state_decls: vec![],
2075            state_assignments: vec![],
2076        };
2077
2078        let graph = build_graph(&prog).unwrap();
2079        let outlet_node = &graph.nodes[0];
2080        assert_eq!(outlet_node.object_name, "outlet~");
2081        assert!(outlet_node.is_signal);
2082    }
2083
2084    #[test]
2085    fn test_control_outlet() {
2086        let prog = Program {
2087            in_decls: vec![],
2088            out_decls: vec![OutDecl {
2089                index: 0,
2090                name: "ctrl_out".to_string(),
2091                port_type: PortType::Float,
2092                value: None,
2093            }],
2094            wires: vec![],
2095            destructuring_wires: vec![],
2096            msg_decls: vec![],
2097            out_assignments: vec![],
2098            direct_connections: vec![],
2099            feedback_decls: vec![],
2100            feedback_assignments: vec![],
2101            state_decls: vec![],
2102            state_assignments: vec![],
2103        };
2104
2105        let graph = build_graph(&prog).unwrap();
2106        let outlet_node = &graph.nodes[0];
2107        assert_eq!(outlet_node.object_name, "outlet");
2108        assert!(!outlet_node.is_signal);
2109    }
2110
2111    #[test]
2112    fn test_nested_call() {
2113        // wire x = *~(cycle~(440), 0.5);
2114        let prog = Program {
2115            in_decls: vec![],
2116            out_decls: vec![],
2117            wires: vec![Wire {
2118                name: "x".to_string(),
2119                value: Expr::Call {
2120                    object: "*~".to_string(),
2121                    args: vec![
2122                        CallArg::positional(Expr::Call {
2123                            object: "cycle~".to_string(),
2124                            args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2125                        }),
2126                        CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
2127                    ],
2128                },
2129                span: None,
2130                attrs: vec![],
2131            }],
2132            destructuring_wires: vec![],
2133            msg_decls: vec![],
2134            out_assignments: vec![],
2135            direct_connections: vec![],
2136            feedback_decls: vec![],
2137            feedback_assignments: vec![],
2138            state_decls: vec![],
2139            state_assignments: vec![],
2140        };
2141
2142        let graph = build_graph(&prog).unwrap();
2143        // 2 nodes: cycle~ and *~
2144        assert_eq!(graph.nodes.len(), 2);
2145
2146        let cycle_node = graph
2147            .nodes
2148            .iter()
2149            .find(|n| n.object_name == "cycle~")
2150            .unwrap();
2151        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
2152
2153        // Edge: cycle~ -> *~
2154        let edge = graph
2155            .edges
2156            .iter()
2157            .find(|e| e.source_id == cycle_node.id && e.dest_id == mul_node.id)
2158            .expect("edge from cycle~ to *~ should exist");
2159        assert_eq!(edge.dest_inlet, 0);
2160    }
2161
2162    #[test]
2163    fn test_multiple_outlets() {
2164        // Patch with 2 output ports
2165        let prog = Program {
2166            in_decls: vec![],
2167            out_decls: vec![
2168                OutDecl {
2169                    index: 0,
2170                    name: "left".to_string(),
2171                    port_type: PortType::Signal,
2172                    value: None,
2173                },
2174                OutDecl {
2175                    index: 1,
2176                    name: "right".to_string(),
2177                    port_type: PortType::Signal,
2178                    value: None,
2179                },
2180            ],
2181            wires: vec![Wire {
2182                name: "osc".to_string(),
2183                value: Expr::Call {
2184                    object: "cycle~".to_string(),
2185                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2186                },
2187                span: None,
2188                attrs: vec![],
2189            }],
2190            destructuring_wires: vec![],
2191            msg_decls: vec![],
2192            out_assignments: vec![
2193                OutAssignment {
2194                    index: 0,
2195                    value: Expr::Ref("osc".to_string()),
2196                    span: None,
2197                },
2198                OutAssignment {
2199                    index: 1,
2200                    value: Expr::Ref("osc".to_string()),
2201                    span: None,
2202                },
2203            ],
2204            direct_connections: vec![],
2205            feedback_decls: vec![],
2206            feedback_assignments: vec![],
2207            state_decls: vec![],
2208            state_assignments: vec![],
2209        };
2210
2211        let graph = build_graph(&prog).unwrap();
2212
2213        // 2 outlet~ nodes, 1 cycle~ node
2214        let outlet_nodes: Vec<&PatchNode> = graph
2215            .nodes
2216            .iter()
2217            .filter(|n| n.object_name == "outlet~")
2218            .collect();
2219        assert_eq!(outlet_nodes.len(), 2);
2220
2221        // 2 edges from cycle~ -> outlet~ (Signal, so no trigger needed)
2222        let cycle_node = graph
2223            .nodes
2224            .iter()
2225            .find(|n| n.object_name == "cycle~")
2226            .unwrap();
2227        let edges_from_cycle: Vec<&PatchEdge> = graph
2228            .edges
2229            .iter()
2230            .filter(|e| e.source_id == cycle_node.id)
2231            .collect();
2232        assert_eq!(edges_from_cycle.len(), 2);
2233    }
2234
2235    // ─── Abstraction registry tests ───
2236
2237    /// Build AST for oscillator
2238    fn make_oscillator_program() -> Program {
2239        Program {
2240            in_decls: vec![InDecl {
2241                index: 0,
2242                name: "freq".to_string(),
2243                port_type: PortType::Float,
2244            }],
2245            out_decls: vec![OutDecl {
2246                index: 0,
2247                name: "audio".to_string(),
2248                port_type: PortType::Signal,
2249                value: None,
2250            }],
2251            wires: vec![Wire {
2252                name: "osc".to_string(),
2253                value: Expr::Call {
2254                    object: "cycle~".to_string(),
2255                    args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
2256                },
2257                span: None,
2258                attrs: vec![],
2259            }],
2260            destructuring_wires: vec![],
2261            msg_decls: vec![],
2262            out_assignments: vec![OutAssignment {
2263                index: 0,
2264                value: Expr::Ref("osc".to_string()),
2265                span: None,
2266            }],
2267            direct_connections: vec![],
2268            feedback_decls: vec![],
2269            feedback_assignments: vec![],
2270            state_decls: vec![],
2271            state_assignments: vec![],
2272        }
2273    }
2274
2275    /// fm_synth AST: oscillator(base_freq) -> *~(carrier, 0.5) -> out[0]
2276    fn make_fm_synth_program() -> Program {
2277        Program {
2278            in_decls: vec![InDecl {
2279                index: 0,
2280                name: "base_freq".to_string(),
2281                port_type: PortType::Float,
2282            }],
2283            out_decls: vec![OutDecl {
2284                index: 0,
2285                name: "audio".to_string(),
2286                port_type: PortType::Signal,
2287                value: None,
2288            }],
2289            wires: vec![
2290                Wire {
2291                    name: "carrier".to_string(),
2292                    value: Expr::Call {
2293                        object: "oscillator".to_string(),
2294                        args: vec![CallArg::positional(Expr::Ref("base_freq".to_string()))],
2295                    },
2296                    span: None,
2297                    attrs: vec![],
2298                },
2299                Wire {
2300                    name: "amp".to_string(),
2301                    value: Expr::Call {
2302                        object: "mul~".to_string(),
2303                        args: vec![
2304                            CallArg::positional(Expr::Ref("carrier".to_string())),
2305                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
2306                        ],
2307                    },
2308                    span: None,
2309                    attrs: vec![],
2310                },
2311            ],
2312            destructuring_wires: vec![],
2313            msg_decls: vec![],
2314            out_assignments: vec![OutAssignment {
2315                index: 0,
2316                value: Expr::Ref("amp".to_string()),
2317                span: None,
2318            }],
2319            direct_connections: vec![],
2320            feedback_decls: vec![],
2321            feedback_assignments: vec![],
2322            state_decls: vec![],
2323            state_assignments: vec![],
2324        }
2325    }
2326
2327    #[test]
2328    fn test_build_graph_with_registry_abstraction_inlets_outlets() {
2329        let mut registry = AbstractionRegistry::new();
2330        registry.register("oscillator", &make_oscillator_program());
2331
2332        let prog = make_fm_synth_program();
2333        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2334
2335        // Find the oscillator node
2336        let osc_node = graph
2337            .nodes
2338            .iter()
2339            .find(|n| n.object_name == "oscillator")
2340            .expect("oscillator node should exist");
2341
2342        // oscillator has in_ports=1 (freq), out_ports=1 (audio)
2343        assert_eq!(osc_node.num_inlets, 1);
2344        assert_eq!(osc_node.num_outlets, 1);
2345        // First out_port is Signal, so is_signal = true
2346        assert!(osc_node.is_signal);
2347    }
2348
2349    #[test]
2350    fn test_build_graph_with_registry_abstraction_name_preserved() {
2351        let mut registry = AbstractionRegistry::new();
2352        registry.register("oscillator", &make_oscillator_program());
2353
2354        let prog = make_fm_synth_program();
2355        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2356
2357        // object_name remains "oscillator" without alias conversion
2358        let osc_node = graph
2359            .nodes
2360            .iter()
2361            .find(|n| n.object_name == "oscillator")
2362            .expect("oscillator node should exist with original name");
2363        assert_eq!(osc_node.object_name, "oscillator");
2364    }
2365
2366    #[test]
2367    fn test_build_graph_with_registry_full_graph() {
2368        let mut registry = AbstractionRegistry::new();
2369        registry.register("oscillator", &make_oscillator_program());
2370
2371        let prog = make_fm_synth_program();
2372        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2373
2374        // Nodes: inlet, outlet~, oscillator, *~
2375        assert_eq!(graph.nodes.len(), 4);
2376
2377        let names: Vec<&str> = graph.nodes.iter().map(|n| n.object_name.as_str()).collect();
2378        assert!(names.contains(&"inlet"));
2379        assert!(names.contains(&"outlet~"));
2380        assert!(names.contains(&"oscillator"));
2381        assert!(names.contains(&"*~"));
2382
2383        // Edges: inlet->oscillator, oscillator->*~, *~->outlet~
2384        assert_eq!(graph.edges.len(), 3);
2385    }
2386
2387    #[test]
2388    fn test_build_graph_without_registry_unknown_object() {
2389        // Calling oscillator without registry,
2390        // falls back to infer_num_inlets/outlets (no error)
2391        let prog = make_fm_synth_program();
2392        let graph = build_graph(&prog).unwrap();
2393
2394        let osc_node = graph
2395            .nodes
2396            .iter()
2397            .find(|n| n.object_name == "oscillator")
2398            .expect("oscillator node should exist");
2399
2400        // Without registry: infer_num_inlets = 1, infer_num_outlets = 1
2401        // But with 1 argument, num_inlets = max(1, 1) = 1
2402        assert_eq!(osc_node.num_inlets, 1);
2403        assert_eq!(osc_node.num_outlets, 1);
2404    }
2405
2406    #[test]
2407    fn test_build_graph_with_registry_multi_port_abstraction() {
2408        // filter abstraction with 3 inlets, 2 outlets
2409        let filter_prog = Program {
2410            in_decls: vec![
2411                InDecl {
2412                    index: 0,
2413                    name: "input_sig".to_string(),
2414                    port_type: PortType::Signal,
2415                },
2416                InDecl {
2417                    index: 1,
2418                    name: "cutoff".to_string(),
2419                    port_type: PortType::Float,
2420                },
2421                InDecl {
2422                    index: 2,
2423                    name: "q_factor".to_string(),
2424                    port_type: PortType::Float,
2425                },
2426            ],
2427            out_decls: vec![
2428                OutDecl {
2429                    index: 0,
2430                    name: "lowpass".to_string(),
2431                    port_type: PortType::Signal,
2432                    value: None,
2433                },
2434                OutDecl {
2435                    index: 1,
2436                    name: "highpass".to_string(),
2437                    port_type: PortType::Signal,
2438                    value: None,
2439                },
2440            ],
2441            wires: vec![],
2442            destructuring_wires: vec![],
2443            msg_decls: vec![],
2444            out_assignments: vec![],
2445            direct_connections: vec![],
2446            feedback_decls: vec![],
2447            feedback_assignments: vec![],
2448            state_decls: vec![],
2449            state_assignments: vec![],
2450        };
2451
2452        let mut registry = AbstractionRegistry::new();
2453        registry.register("filter", &filter_prog);
2454
2455        // Program that calls filter(osc, 1000, 0.7)
2456        let caller = Program {
2457            in_decls: vec![],
2458            out_decls: vec![],
2459            wires: vec![Wire {
2460                name: "result".to_string(),
2461                value: Expr::Call {
2462                    object: "filter".to_string(),
2463                    args: vec![
2464                        CallArg::positional(Expr::Call {
2465                            object: "cycle~".to_string(),
2466                            args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2467                        }),
2468                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2469                        CallArg::positional(Expr::Lit(LitValue::Float(0.7))),
2470                    ],
2471                },
2472                span: None,
2473                attrs: vec![],
2474            }],
2475            destructuring_wires: vec![],
2476            msg_decls: vec![],
2477            out_assignments: vec![],
2478            direct_connections: vec![],
2479            feedback_decls: vec![],
2480            feedback_assignments: vec![],
2481            state_decls: vec![],
2482            state_assignments: vec![],
2483        };
2484
2485        let graph = build_graph_with_registry(&caller, Some(&registry)).unwrap();
2486
2487        let filter_node = graph
2488            .nodes
2489            .iter()
2490            .find(|n| n.object_name == "filter")
2491            .expect("filter node should exist");
2492
2493        assert_eq!(filter_node.num_inlets, 3);
2494        assert_eq!(filter_node.num_outlets, 2);
2495        assert!(filter_node.is_signal);
2496    }
2497
2498    #[test]
2499    fn test_build_graph_with_none_registry() {
2500        // registry=None behaves the same as build_graph
2501        let prog = make_l2_program();
2502        let graph = build_graph_with_registry(&prog, None).unwrap();
2503
2504        assert_eq!(graph.nodes.len(), 4);
2505    }
2506
2507    // ─── Tuple / Destructuring tests ───
2508
2509    #[test]
2510    fn test_tuple_generates_pack_node() {
2511        // wire t = (x, y, z); -> pack f f f node
2512        let prog = Program {
2513            in_decls: vec![
2514                InDecl {
2515                    index: 0,
2516                    name: "x".to_string(),
2517                    port_type: PortType::Float,
2518                },
2519                InDecl {
2520                    index: 1,
2521                    name: "y".to_string(),
2522                    port_type: PortType::Float,
2523                },
2524                InDecl {
2525                    index: 2,
2526                    name: "z".to_string(),
2527                    port_type: PortType::Float,
2528                },
2529            ],
2530            out_decls: vec![OutDecl {
2531                index: 0,
2532                name: "coords".to_string(),
2533                port_type: PortType::List,
2534                value: None,
2535            }],
2536            wires: vec![Wire {
2537                name: "packed".to_string(),
2538                value: Expr::Tuple(vec![
2539                    Expr::Ref("x".to_string()),
2540                    Expr::Ref("y".to_string()),
2541                    Expr::Ref("z".to_string()),
2542                ]),
2543                span: None,
2544                attrs: vec![],
2545            }],
2546            destructuring_wires: vec![],
2547            msg_decls: vec![],
2548            out_assignments: vec![OutAssignment {
2549                index: 0,
2550                value: Expr::Ref("packed".to_string()),
2551                span: None,
2552            }],
2553            direct_connections: vec![],
2554            feedback_decls: vec![],
2555            feedback_assignments: vec![],
2556            state_decls: vec![],
2557            state_assignments: vec![],
2558        };
2559
2560        let graph = build_graph(&prog).unwrap();
2561
2562        // pack node exists
2563        let pack_node = graph
2564            .nodes
2565            .iter()
2566            .find(|n| n.object_name == "pack")
2567            .expect("pack node should exist");
2568        assert_eq!(pack_node.num_inlets, 3);
2569        assert_eq!(pack_node.num_outlets, 1);
2570        assert_eq!(pack_node.args, vec!["f", "f", "f"]);
2571        assert!(!pack_node.is_signal);
2572
2573        // 3 edges from inlet -> pack
2574        let edges_to_pack: Vec<_> = graph
2575            .edges
2576            .iter()
2577            .filter(|e| e.dest_id == pack_node.id)
2578            .collect();
2579        assert_eq!(edges_to_pack.len(), 3);
2580
2581        // Each inlet connects to a different dest_inlet
2582        let mut dest_inlets: Vec<u32> = edges_to_pack.iter().map(|e| e.dest_inlet).collect();
2583        dest_inlets.sort();
2584        assert_eq!(dest_inlets, vec![0, 1, 2]);
2585    }
2586
2587    #[test]
2588    fn test_destructuring_with_unpack_call() {
2589        // wire (a, b) = unpack(data); -> Expr::Call generates an unpack node,
2590        // DestructuringWire maps a, b to its outlets
2591        use flutmax_ast::DestructuringWire;
2592
2593        let prog = Program {
2594            in_decls: vec![InDecl {
2595                index: 0,
2596                name: "data".to_string(),
2597                port_type: PortType::Float,
2598            }],
2599            out_decls: vec![
2600                OutDecl {
2601                    index: 0,
2602                    name: "x".to_string(),
2603                    port_type: PortType::Float,
2604                    value: None,
2605                },
2606                OutDecl {
2607                    index: 1,
2608                    name: "y".to_string(),
2609                    port_type: PortType::Float,
2610                    value: None,
2611                },
2612            ],
2613            wires: vec![],
2614            destructuring_wires: vec![DestructuringWire {
2615                names: vec!["a".to_string(), "b".to_string()],
2616                value: Expr::Call {
2617                    object: "unpack".to_string(),
2618                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
2619                },
2620                span: None,
2621            }],
2622            msg_decls: vec![],
2623            out_assignments: vec![
2624                OutAssignment {
2625                    index: 0,
2626                    value: Expr::Ref("a".to_string()),
2627                    span: None,
2628                },
2629                OutAssignment {
2630                    index: 1,
2631                    value: Expr::Ref("b".to_string()),
2632                    span: None,
2633                },
2634            ],
2635            direct_connections: vec![],
2636            feedback_decls: vec![],
2637            feedback_assignments: vec![],
2638            state_decls: vec![],
2639            state_assignments: vec![],
2640        };
2641
2642        let graph = build_graph(&prog).unwrap();
2643
2644        // Expr::Call("unpack") generates one unpack node
2645        // DestructuringWire reuses existing unpack node outlets
2646        let unpack_nodes: Vec<_> = graph
2647            .nodes
2648            .iter()
2649            .filter(|n| n.object_name == "unpack")
2650            .collect();
2651        assert_eq!(unpack_nodes.len(), 1);
2652
2653        let unpack_node = unpack_nodes[0];
2654        assert_eq!(unpack_node.num_outlets, 2);
2655        assert!(!unpack_node.is_signal);
2656
2657        // Edge: inlet -> unpack
2658        let edges_to_unpack: Vec<_> = graph
2659            .edges
2660            .iter()
2661            .filter(|e| e.dest_id == unpack_node.id)
2662            .collect();
2663        assert_eq!(edges_to_unpack.len(), 1);
2664
2665        // a and b are each connected to outlet nodes
2666        let outlet_nodes: Vec<_> = graph
2667            .nodes
2668            .iter()
2669            .filter(|n| n.object_name == "outlet")
2670            .collect();
2671        assert_eq!(outlet_nodes.len(), 2);
2672
2673        // 2 edges from unpack -> outlet
2674        let edges_from_unpack: Vec<_> = graph
2675            .edges
2676            .iter()
2677            .filter(|e| e.source_id == unpack_node.id)
2678            .collect();
2679        assert_eq!(edges_from_unpack.len(), 2);
2680
2681        // From outlet 0 and outlet 1 respectively
2682        let mut source_outlets: Vec<u32> =
2683            edges_from_unpack.iter().map(|e| e.source_outlet).collect();
2684        source_outlets.sort();
2685        assert_eq!(source_outlets, vec![0, 1]);
2686    }
2687
2688    #[test]
2689    fn test_destructuring_with_ref_auto_unpack() {
2690        // wire (a, b) = packed; -> packed node has insufficient outlets, so auto-insert unpack
2691        use flutmax_ast::DestructuringWire;
2692
2693        let prog = Program {
2694            in_decls: vec![
2695                InDecl {
2696                    index: 0,
2697                    name: "x".to_string(),
2698                    port_type: PortType::Float,
2699                },
2700                InDecl {
2701                    index: 1,
2702                    name: "y".to_string(),
2703                    port_type: PortType::Float,
2704                },
2705            ],
2706            out_decls: vec![],
2707            wires: vec![Wire {
2708                name: "packed".to_string(),
2709                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
2710                span: None,
2711                attrs: vec![],
2712            }],
2713            destructuring_wires: vec![DestructuringWire {
2714                names: vec!["a".to_string(), "b".to_string()],
2715                value: Expr::Ref("packed".to_string()),
2716                span: None,
2717            }],
2718            msg_decls: vec![],
2719            out_assignments: vec![],
2720            direct_connections: vec![],
2721            feedback_decls: vec![],
2722            feedback_assignments: vec![],
2723            state_decls: vec![],
2724            state_assignments: vec![],
2725        };
2726
2727        let graph = build_graph(&prog).unwrap();
2728
2729        // One pack node (tuple)
2730        let pack_node = graph
2731            .nodes
2732            .iter()
2733            .find(|n| n.object_name == "pack")
2734            .expect("pack node should exist");
2735        assert_eq!(pack_node.num_outlets, 1);
2736
2737        // pack has 1 outlet, so DestructuringWire auto-inserts unpack
2738        let unpack_node = graph
2739            .nodes
2740            .iter()
2741            .find(|n| n.object_name == "unpack")
2742            .expect("unpack node should be auto-inserted");
2743        assert_eq!(unpack_node.num_outlets, 2);
2744        assert_eq!(unpack_node.args, vec!["f", "f"]);
2745
2746        // Edge: pack -> unpack
2747        let pack_to_unpack = graph
2748            .edges
2749            .iter()
2750            .find(|e| e.source_id == pack_node.id && e.dest_id == unpack_node.id)
2751            .expect("edge from pack to unpack should exist");
2752        assert_eq!(pack_to_unpack.dest_inlet, 0);
2753    }
2754
2755    #[test]
2756    fn test_tuple_two_elements_pack() {
2757        // wire t = (a, b); → pack f f
2758        let prog = Program {
2759            in_decls: vec![
2760                InDecl {
2761                    index: 0,
2762                    name: "a".to_string(),
2763                    port_type: PortType::Float,
2764                },
2765                InDecl {
2766                    index: 1,
2767                    name: "b".to_string(),
2768                    port_type: PortType::Float,
2769                },
2770            ],
2771            out_decls: vec![],
2772            wires: vec![Wire {
2773                name: "t".to_string(),
2774                value: Expr::Tuple(vec![Expr::Ref("a".to_string()), Expr::Ref("b".to_string())]),
2775                span: None,
2776                attrs: vec![],
2777            }],
2778            destructuring_wires: vec![],
2779            msg_decls: vec![],
2780            out_assignments: vec![],
2781            direct_connections: vec![],
2782            feedback_decls: vec![],
2783            feedback_assignments: vec![],
2784            state_decls: vec![],
2785            state_assignments: vec![],
2786        };
2787
2788        let graph = build_graph(&prog).unwrap();
2789
2790        let pack_node = graph
2791            .nodes
2792            .iter()
2793            .find(|n| n.object_name == "pack")
2794            .expect("pack node should exist");
2795        assert_eq!(pack_node.num_inlets, 2);
2796        assert_eq!(pack_node.args, vec!["f", "f"]);
2797    }
2798
2799    // ─── Feedback tests ───
2800
2801    #[test]
2802    fn test_feedback_generates_tapin_node() {
2803        // feedback fb: signal; -> tapin~ node is generated
2804        use flutmax_ast::FeedbackDecl;
2805
2806        let prog = Program {
2807            in_decls: vec![InDecl {
2808                index: 0,
2809                name: "input".to_string(),
2810                port_type: PortType::Signal,
2811            }],
2812            out_decls: vec![OutDecl {
2813                index: 0,
2814                name: "output".to_string(),
2815                port_type: PortType::Signal,
2816                value: None,
2817            }],
2818            wires: vec![
2819                Wire {
2820                    name: "delayed".to_string(),
2821                    value: Expr::Call {
2822                        object: "tapout~".to_string(),
2823                        args: vec![
2824                            CallArg::positional(Expr::Ref("fb".to_string())),
2825                            CallArg::positional(Expr::Lit(LitValue::Int(500))),
2826                        ],
2827                    },
2828                    span: None,
2829                    attrs: vec![],
2830                },
2831                Wire {
2832                    name: "mixed".to_string(),
2833                    value: Expr::Call {
2834                        object: "add~".to_string(),
2835                        args: vec![
2836                            CallArg::positional(Expr::Ref("input".to_string())),
2837                            CallArg::positional(Expr::Call {
2838                                object: "mul~".to_string(),
2839                                args: vec![
2840                                    CallArg::positional(Expr::Ref("delayed".to_string())),
2841                                    CallArg::positional(Expr::Lit(LitValue::Float(0.3))),
2842                                ],
2843                            }),
2844                        ],
2845                    },
2846                    span: None,
2847                    attrs: vec![],
2848                },
2849            ],
2850            destructuring_wires: vec![],
2851            msg_decls: vec![],
2852            out_assignments: vec![OutAssignment {
2853                index: 0,
2854                value: Expr::Ref("mixed".to_string()),
2855                span: None,
2856            }],
2857            direct_connections: vec![],
2858            feedback_decls: vec![FeedbackDecl {
2859                name: "fb".to_string(),
2860                port_type: PortType::Signal,
2861                span: None,
2862            }],
2863            feedback_assignments: vec![FeedbackAssignment {
2864                target: "fb".to_string(),
2865                value: Expr::Call {
2866                    object: "tapin~".to_string(),
2867                    args: vec![
2868                        CallArg::positional(Expr::Ref("mixed".to_string())),
2869                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2870                    ],
2871                },
2872                span: None,
2873            }],
2874            state_decls: vec![],
2875            state_assignments: vec![],
2876        };
2877
2878        let graph = build_graph(&prog).unwrap();
2879
2880        // tapin~ node exists
2881        let tapin_node = graph
2882            .nodes
2883            .iter()
2884            .find(|n| n.object_name == "tapin~")
2885            .expect("tapin~ node should exist");
2886        assert!(tapin_node.is_signal);
2887        assert_eq!(tapin_node.num_inlets, 1);
2888        assert_eq!(tapin_node.num_outlets, 1);
2889
2890        // tapout~ node exists
2891        let tapout_node = graph
2892            .nodes
2893            .iter()
2894            .find(|n| n.object_name == "tapout~")
2895            .expect("tapout~ node should exist");
2896        assert!(tapout_node.is_signal);
2897
2898        // Edge tapin~ -> tapout~ exists
2899        let tapin_to_tapout = graph
2900            .edges
2901            .iter()
2902            .find(|e| e.source_id == tapin_node.id && e.dest_id == tapout_node.id)
2903            .expect("edge from tapin~ to tapout~ should exist");
2904        assert_eq!(tapin_to_tapout.source_outlet, 0);
2905        assert_eq!(tapin_to_tapout.dest_inlet, 0);
2906        // tapin~ -> tapout~ is a normal edge (is_feedback is on the assignment edge)
2907        assert!(!tapin_to_tapout.is_feedback);
2908
2909        // feedback assignment edge has is_feedback=true
2910        let feedback_edges: Vec<_> = graph.edges.iter().filter(|e| e.is_feedback).collect();
2911        assert_eq!(
2912            feedback_edges.len(),
2913            1,
2914            "should have exactly one feedback edge"
2915        );
2916    }
2917
2918    #[test]
2919    fn test_feedback_no_trigger_on_feedback_edge() {
2920        // Verify trigger is not inserted for feedback edges
2921        use flutmax_ast::FeedbackDecl;
2922
2923        let prog = Program {
2924            in_decls: vec![InDecl {
2925                index: 0,
2926                name: "input".to_string(),
2927                port_type: PortType::Signal,
2928            }],
2929            out_decls: vec![OutDecl {
2930                index: 0,
2931                name: "output".to_string(),
2932                port_type: PortType::Signal,
2933                value: None,
2934            }],
2935            wires: vec![
2936                Wire {
2937                    name: "delayed".to_string(),
2938                    value: Expr::Call {
2939                        object: "tapout~".to_string(),
2940                        args: vec![
2941                            CallArg::positional(Expr::Ref("fb".to_string())),
2942                            CallArg::positional(Expr::Lit(LitValue::Int(500))),
2943                        ],
2944                    },
2945                    span: None,
2946                    attrs: vec![],
2947                },
2948                Wire {
2949                    name: "mixed".to_string(),
2950                    value: Expr::Call {
2951                        object: "add~".to_string(),
2952                        args: vec![
2953                            CallArg::positional(Expr::Ref("input".to_string())),
2954                            CallArg::positional(Expr::Ref("delayed".to_string())),
2955                        ],
2956                    },
2957                    span: None,
2958                    attrs: vec![],
2959                },
2960            ],
2961            destructuring_wires: vec![],
2962            msg_decls: vec![],
2963            out_assignments: vec![OutAssignment {
2964                index: 0,
2965                value: Expr::Ref("mixed".to_string()),
2966                span: None,
2967            }],
2968            direct_connections: vec![],
2969            feedback_decls: vec![FeedbackDecl {
2970                name: "fb".to_string(),
2971                port_type: PortType::Signal,
2972                span: None,
2973            }],
2974            feedback_assignments: vec![FeedbackAssignment {
2975                target: "fb".to_string(),
2976                value: Expr::Call {
2977                    object: "tapin~".to_string(),
2978                    args: vec![
2979                        CallArg::positional(Expr::Ref("mixed".to_string())),
2980                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2981                    ],
2982                },
2983                span: None,
2984            }],
2985            state_decls: vec![],
2986            state_assignments: vec![],
2987        };
2988
2989        let graph = build_graph(&prog).unwrap();
2990
2991        // Confirm no trigger node was inserted
2992        // (all Signal, so no trigger needed)
2993        let trigger_nodes: Vec<_> = graph
2994            .nodes
2995            .iter()
2996            .filter(|n| n.object_name == "trigger")
2997            .collect();
2998        assert_eq!(
2999            trigger_nodes.len(),
3000            0,
3001            "no trigger nodes should be inserted for signal-only feedback"
3002        );
3003    }
3004
3005    // ─── E004: NoOutDeclaration tests ───
3006
3007    #[test]
3008    fn test_e004_no_out_declaration_detected() {
3009        // Assign to out[0] without out declaration -> E004
3010        let prog = Program {
3011            in_decls: vec![],
3012            out_decls: vec![],
3013            wires: vec![Wire {
3014                name: "x".to_string(),
3015                value: Expr::Call {
3016                    object: "button".to_string(),
3017                    args: vec![],
3018                },
3019                span: None,
3020                attrs: vec![],
3021            }],
3022            destructuring_wires: vec![],
3023            msg_decls: vec![],
3024            out_assignments: vec![OutAssignment {
3025                index: 0,
3026                value: Expr::Ref("x".to_string()),
3027                span: None,
3028            }],
3029            direct_connections: vec![],
3030            feedback_decls: vec![],
3031            feedback_assignments: vec![],
3032            state_decls: vec![],
3033            state_assignments: vec![],
3034        };
3035
3036        let result = build_graph(&prog);
3037        assert!(result.is_err());
3038        match result.unwrap_err() {
3039            BuildError::NoOutDeclaration(idx) => assert_eq!(idx, 0),
3040            other => panic!("expected NoOutDeclaration, got {:?}", other),
3041        }
3042    }
3043
3044    #[test]
3045    fn test_e004_valid_out_declaration_no_error() {
3046        // Assign to out[0] with out declaration -> no error
3047        let prog = Program {
3048            in_decls: vec![],
3049            out_decls: vec![OutDecl {
3050                index: 0,
3051                name: "audio".to_string(),
3052                port_type: PortType::Signal,
3053                value: None,
3054            }],
3055            wires: vec![Wire {
3056                name: "osc".to_string(),
3057                value: Expr::Call {
3058                    object: "cycle~".to_string(),
3059                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3060                },
3061                span: None,
3062                attrs: vec![],
3063            }],
3064            destructuring_wires: vec![],
3065            msg_decls: vec![],
3066            out_assignments: vec![OutAssignment {
3067                index: 0,
3068                value: Expr::Ref("osc".to_string()),
3069                span: None,
3070            }],
3071            direct_connections: vec![],
3072            feedback_decls: vec![],
3073            feedback_assignments: vec![],
3074            state_decls: vec![],
3075            state_assignments: vec![],
3076        };
3077
3078        let result = build_graph(&prog);
3079        assert!(result.is_ok());
3080    }
3081
3082    // ─── E006: DestructuringCountMismatch tests ───
3083
3084    #[test]
3085    fn test_e006_destructuring_count_mismatch_detected() {
3086        // unpack has 2 outlets but 3 names -> E006
3087        use flutmax_ast::DestructuringWire;
3088
3089        let prog = Program {
3090            in_decls: vec![InDecl {
3091                index: 0,
3092                name: "data".to_string(),
3093                port_type: PortType::Float,
3094            }],
3095            out_decls: vec![],
3096            wires: vec![],
3097            destructuring_wires: vec![DestructuringWire {
3098                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
3099                value: Expr::Call {
3100                    object: "unpack".to_string(),
3101                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3102                },
3103                span: None,
3104            }],
3105            msg_decls: vec![],
3106            out_assignments: vec![],
3107            direct_connections: vec![],
3108            feedback_decls: vec![],
3109            feedback_assignments: vec![],
3110            state_decls: vec![],
3111            state_assignments: vec![],
3112        };
3113
3114        let result = build_graph(&prog);
3115        assert!(result.is_err());
3116        match result.unwrap_err() {
3117            BuildError::DestructuringCountMismatch { expected, got } => {
3118                assert_eq!(expected, 2);
3119                assert_eq!(got, 3);
3120            }
3121            other => panic!("expected DestructuringCountMismatch, got {:?}", other),
3122        }
3123    }
3124
3125    #[test]
3126    fn test_e006_destructuring_count_match_no_error() {
3127        // unpack has 2 outlets and 2 names -> no error
3128        use flutmax_ast::DestructuringWire;
3129
3130        let prog = Program {
3131            in_decls: vec![InDecl {
3132                index: 0,
3133                name: "data".to_string(),
3134                port_type: PortType::Float,
3135            }],
3136            out_decls: vec![],
3137            wires: vec![],
3138            destructuring_wires: vec![DestructuringWire {
3139                names: vec!["a".to_string(), "b".to_string()],
3140                value: Expr::Call {
3141                    object: "unpack".to_string(),
3142                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3143                },
3144                span: None,
3145            }],
3146            msg_decls: vec![],
3147            out_assignments: vec![],
3148            direct_connections: vec![],
3149            feedback_decls: vec![],
3150            feedback_assignments: vec![],
3151            state_decls: vec![],
3152            state_assignments: vec![],
3153        };
3154
3155        let result = build_graph(&prog);
3156        assert!(result.is_ok());
3157    }
3158
3159    // ─── E009: AbstractionArgCountMismatch tests ───
3160
3161    #[test]
3162    fn test_e009_abstraction_arg_count_mismatch_detected() {
3163        // oscillator has 1 in_port but called with 2 args -> E009
3164        let mut registry = AbstractionRegistry::new();
3165        registry.register("oscillator", &make_oscillator_program());
3166
3167        let prog = Program {
3168            in_decls: vec![],
3169            out_decls: vec![],
3170            wires: vec![Wire {
3171                name: "osc".to_string(),
3172                value: Expr::Call {
3173                    object: "oscillator".to_string(),
3174                    args: vec![
3175                        CallArg::positional(Expr::Lit(LitValue::Int(440))),
3176                        CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
3177                    ],
3178                },
3179                span: None,
3180                attrs: vec![],
3181            }],
3182            destructuring_wires: vec![],
3183            msg_decls: vec![],
3184            out_assignments: vec![],
3185            direct_connections: vec![],
3186            feedback_decls: vec![],
3187            feedback_assignments: vec![],
3188            state_decls: vec![],
3189            state_assignments: vec![],
3190        };
3191
3192        let result = build_graph_with_registry(&prog, Some(&registry));
3193        assert!(result.is_err());
3194        match result.unwrap_err() {
3195            BuildError::AbstractionArgCountMismatch {
3196                name,
3197                expected,
3198                got,
3199            } => {
3200                assert_eq!(name, "oscillator");
3201                assert_eq!(expected, 1);
3202                assert_eq!(got, 2);
3203            }
3204            other => panic!("expected AbstractionArgCountMismatch, got {:?}", other),
3205        }
3206    }
3207
3208    #[test]
3209    fn test_e009_abstraction_arg_count_match_no_error() {
3210        // oscillator has 1 in_port with 1 arg -> no error
3211        let mut registry = AbstractionRegistry::new();
3212        registry.register("oscillator", &make_oscillator_program());
3213
3214        let prog = Program {
3215            in_decls: vec![],
3216            out_decls: vec![],
3217            wires: vec![Wire {
3218                name: "osc".to_string(),
3219                value: Expr::Call {
3220                    object: "oscillator".to_string(),
3221                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3222                },
3223                span: None,
3224                attrs: vec![],
3225            }],
3226            destructuring_wires: vec![],
3227            msg_decls: vec![],
3228            out_assignments: vec![],
3229            direct_connections: vec![],
3230            feedback_decls: vec![],
3231            feedback_assignments: vec![],
3232            state_decls: vec![],
3233            state_assignments: vec![],
3234        };
3235
3236        let result = build_graph_with_registry(&prog, Some(&registry));
3237        assert!(result.is_ok());
3238    }
3239
3240    // ─── E013: DuplicateFeedbackAssignment tests ───
3241
3242    #[test]
3243    fn test_e013_duplicate_feedback_assignment_detected() {
3244        // 2 assignments to same feedback variable -> E013
3245        use flutmax_ast::{FeedbackAssignment, FeedbackDecl};
3246
3247        let prog = Program {
3248            in_decls: vec![InDecl {
3249                index: 0,
3250                name: "input".to_string(),
3251                port_type: PortType::Signal,
3252            }],
3253            out_decls: vec![],
3254            wires: vec![Wire {
3255                name: "sig".to_string(),
3256                value: Expr::Call {
3257                    object: "cycle~".to_string(),
3258                    args: vec![CallArg::positional(Expr::Ref("input".to_string()))],
3259                },
3260                span: None,
3261                attrs: vec![],
3262            }],
3263            destructuring_wires: vec![],
3264            msg_decls: vec![],
3265            out_assignments: vec![],
3266            direct_connections: vec![],
3267            feedback_decls: vec![FeedbackDecl {
3268                name: "fb".to_string(),
3269                port_type: PortType::Signal,
3270                span: None,
3271            }],
3272            feedback_assignments: vec![
3273                FeedbackAssignment {
3274                    target: "fb".to_string(),
3275                    value: Expr::Ref("sig".to_string()),
3276                    span: None,
3277                },
3278                FeedbackAssignment {
3279                    target: "fb".to_string(),
3280                    value: Expr::Ref("sig".to_string()),
3281                    span: None,
3282                },
3283            ],
3284            state_decls: vec![],
3285            state_assignments: vec![],
3286        };
3287
3288        let result = build_graph(&prog);
3289        assert!(result.is_err());
3290        match result.unwrap_err() {
3291            BuildError::DuplicateFeedbackAssignment(name) => assert_eq!(name, "fb"),
3292            other => panic!("expected DuplicateFeedbackAssignment, got {:?}", other),
3293        }
3294    }
3295
3296    #[test]
3297    fn test_e013_single_feedback_assignment_no_error() {
3298        // 1 feedback assignment -> no error
3299        use flutmax_ast::{FeedbackAssignment, FeedbackDecl};
3300
3301        let prog = Program {
3302            in_decls: vec![InDecl {
3303                index: 0,
3304                name: "input".to_string(),
3305                port_type: PortType::Signal,
3306            }],
3307            out_decls: vec![],
3308            wires: vec![Wire {
3309                name: "sig".to_string(),
3310                value: Expr::Call {
3311                    object: "cycle~".to_string(),
3312                    args: vec![CallArg::positional(Expr::Ref("input".to_string()))],
3313                },
3314                span: None,
3315                attrs: vec![],
3316            }],
3317            destructuring_wires: vec![],
3318            msg_decls: vec![],
3319            out_assignments: vec![],
3320            direct_connections: vec![],
3321            feedback_decls: vec![FeedbackDecl {
3322                name: "fb".to_string(),
3323                port_type: PortType::Signal,
3324                span: None,
3325            }],
3326            feedback_assignments: vec![FeedbackAssignment {
3327                target: "fb".to_string(),
3328                value: Expr::Ref("sig".to_string()),
3329                span: None,
3330            }],
3331            state_decls: vec![],
3332            state_assignments: vec![],
3333        };
3334
3335        let result = build_graph(&prog);
3336        assert!(result.is_ok());
3337    }
3338
3339    // ─── E17: Edge Order tests ───
3340
3341    #[test]
3342    fn test_fanout_edges_get_order() {
3343        // cycle~ -> outlet~ x2 (Signal fanout) -> order is assigned
3344        let prog = Program {
3345            in_decls: vec![],
3346            out_decls: vec![
3347                OutDecl {
3348                    index: 0,
3349                    name: "left".to_string(),
3350                    port_type: PortType::Signal,
3351                    value: None,
3352                },
3353                OutDecl {
3354                    index: 1,
3355                    name: "right".to_string(),
3356                    port_type: PortType::Signal,
3357                    value: None,
3358                },
3359            ],
3360            wires: vec![Wire {
3361                name: "osc".to_string(),
3362                value: Expr::Call {
3363                    object: "cycle~".to_string(),
3364                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3365                },
3366                span: None,
3367                attrs: vec![],
3368            }],
3369            destructuring_wires: vec![],
3370            msg_decls: vec![],
3371            out_assignments: vec![
3372                OutAssignment {
3373                    index: 0,
3374                    value: Expr::Ref("osc".to_string()),
3375                    span: None,
3376                },
3377                OutAssignment {
3378                    index: 1,
3379                    value: Expr::Ref("osc".to_string()),
3380                    span: None,
3381                },
3382            ],
3383            direct_connections: vec![],
3384            feedback_decls: vec![],
3385            feedback_assignments: vec![],
3386            state_decls: vec![],
3387            state_assignments: vec![],
3388        };
3389
3390        let graph = build_graph(&prog).unwrap();
3391
3392        // 2 edges from cycle~, with order assigned
3393        let cycle_node = graph
3394            .nodes
3395            .iter()
3396            .find(|n| n.object_name == "cycle~")
3397            .unwrap();
3398        let edges_from_cycle: Vec<_> = graph
3399            .edges
3400            .iter()
3401            .filter(|e| e.source_id == cycle_node.id && e.source_outlet == 0)
3402            .collect();
3403        assert_eq!(edges_from_cycle.len(), 2);
3404
3405        // Both have Some order
3406        assert!(edges_from_cycle[0].order.is_some());
3407        assert!(edges_from_cycle[1].order.is_some());
3408
3409        // order is 0 and 1
3410        let mut orders: Vec<u32> = edges_from_cycle.iter().map(|e| e.order.unwrap()).collect();
3411        orders.sort();
3412        assert_eq!(orders, vec![0, 1]);
3413    }
3414
3415    #[test]
3416    fn test_single_edge_no_order() {
3417        // Single-connection edges are not assigned order
3418        let prog = make_l2_program();
3419        let graph = build_graph(&prog).unwrap();
3420
3421        // All edges have order: None
3422        for edge in &graph.edges {
3423            assert_eq!(
3424                edge.order, None,
3425                "single edge from {} outlet {} should have no order",
3426                edge.source_id, edge.source_outlet
3427            );
3428        }
3429    }
3430
3431    // ─── E17: Purity Classification tests ───
3432
3433    #[test]
3434    fn test_classify_purity_signal_pure() {
3435        assert_eq!(classify_purity("cycle~"), NodePurity::Pure);
3436        assert_eq!(classify_purity("*~"), NodePurity::Pure);
3437        assert_eq!(classify_purity("+~"), NodePurity::Pure);
3438        assert_eq!(classify_purity("biquad~"), NodePurity::Pure);
3439    }
3440
3441    #[test]
3442    fn test_classify_purity_signal_stateful() {
3443        assert_eq!(classify_purity("tapin~"), NodePurity::Stateful);
3444        assert_eq!(classify_purity("tapout~"), NodePurity::Stateful);
3445        assert_eq!(classify_purity("line~"), NodePurity::Stateful);
3446        assert_eq!(classify_purity("delay~"), NodePurity::Stateful);
3447    }
3448
3449    #[test]
3450    fn test_classify_purity_control_stateful() {
3451        assert_eq!(classify_purity("pack"), NodePurity::Stateful);
3452        assert_eq!(classify_purity("unpack"), NodePurity::Stateful);
3453        assert_eq!(classify_purity("int"), NodePurity::Stateful);
3454        assert_eq!(classify_purity("float"), NodePurity::Stateful);
3455        assert_eq!(classify_purity("toggle"), NodePurity::Stateful);
3456        assert_eq!(classify_purity("gate"), NodePurity::Stateful);
3457        assert_eq!(classify_purity("counter"), NodePurity::Stateful);
3458        assert_eq!(classify_purity("coll"), NodePurity::Stateful);
3459        assert_eq!(classify_purity("dict"), NodePurity::Stateful);
3460    }
3461
3462    #[test]
3463    fn test_classify_purity_control_pure() {
3464        assert_eq!(classify_purity("+"), NodePurity::Pure);
3465        assert_eq!(classify_purity("-"), NodePurity::Pure);
3466        assert_eq!(classify_purity("*"), NodePurity::Pure);
3467        assert_eq!(classify_purity("/"), NodePurity::Pure);
3468        assert_eq!(classify_purity("trigger"), NodePurity::Pure);
3469        assert_eq!(classify_purity("t"), NodePurity::Pure);
3470        assert_eq!(classify_purity("route"), NodePurity::Pure);
3471        assert_eq!(classify_purity("select"), NodePurity::Pure);
3472        assert_eq!(classify_purity("prepend"), NodePurity::Pure);
3473    }
3474
3475    #[test]
3476    fn test_classify_purity_unknown() {
3477        assert_eq!(classify_purity("my_custom_object"), NodePurity::Unknown);
3478        assert_eq!(classify_purity("some_abstraction"), NodePurity::Unknown);
3479    }
3480
3481    // ─── E17: Hot/Cold Inlets tests ───
3482
3483    #[test]
3484    fn test_default_hot_inlets_standard() {
3485        // inlet 0 = hot, others = cold
3486        let hot = default_hot_inlets("cycle~", 2);
3487        assert_eq!(hot, vec![true, false]);
3488    }
3489
3490    #[test]
3491    fn test_default_hot_inlets_single() {
3492        let hot = default_hot_inlets("print", 1);
3493        assert_eq!(hot, vec![true]);
3494    }
3495
3496    #[test]
3497    fn test_default_hot_inlets_none() {
3498        let hot = default_hot_inlets("inlet", 0);
3499        assert!(hot.is_empty());
3500    }
3501
3502    #[test]
3503    fn test_default_hot_inlets_many() {
3504        let hot = default_hot_inlets("biquad~", 6);
3505        assert_eq!(hot, vec![true, false, false, false, false, false]);
3506    }
3507
3508    // ─── E17: Graph Node Attributes tests ───
3509
3510    #[test]
3511    fn test_built_node_has_purity() {
3512        let prog = make_l2_program();
3513        let graph = build_graph(&prog).unwrap();
3514
3515        let cycle_node = graph
3516            .nodes
3517            .iter()
3518            .find(|n| n.object_name == "cycle~")
3519            .unwrap();
3520        assert_eq!(cycle_node.purity, NodePurity::Pure);
3521
3522        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
3523        assert_eq!(mul_node.purity, NodePurity::Pure);
3524    }
3525
3526    #[test]
3527    fn test_built_node_has_hot_inlets() {
3528        let prog = make_l2_program();
3529        let graph = build_graph(&prog).unwrap();
3530
3531        let cycle_node = graph
3532            .nodes
3533            .iter()
3534            .find(|n| n.object_name == "cycle~")
3535            .unwrap();
3536        assert_eq!(cycle_node.hot_inlets, vec![true, false]);
3537
3538        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
3539        assert_eq!(mul_node.hot_inlets, vec![true, false]);
3540    }
3541
3542    // ─── E007: InvalidPortIndex tests ───
3543
3544    #[test]
3545    fn test_direct_connection_valid_port() {
3546        // node.in[0] = expr; — valid port index
3547        let prog = Program {
3548            in_decls: vec![],
3549            out_decls: vec![],
3550            wires: vec![
3551                Wire {
3552                    name: "src".to_string(),
3553                    value: Expr::Call {
3554                        object: "button".to_string(),
3555                        args: vec![],
3556                    },
3557                    span: None,
3558                    attrs: vec![],
3559                },
3560                Wire {
3561                    name: "target".to_string(),
3562                    value: Expr::Call {
3563                        object: "+".to_string(),
3564                        args: vec![
3565                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3566                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3567                        ],
3568                    },
3569                    span: None,
3570                    attrs: vec![],
3571                },
3572            ],
3573            destructuring_wires: vec![],
3574            msg_decls: vec![],
3575            out_assignments: vec![],
3576            direct_connections: vec![DirectConnection {
3577                target: flutmax_ast::InputPortAccess {
3578                    object: "target".to_string(),
3579                    index: 0,
3580                },
3581                value: Expr::Ref("src".to_string()),
3582            }],
3583            feedback_decls: vec![],
3584            feedback_assignments: vec![],
3585            state_decls: vec![],
3586            state_assignments: vec![],
3587        };
3588
3589        let result = build_graph(&prog);
3590        assert!(result.is_ok(), "valid port index should succeed");
3591    }
3592
3593    #[test]
3594    fn test_direct_connection_invalid_port_index() {
3595        // node.in[99] = expr; — out-of-range port index -> E007
3596        let prog = Program {
3597            in_decls: vec![],
3598            out_decls: vec![],
3599            wires: vec![
3600                Wire {
3601                    name: "src".to_string(),
3602                    value: Expr::Call {
3603                        object: "button".to_string(),
3604                        args: vec![],
3605                    },
3606                    span: None,
3607                    attrs: vec![],
3608                },
3609                Wire {
3610                    name: "target".to_string(),
3611                    value: Expr::Call {
3612                        object: "+".to_string(),
3613                        args: vec![
3614                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3615                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3616                        ],
3617                    },
3618                    span: None,
3619                    attrs: vec![],
3620                },
3621            ],
3622            destructuring_wires: vec![],
3623            msg_decls: vec![],
3624            out_assignments: vec![],
3625            direct_connections: vec![DirectConnection {
3626                target: flutmax_ast::InputPortAccess {
3627                    object: "target".to_string(),
3628                    index: 99,
3629                },
3630                value: Expr::Ref("src".to_string()),
3631            }],
3632            feedback_decls: vec![],
3633            feedback_assignments: vec![],
3634            state_decls: vec![],
3635            state_assignments: vec![],
3636        };
3637
3638        // Port index exceeding initial num_inlets auto-extends the node
3639        // (needed for decompiler back-edge direct_connections).
3640        let result = build_graph(&prog);
3641        assert!(result.is_ok());
3642        let graph = result.unwrap();
3643        let target_node = graph
3644            .find_node("target_id_0")
3645            .or_else(|| graph.nodes.iter().find(|n| n.object_name == "+"));
3646        assert!(target_node.is_some());
3647        // The node should now have at least 100 inlets (index 99 + 1)
3648        assert!(target_node.unwrap().num_inlets >= 100);
3649    }
3650
3651    #[test]
3652    fn test_direct_connection_undefined_node() {
3653        // nonexistent.in[0] = expr; — undefined node
3654        let prog = Program {
3655            in_decls: vec![],
3656            out_decls: vec![],
3657            wires: vec![Wire {
3658                name: "src".to_string(),
3659                value: Expr::Call {
3660                    object: "button".to_string(),
3661                    args: vec![],
3662                },
3663                span: None,
3664                attrs: vec![],
3665            }],
3666            destructuring_wires: vec![],
3667            msg_decls: vec![],
3668            out_assignments: vec![],
3669            direct_connections: vec![DirectConnection {
3670                target: flutmax_ast::InputPortAccess {
3671                    object: "nonexistent".to_string(),
3672                    index: 0,
3673                },
3674                value: Expr::Ref("src".to_string()),
3675            }],
3676            feedback_decls: vec![],
3677            feedback_assignments: vec![],
3678            state_decls: vec![],
3679            state_assignments: vec![],
3680        };
3681
3682        let result = build_graph(&prog);
3683        assert!(result.is_err());
3684        match result.unwrap_err() {
3685            BuildError::UndefinedRef(name) => assert_eq!(name, "nonexistent"),
3686            other => panic!("expected UndefinedRef, got: {:?}", other),
3687        }
3688    }
3689
3690    // ─── Typed Pack tests ───
3691
3692    #[test]
3693    fn test_typed_pack_int_literals() {
3694        // wire t = (1, 2, 3); → pack i i i
3695        let prog = Program {
3696            in_decls: vec![],
3697            out_decls: vec![],
3698            wires: vec![Wire {
3699                name: "t".to_string(),
3700                value: Expr::Tuple(vec![
3701                    Expr::Lit(LitValue::Int(1)),
3702                    Expr::Lit(LitValue::Int(2)),
3703                    Expr::Lit(LitValue::Int(3)),
3704                ]),
3705                span: None,
3706                attrs: vec![],
3707            }],
3708            destructuring_wires: vec![],
3709            msg_decls: vec![],
3710            out_assignments: vec![],
3711            direct_connections: vec![],
3712            feedback_decls: vec![],
3713            feedback_assignments: vec![],
3714            state_decls: vec![],
3715            state_assignments: vec![],
3716        };
3717
3718        let graph = build_graph(&prog).unwrap();
3719        let pack_node = graph
3720            .nodes
3721            .iter()
3722            .find(|n| n.object_name == "pack")
3723            .expect("pack node should exist");
3724        assert_eq!(pack_node.args, vec!["i", "i", "i"]);
3725    }
3726
3727    #[test]
3728    fn test_typed_pack_mixed_literals() {
3729        // wire t = (1, 0.5, "x"); → pack i f s
3730        let prog = Program {
3731            in_decls: vec![],
3732            out_decls: vec![],
3733            wires: vec![Wire {
3734                name: "t".to_string(),
3735                value: Expr::Tuple(vec![
3736                    Expr::Lit(LitValue::Int(1)),
3737                    Expr::Lit(LitValue::Float(0.5)),
3738                    Expr::Lit(LitValue::Str("x".to_string())),
3739                ]),
3740                span: None,
3741                attrs: vec![],
3742            }],
3743            destructuring_wires: vec![],
3744            msg_decls: vec![],
3745            out_assignments: vec![],
3746            direct_connections: vec![],
3747            feedback_decls: vec![],
3748            feedback_assignments: vec![],
3749            state_decls: vec![],
3750            state_assignments: vec![],
3751        };
3752
3753        let graph = build_graph(&prog).unwrap();
3754        let pack_node = graph
3755            .nodes
3756            .iter()
3757            .find(|n| n.object_name == "pack")
3758            .expect("pack node should exist");
3759        assert_eq!(pack_node.args, vec!["i", "f", "s"]);
3760    }
3761
3762    #[test]
3763    fn test_typed_pack_ref_fallback() {
3764        // wire t = (x, y); -> pack f f (Ref falls back)
3765        let prog = Program {
3766            in_decls: vec![
3767                InDecl {
3768                    index: 0,
3769                    name: "x".to_string(),
3770                    port_type: PortType::Float,
3771                },
3772                InDecl {
3773                    index: 1,
3774                    name: "y".to_string(),
3775                    port_type: PortType::Float,
3776                },
3777            ],
3778            out_decls: vec![],
3779            wires: vec![Wire {
3780                name: "t".to_string(),
3781                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
3782                span: None,
3783                attrs: vec![],
3784            }],
3785            destructuring_wires: vec![],
3786            msg_decls: vec![],
3787            out_assignments: vec![],
3788            direct_connections: vec![],
3789            feedback_decls: vec![],
3790            feedback_assignments: vec![],
3791            state_decls: vec![],
3792            state_assignments: vec![],
3793        };
3794
3795        let graph = build_graph(&prog).unwrap();
3796        let pack_node = graph
3797            .nodes
3798            .iter()
3799            .find(|n| n.object_name == "pack")
3800            .expect("pack node should exist");
3801        assert_eq!(pack_node.args, vec!["f", "f"]);
3802    }
3803
3804    // ─── E020: bare multi-outlet ref tests ───
3805
3806    #[test]
3807    fn test_bare_multi_outlet_ref_ok() {
3808        // wire x = line~(arg0); out[0] = x; -> OK (bare = outlet 0, E020 removed)
3809        let prog = Program {
3810            in_decls: vec![InDecl {
3811                index: 0,
3812                name: "arg0".to_string(),
3813                port_type: PortType::Signal,
3814            }],
3815            out_decls: vec![OutDecl {
3816                index: 0,
3817                name: "out".to_string(),
3818                port_type: PortType::Signal,
3819                value: None,
3820            }],
3821            wires: vec![Wire {
3822                name: "result".to_string(),
3823                value: Expr::Call {
3824                    object: "line~".to_string(),
3825                    args: vec![CallArg::positional(Expr::Ref("arg0".to_string()))],
3826                },
3827                span: None,
3828                attrs: vec![],
3829            }],
3830            destructuring_wires: vec![],
3831            msg_decls: vec![],
3832            out_assignments: vec![OutAssignment {
3833                index: 0,
3834                value: Expr::Ref("result".to_string()),
3835                span: None,
3836            }],
3837            direct_connections: vec![],
3838            feedback_decls: vec![],
3839            feedback_assignments: vec![],
3840            state_decls: vec![],
3841            state_assignments: vec![],
3842        };
3843
3844        let result = build_graph(&prog);
3845        assert!(
3846            result.is_ok(),
3847            "bare reference to multi-outlet node should be OK"
3848        );
3849    }
3850
3851    #[test]
3852    fn test_e020_output_port_access_ok() {
3853        // wire x = line~(arg0); out[0] = x.out[0]; → OK
3854        use flutmax_ast::OutputPortAccess;
3855
3856        let prog = Program {
3857            in_decls: vec![InDecl {
3858                index: 0,
3859                name: "arg0".to_string(),
3860                port_type: PortType::Signal,
3861            }],
3862            out_decls: vec![OutDecl {
3863                index: 0,
3864                name: "out".to_string(),
3865                port_type: PortType::Signal,
3866                value: None,
3867            }],
3868            wires: vec![Wire {
3869                name: "result".to_string(),
3870                value: Expr::Call {
3871                    object: "line~".to_string(),
3872                    args: vec![CallArg::positional(Expr::Ref("arg0".to_string()))],
3873                },
3874                span: None,
3875                attrs: vec![],
3876            }],
3877            destructuring_wires: vec![],
3878            msg_decls: vec![],
3879            out_assignments: vec![OutAssignment {
3880                index: 0,
3881                value: Expr::OutputPortAccess(OutputPortAccess {
3882                    object: "result".to_string(),
3883                    index: 0,
3884                }),
3885                span: None,
3886            }],
3887            direct_connections: vec![],
3888            feedback_decls: vec![],
3889            feedback_assignments: vec![],
3890            state_decls: vec![],
3891            state_assignments: vec![],
3892        };
3893
3894        let result = build_graph(&prog);
3895        assert!(
3896            result.is_ok(),
3897            "OutputPortAccess should bypass E020: {:?}",
3898            result.err()
3899        );
3900    }
3901
3902    #[test]
3903    fn test_e020_destructured_names_exempt() {
3904        // wire (a, b) = unpack(data); out[0] = a; → OK (destructured names exempt from E020)
3905        use flutmax_ast::DestructuringWire;
3906
3907        let prog = Program {
3908            in_decls: vec![InDecl {
3909                index: 0,
3910                name: "data".to_string(),
3911                port_type: PortType::Float,
3912            }],
3913            out_decls: vec![OutDecl {
3914                index: 0,
3915                name: "x".to_string(),
3916                port_type: PortType::Float,
3917                value: None,
3918            }],
3919            wires: vec![],
3920            destructuring_wires: vec![DestructuringWire {
3921                names: vec!["a".to_string(), "b".to_string()],
3922                value: Expr::Call {
3923                    object: "unpack".to_string(),
3924                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3925                },
3926                span: None,
3927            }],
3928            msg_decls: vec![],
3929            out_assignments: vec![OutAssignment {
3930                index: 0,
3931                value: Expr::Ref("a".to_string()),
3932                span: None,
3933            }],
3934            direct_connections: vec![],
3935            feedback_decls: vec![],
3936            feedback_assignments: vec![],
3937            state_decls: vec![],
3938            state_assignments: vec![],
3939        };
3940
3941        let result = build_graph(&prog);
3942        assert!(
3943            result.is_ok(),
3944            "destructured name should not trigger E020: {:?}",
3945            result.err()
3946        );
3947    }
3948
3949    #[test]
3950    fn test_single_outlet_bare_ref_ok() {
3951        // wire x = cycle~(440); out[0] = x; → OK (single outlet)
3952        let prog = Program {
3953            in_decls: vec![],
3954            out_decls: vec![OutDecl {
3955                index: 0,
3956                name: "out".to_string(),
3957                port_type: PortType::Signal,
3958                value: None,
3959            }],
3960            wires: vec![Wire {
3961                name: "osc".to_string(),
3962                value: Expr::Call {
3963                    object: "cycle~".to_string(),
3964                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3965                },
3966                span: None,
3967                attrs: vec![],
3968            }],
3969            destructuring_wires: vec![],
3970            msg_decls: vec![],
3971            out_assignments: vec![OutAssignment {
3972                index: 0,
3973                value: Expr::Ref("osc".to_string()),
3974                span: None,
3975            }],
3976            direct_connections: vec![],
3977            feedback_decls: vec![],
3978            feedback_assignments: vec![],
3979            state_decls: vec![],
3980            state_assignments: vec![],
3981        };
3982
3983        let result = build_graph(&prog);
3984        assert!(
3985            result.is_ok(),
3986            "single outlet bare ref should be OK: {:?}",
3987            result.err()
3988        );
3989    }
3990
3991    // ─── State tests ───
3992
3993    #[test]
3994    fn test_state_decl_creates_int_node() {
3995        // state counter: int = 0; -> [int 0] node is generated
3996        let prog = Program {
3997            in_decls: vec![],
3998            out_decls: vec![],
3999            wires: vec![],
4000            destructuring_wires: vec![],
4001            msg_decls: vec![],
4002            out_assignments: vec![],
4003            direct_connections: vec![],
4004            feedback_decls: vec![],
4005            feedback_assignments: vec![],
4006            state_decls: vec![StateDecl {
4007                name: "counter".to_string(),
4008                port_type: PortType::Int,
4009                init_value: Expr::Lit(LitValue::Int(0)),
4010                span: None,
4011            }],
4012            state_assignments: vec![],
4013        };
4014
4015        let graph = build_graph(&prog).unwrap();
4016
4017        assert_eq!(graph.nodes.len(), 1);
4018        let node = &graph.nodes[0];
4019        assert_eq!(node.object_name, "int");
4020        assert_eq!(node.args, vec!["0"]);
4021        assert_eq!(node.num_inlets, 2);
4022        assert_eq!(node.num_outlets, 1);
4023        assert!(!node.is_signal);
4024        assert_eq!(node.varname, Some("counter".to_string()));
4025        // inlet 0 hot, inlet 1 cold
4026        assert_eq!(node.hot_inlets, vec![true, false]);
4027    }
4028
4029    #[test]
4030    fn test_state_decl_creates_float_node() {
4031        // state volume: float = 0.5; -> [float 0.5] node is generated
4032        let prog = Program {
4033            in_decls: vec![],
4034            out_decls: vec![],
4035            wires: vec![],
4036            destructuring_wires: vec![],
4037            msg_decls: vec![],
4038            out_assignments: vec![],
4039            direct_connections: vec![],
4040            feedback_decls: vec![],
4041            feedback_assignments: vec![],
4042            state_decls: vec![StateDecl {
4043                name: "volume".to_string(),
4044                port_type: PortType::Float,
4045                init_value: Expr::Lit(LitValue::Float(0.5)),
4046                span: None,
4047            }],
4048            state_assignments: vec![],
4049        };
4050
4051        let graph = build_graph(&prog).unwrap();
4052
4053        assert_eq!(graph.nodes.len(), 1);
4054        let node = &graph.nodes[0];
4055        assert_eq!(node.object_name, "float");
4056        assert_eq!(node.args, vec!["0.5"]);
4057        assert_eq!(node.varname, Some("volume".to_string()));
4058    }
4059
4060    #[test]
4061    fn test_state_assignment_connects_to_cold_inlet() {
4062        // state counter: int = 0;
4063        // wire next = add(counter, 1);
4064        // state counter = next;
4065        // -> next -> int inlet 1 (cold)
4066        let prog = Program {
4067            in_decls: vec![],
4068            out_decls: vec![],
4069            wires: vec![Wire {
4070                name: "next".to_string(),
4071                value: Expr::Call {
4072                    object: "add".to_string(),
4073                    args: vec![
4074                        CallArg::positional(Expr::Ref("counter".to_string())),
4075                        CallArg::positional(Expr::Lit(LitValue::Int(1))),
4076                    ],
4077                },
4078                span: None,
4079                attrs: vec![],
4080            }],
4081            destructuring_wires: vec![],
4082            msg_decls: vec![],
4083            out_assignments: vec![],
4084            direct_connections: vec![],
4085            feedback_decls: vec![],
4086            feedback_assignments: vec![],
4087            state_decls: vec![StateDecl {
4088                name: "counter".to_string(),
4089                port_type: PortType::Int,
4090                init_value: Expr::Lit(LitValue::Int(0)),
4091                span: None,
4092            }],
4093            state_assignments: vec![StateAssignment {
4094                name: "counter".to_string(),
4095                value: Expr::Ref("next".to_string()),
4096                span: None,
4097            }],
4098        };
4099
4100        let graph = build_graph(&prog).unwrap();
4101
4102        // int node (state)
4103        let int_node = graph
4104            .nodes
4105            .iter()
4106            .find(|n| n.object_name == "int")
4107            .expect("int node should exist");
4108
4109        // add node (next)
4110        let add_node = graph
4111            .nodes
4112            .iter()
4113            .find(|n| n.object_name == "+")
4114            .expect("add node should exist");
4115
4116        // Edge add -> int inlet 1 exists
4117        let edge = graph
4118            .edges
4119            .iter()
4120            .find(|e| e.source_id == add_node.id && e.dest_id == int_node.id)
4121            .expect("edge from add to int should exist");
4122        assert_eq!(
4123            edge.dest_inlet, 1,
4124            "state assignment should connect to cold inlet (1)"
4125        );
4126    }
4127
4128    #[test]
4129    fn test_state_ref_in_wire_expression() {
4130        // state counter: int = 0;
4131        // wire next = add(counter, 1);
4132        // -> counter reference creates edge from int node outlet 0 -> add inlet 0
4133        let prog = Program {
4134            in_decls: vec![],
4135            out_decls: vec![],
4136            wires: vec![Wire {
4137                name: "next".to_string(),
4138                value: Expr::Call {
4139                    object: "add".to_string(),
4140                    args: vec![
4141                        CallArg::positional(Expr::Ref("counter".to_string())),
4142                        CallArg::positional(Expr::Lit(LitValue::Int(1))),
4143                    ],
4144                },
4145                span: None,
4146                attrs: vec![],
4147            }],
4148            destructuring_wires: vec![],
4149            msg_decls: vec![],
4150            out_assignments: vec![],
4151            direct_connections: vec![],
4152            feedback_decls: vec![],
4153            feedback_assignments: vec![],
4154            state_decls: vec![StateDecl {
4155                name: "counter".to_string(),
4156                port_type: PortType::Int,
4157                init_value: Expr::Lit(LitValue::Int(0)),
4158                span: None,
4159            }],
4160            state_assignments: vec![],
4161        };
4162
4163        let graph = build_graph(&prog).unwrap();
4164
4165        let int_node = graph
4166            .nodes
4167            .iter()
4168            .find(|n| n.object_name == "int")
4169            .expect("int node should exist");
4170        let add_node = graph
4171            .nodes
4172            .iter()
4173            .find(|n| n.object_name == "+")
4174            .expect("add node should exist");
4175
4176        // Edge int -> add inlet 0 exists
4177        let edge = graph
4178            .edges
4179            .iter()
4180            .find(|e| e.source_id == int_node.id && e.dest_id == add_node.id)
4181            .expect("edge from int to add should exist");
4182        assert_eq!(edge.source_outlet, 0);
4183        assert_eq!(edge.dest_inlet, 0);
4184    }
4185
4186    #[test]
4187    fn test_e019_duplicate_state_assignment() {
4188        // state counter: int = 0;
4189        // state counter = a;
4190        // state counter = b;  → E019
4191        let prog = Program {
4192            in_decls: vec![],
4193            out_decls: vec![],
4194            wires: vec![
4195                Wire {
4196                    name: "a".to_string(),
4197                    value: Expr::Call {
4198                        object: "button".to_string(),
4199                        args: vec![],
4200                    },
4201                    span: None,
4202                    attrs: vec![],
4203                },
4204                Wire {
4205                    name: "b".to_string(),
4206                    value: Expr::Call {
4207                        object: "button".to_string(),
4208                        args: vec![],
4209                    },
4210                    span: None,
4211                    attrs: vec![],
4212                },
4213            ],
4214            destructuring_wires: vec![],
4215            msg_decls: vec![],
4216            out_assignments: vec![],
4217            direct_connections: vec![],
4218            feedback_decls: vec![],
4219            feedback_assignments: vec![],
4220            state_decls: vec![StateDecl {
4221                name: "counter".to_string(),
4222                port_type: PortType::Int,
4223                init_value: Expr::Lit(LitValue::Int(0)),
4224                span: None,
4225            }],
4226            state_assignments: vec![
4227                StateAssignment {
4228                    name: "counter".to_string(),
4229                    value: Expr::Ref("a".to_string()),
4230                    span: None,
4231                },
4232                StateAssignment {
4233                    name: "counter".to_string(),
4234                    value: Expr::Ref("b".to_string()),
4235                    span: None,
4236                },
4237            ],
4238        };
4239
4240        let result = build_graph(&prog);
4241        assert!(result.is_err());
4242        match result.unwrap_err() {
4243            BuildError::DuplicateStateAssignment(name) => assert_eq!(name, "counter"),
4244            other => panic!("expected DuplicateStateAssignment, got {:?}", other),
4245        }
4246    }
4247
4248    #[test]
4249    fn test_state_single_assignment_no_error() {
4250        // Single state assignment does not error
4251        let prog = Program {
4252            in_decls: vec![],
4253            out_decls: vec![],
4254            wires: vec![Wire {
4255                name: "val".to_string(),
4256                value: Expr::Call {
4257                    object: "button".to_string(),
4258                    args: vec![],
4259                },
4260                span: None,
4261                attrs: vec![],
4262            }],
4263            destructuring_wires: vec![],
4264            msg_decls: vec![],
4265            out_assignments: vec![],
4266            direct_connections: vec![],
4267            feedback_decls: vec![],
4268            feedback_assignments: vec![],
4269            state_decls: vec![StateDecl {
4270                name: "counter".to_string(),
4271                port_type: PortType::Int,
4272                init_value: Expr::Lit(LitValue::Int(0)),
4273                span: None,
4274            }],
4275            state_assignments: vec![StateAssignment {
4276                name: "counter".to_string(),
4277                value: Expr::Ref("val".to_string()),
4278                span: None,
4279            }],
4280        };
4281
4282        let result = build_graph(&prog);
4283        assert!(result.is_ok());
4284    }
4285
4286    // ─── E20: Typed Destructuring (unpack subtype propagation) tests ───
4287
4288    #[test]
4289    fn test_typed_unpack_from_int_tuple() {
4290        // wire t = (1, 2, 3); wire (a, b, c) = t;
4291        // → auto-inserted unpack should have args ["i", "i", "i"]
4292        use flutmax_ast::DestructuringWire;
4293
4294        let prog = Program {
4295            in_decls: vec![],
4296            out_decls: vec![],
4297            wires: vec![Wire {
4298                name: "t".to_string(),
4299                value: Expr::Tuple(vec![
4300                    Expr::Lit(LitValue::Int(1)),
4301                    Expr::Lit(LitValue::Int(2)),
4302                    Expr::Lit(LitValue::Int(3)),
4303                ]),
4304                span: None,
4305                attrs: vec![],
4306            }],
4307            destructuring_wires: vec![DestructuringWire {
4308                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
4309                value: Expr::Ref("t".to_string()),
4310                span: None,
4311            }],
4312            msg_decls: vec![],
4313            out_assignments: vec![],
4314            direct_connections: vec![],
4315            feedback_decls: vec![],
4316            feedback_assignments: vec![],
4317            state_decls: vec![],
4318            state_assignments: vec![],
4319        };
4320
4321        let graph = build_graph(&prog).unwrap();
4322        let unpack_node = graph
4323            .nodes
4324            .iter()
4325            .find(|n| n.object_name == "unpack")
4326            .expect("unpack node should be auto-inserted");
4327        assert_eq!(unpack_node.args, vec!["i", "i", "i"]);
4328    }
4329
4330    #[test]
4331    fn test_typed_unpack_from_mixed_tuple() {
4332        // wire t = (1, 0.5, "x"); wire (a, b, c) = t;
4333        // → auto-inserted unpack should have args ["i", "f", "s"]
4334        use flutmax_ast::DestructuringWire;
4335
4336        let prog = Program {
4337            in_decls: vec![],
4338            out_decls: vec![],
4339            wires: vec![Wire {
4340                name: "t".to_string(),
4341                value: Expr::Tuple(vec![
4342                    Expr::Lit(LitValue::Int(1)),
4343                    Expr::Lit(LitValue::Float(0.5)),
4344                    Expr::Lit(LitValue::Str("x".to_string())),
4345                ]),
4346                span: None,
4347                attrs: vec![],
4348            }],
4349            destructuring_wires: vec![DestructuringWire {
4350                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
4351                value: Expr::Ref("t".to_string()),
4352                span: None,
4353            }],
4354            msg_decls: vec![],
4355            out_assignments: vec![],
4356            direct_connections: vec![],
4357            feedback_decls: vec![],
4358            feedback_assignments: vec![],
4359            state_decls: vec![],
4360            state_assignments: vec![],
4361        };
4362
4363        let graph = build_graph(&prog).unwrap();
4364        let unpack_node = graph
4365            .nodes
4366            .iter()
4367            .find(|n| n.object_name == "unpack")
4368            .expect("unpack node should be auto-inserted");
4369        assert_eq!(unpack_node.args, vec!["i", "f", "s"]);
4370    }
4371
4372    #[test]
4373    fn test_typed_unpack_unknown_source_fallback() {
4374        // wire (a, b) = unpack(data); — data is an inlet (not tuple)
4375        // → unpack should have default args ["f", "f"]
4376        use flutmax_ast::DestructuringWire;
4377
4378        let prog = Program {
4379            in_decls: vec![InDecl {
4380                index: 0,
4381                name: "data".to_string(),
4382                port_type: PortType::Float,
4383            }],
4384            out_decls: vec![],
4385            wires: vec![],
4386            destructuring_wires: vec![DestructuringWire {
4387                names: vec!["a".to_string(), "b".to_string()],
4388                value: Expr::Call {
4389                    object: "unpack".to_string(),
4390                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
4391                },
4392                span: None,
4393            }],
4394            msg_decls: vec![],
4395            out_assignments: vec![],
4396            direct_connections: vec![],
4397            feedback_decls: vec![],
4398            feedback_assignments: vec![],
4399            state_decls: vec![],
4400            state_assignments: vec![],
4401        };
4402
4403        let graph = build_graph(&prog).unwrap();
4404        let unpack_nodes: Vec<_> = graph
4405            .nodes
4406            .iter()
4407            .filter(|n| n.object_name == "unpack")
4408            .collect();
4409        assert_eq!(unpack_nodes.len(), 1);
4410        // Explicit unpack call: the resolve_expr creates it with default num_outlets=2
4411        // which matches names count, so no auto-inserted unpack needed.
4412        // The Call-generated unpack has its own arg handling.
4413    }
4414
4415    #[test]
4416    fn test_typed_unpack_ref_to_tuple_with_refs() {
4417        // wire t = (x, y); wire (a, b) = t;
4418        // → Ref elements fall back to "f", so unpack args = ["f", "f"]
4419        use flutmax_ast::DestructuringWire;
4420
4421        let prog = Program {
4422            in_decls: vec![
4423                InDecl {
4424                    index: 0,
4425                    name: "x".to_string(),
4426                    port_type: PortType::Float,
4427                },
4428                InDecl {
4429                    index: 1,
4430                    name: "y".to_string(),
4431                    port_type: PortType::Float,
4432                },
4433            ],
4434            out_decls: vec![],
4435            wires: vec![Wire {
4436                name: "t".to_string(),
4437                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
4438                span: None,
4439                attrs: vec![],
4440            }],
4441            destructuring_wires: vec![DestructuringWire {
4442                names: vec!["a".to_string(), "b".to_string()],
4443                value: Expr::Ref("t".to_string()),
4444                span: None,
4445            }],
4446            msg_decls: vec![],
4447            out_assignments: vec![],
4448            direct_connections: vec![],
4449            feedback_decls: vec![],
4450            feedback_assignments: vec![],
4451            state_decls: vec![],
4452            state_assignments: vec![],
4453        };
4454
4455        let graph = build_graph(&prog).unwrap();
4456        let unpack_node = graph
4457            .nodes
4458            .iter()
4459            .find(|n| n.object_name == "unpack")
4460            .expect("unpack node should be auto-inserted");
4461        assert_eq!(unpack_node.args, vec!["f", "f"]);
4462    }
4463
4464    // ========================================
4465    // W001: Duplicate connection to same inlet warning
4466    // ========================================
4467
4468    #[test]
4469    fn test_w001_duplicate_inlet_detected() {
4470        // 2 connections to target.in[0] -> W001 warning
4471        let prog = Program {
4472            in_decls: vec![],
4473            out_decls: vec![],
4474            wires: vec![
4475                Wire {
4476                    name: "a".to_string(),
4477                    value: Expr::Call {
4478                        object: "button".to_string(),
4479                        args: vec![],
4480                    },
4481                    span: None,
4482                    attrs: vec![],
4483                },
4484                Wire {
4485                    name: "b".to_string(),
4486                    value: Expr::Call {
4487                        object: "button".to_string(),
4488                        args: vec![],
4489                    },
4490                    span: None,
4491                    attrs: vec![],
4492                },
4493                Wire {
4494                    name: "target".to_string(),
4495                    value: Expr::Call {
4496                        object: "+".to_string(),
4497                        args: vec![
4498                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4499                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4500                        ],
4501                    },
4502                    span: None,
4503                    attrs: vec![],
4504                },
4505            ],
4506            destructuring_wires: vec![],
4507            msg_decls: vec![],
4508            out_assignments: vec![],
4509            direct_connections: vec![
4510                DirectConnection {
4511                    target: flutmax_ast::InputPortAccess {
4512                        object: "target".to_string(),
4513                        index: 0,
4514                    },
4515                    value: Expr::Ref("a".to_string()),
4516                },
4517                DirectConnection {
4518                    target: flutmax_ast::InputPortAccess {
4519                        object: "target".to_string(),
4520                        index: 0,
4521                    },
4522                    value: Expr::Ref("b".to_string()),
4523                },
4524            ],
4525            feedback_decls: vec![],
4526            feedback_assignments: vec![],
4527            state_decls: vec![],
4528            state_assignments: vec![],
4529        };
4530
4531        let result = build_graph_with_warnings(&prog).unwrap();
4532        assert_eq!(result.warnings.len(), 1);
4533        match &result.warnings[0] {
4534            BuildWarning::DuplicateInletConnection {
4535                node_id: _,
4536                inlet,
4537                count,
4538            } => {
4539                assert_eq!(*inlet, 0);
4540                assert_eq!(*count, 2);
4541            }
4542        }
4543    }
4544
4545    #[test]
4546    fn test_w001_no_warning_single_connection() {
4547        // 1 connection per inlet -> no warning
4548        let prog = Program {
4549            in_decls: vec![],
4550            out_decls: vec![],
4551            wires: vec![
4552                Wire {
4553                    name: "a".to_string(),
4554                    value: Expr::Call {
4555                        object: "button".to_string(),
4556                        args: vec![],
4557                    },
4558                    span: None,
4559                    attrs: vec![],
4560                },
4561                Wire {
4562                    name: "target".to_string(),
4563                    value: Expr::Call {
4564                        object: "+".to_string(),
4565                        args: vec![
4566                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4567                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4568                        ],
4569                    },
4570                    span: None,
4571                    attrs: vec![],
4572                },
4573            ],
4574            destructuring_wires: vec![],
4575            msg_decls: vec![],
4576            out_assignments: vec![],
4577            direct_connections: vec![DirectConnection {
4578                target: flutmax_ast::InputPortAccess {
4579                    object: "target".to_string(),
4580                    index: 1,
4581                },
4582                value: Expr::Ref("a".to_string()),
4583            }],
4584            feedback_decls: vec![],
4585            feedback_assignments: vec![],
4586            state_decls: vec![],
4587            state_assignments: vec![],
4588        };
4589
4590        let result = build_graph_with_warnings(&prog).unwrap();
4591        assert!(
4592            result.warnings.is_empty(),
4593            "single connections should not trigger W001"
4594        );
4595    }
4596
4597    #[test]
4598    fn test_w001_display_format() {
4599        let warning = BuildWarning::DuplicateInletConnection {
4600            node_id: "obj-3".to_string(),
4601            inlet: 0,
4602            count: 2,
4603        };
4604        assert_eq!(format!("{}", warning), "W001: 2 connections to obj-3.in[0]");
4605    }
4606
4607    // ─── msg declaration tests ───
4608
4609    #[test]
4610    fn test_msg_creates_message_node() {
4611        let prog = Program {
4612            in_decls: vec![],
4613            out_decls: vec![OutDecl {
4614                index: 0,
4615                name: "output".to_string(),
4616                port_type: PortType::Bang,
4617                value: None,
4618            }],
4619            wires: vec![],
4620            destructuring_wires: vec![],
4621            msg_decls: vec![MsgDecl {
4622                name: "click".to_string(),
4623                content: "bang".to_string(),
4624                span: None,
4625                attrs: vec![],
4626            }],
4627            out_assignments: vec![OutAssignment {
4628                index: 0,
4629                value: Expr::Ref("click".to_string()),
4630                span: None,
4631            }],
4632            direct_connections: vec![],
4633            feedback_decls: vec![],
4634            feedback_assignments: vec![],
4635            state_decls: vec![],
4636            state_assignments: vec![],
4637        };
4638
4639        let graph = build_graph(&prog).unwrap();
4640
4641        // Find the message node
4642        let msg_node = graph
4643            .nodes
4644            .iter()
4645            .find(|n| n.object_name == "message")
4646            .expect("should have a message node");
4647
4648        assert_eq!(msg_node.args, vec!["bang"]);
4649        assert_eq!(msg_node.num_inlets, 2);
4650        assert_eq!(msg_node.num_outlets, 1);
4651        assert!(!msg_node.is_signal);
4652        assert_eq!(msg_node.varname, Some("click".to_string()));
4653    }
4654
4655    #[test]
4656    fn test_msg_connectable_as_source() {
4657        let prog = Program {
4658            in_decls: vec![],
4659            out_decls: vec![],
4660            wires: vec![Wire {
4661                name: "printer".to_string(),
4662                value: Expr::Call {
4663                    object: "print".to_string(),
4664                    args: vec![CallArg::positional(Expr::Ref("click".to_string()))],
4665                },
4666                span: None,
4667                attrs: vec![],
4668            }],
4669            destructuring_wires: vec![],
4670            msg_decls: vec![MsgDecl {
4671                name: "click".to_string(),
4672                content: "bang".to_string(),
4673                span: None,
4674                attrs: vec![],
4675            }],
4676            out_assignments: vec![],
4677            direct_connections: vec![],
4678            feedback_decls: vec![],
4679            feedback_assignments: vec![],
4680            state_decls: vec![],
4681            state_assignments: vec![],
4682        };
4683
4684        let graph = build_graph(&prog).unwrap();
4685
4686        // Should have edge from message node to print node
4687        assert!(!graph.edges.is_empty(), "should have at least one edge");
4688        let msg_node = graph
4689            .nodes
4690            .iter()
4691            .find(|n| n.object_name == "message")
4692            .expect("message node");
4693        let print_node = graph
4694            .nodes
4695            .iter()
4696            .find(|n| n.object_name == "print")
4697            .expect("print node");
4698
4699        let edge = graph
4700            .edges
4701            .iter()
4702            .find(|e| e.source_id == msg_node.id && e.dest_id == print_node.id)
4703            .expect("edge from message to print");
4704        assert_eq!(edge.source_outlet, 0);
4705        assert_eq!(edge.dest_inlet, 0);
4706    }
4707
4708    // ─── Dotted identifier tests ───
4709
4710    #[test]
4711    fn test_dotted_object_name_in_call() {
4712        let prog = Program {
4713            in_decls: vec![],
4714            out_decls: vec![OutDecl {
4715                index: 0,
4716                name: "output".to_string(),
4717                port_type: PortType::Float,
4718                value: None,
4719            }],
4720            wires: vec![Wire {
4721                name: "dial".to_string(),
4722                value: Expr::Call {
4723                    object: "live.dial".to_string(),
4724                    args: vec![CallArg::positional(Expr::Lit(LitValue::Float(0.5)))],
4725                },
4726                span: None,
4727                attrs: vec![],
4728            }],
4729            destructuring_wires: vec![],
4730            msg_decls: vec![],
4731            out_assignments: vec![OutAssignment {
4732                index: 0,
4733                value: Expr::OutputPortAccess(OutputPortAccess {
4734                    object: "dial".to_string(),
4735                    index: 0,
4736                }),
4737                span: None,
4738            }],
4739            direct_connections: vec![],
4740            feedback_decls: vec![],
4741            feedback_assignments: vec![],
4742            state_decls: vec![],
4743            state_assignments: vec![],
4744        };
4745
4746        let graph = build_graph(&prog).unwrap();
4747
4748        // Should have a live.dial node
4749        let dial_node = graph
4750            .nodes
4751            .iter()
4752            .find(|n| n.object_name == "live.dial")
4753            .expect("should have a live.dial node");
4754        assert_eq!(dial_node.args, vec!["0.5"]);
4755    }
4756
4757    // ================================================
4758    // .attr() chain builder tests
4759    // ================================================
4760
4761    #[test]
4762    fn test_wire_attrs_propagated_to_node() {
4763        use flutmax_ast::AttrPair;
4764
4765        let prog = Program {
4766            in_decls: vec![],
4767            out_decls: vec![],
4768            wires: vec![Wire {
4769                name: "w".to_string(),
4770                value: Expr::Call {
4771                    object: "flonum".to_string(),
4772                    args: vec![],
4773                },
4774                span: None,
4775                attrs: vec![
4776                    AttrPair {
4777                        key: "minimum".to_string(),
4778                        value: flutmax_ast::AttrValue::Float(0.0),
4779                    },
4780                    AttrPair {
4781                        key: "maximum".to_string(),
4782                        value: flutmax_ast::AttrValue::Float(100.0),
4783                    },
4784                ],
4785            }],
4786            destructuring_wires: vec![],
4787            msg_decls: vec![],
4788            out_assignments: vec![],
4789            direct_connections: vec![],
4790            feedback_decls: vec![],
4791            feedback_assignments: vec![],
4792            state_decls: vec![],
4793            state_assignments: vec![],
4794        };
4795
4796        let graph = build_graph(&prog).unwrap();
4797
4798        let fnum = graph
4799            .nodes
4800            .iter()
4801            .find(|n| n.object_name == "flonum")
4802            .expect("should have a flonum node");
4803
4804        assert_eq!(fnum.attrs.len(), 2);
4805        assert_eq!(fnum.attrs[0], ("minimum".to_string(), "0.".to_string()));
4806        assert_eq!(fnum.attrs[1], ("maximum".to_string(), "100.".to_string()));
4807    }
4808
4809    #[test]
4810    fn test_msg_attrs_propagated_to_node() {
4811        use flutmax_ast::AttrPair;
4812
4813        let prog = Program {
4814            in_decls: vec![],
4815            out_decls: vec![],
4816            wires: vec![],
4817            destructuring_wires: vec![],
4818            msg_decls: vec![MsgDecl {
4819                name: "click".to_string(),
4820                content: "bang".to_string(),
4821                span: None,
4822                attrs: vec![AttrPair {
4823                    key: "patching_rect".to_string(),
4824                    value: flutmax_ast::AttrValue::Float(100.0),
4825                }],
4826            }],
4827            out_assignments: vec![],
4828            direct_connections: vec![],
4829            feedback_decls: vec![],
4830            feedback_assignments: vec![],
4831            state_decls: vec![],
4832            state_assignments: vec![],
4833        };
4834
4835        let graph = build_graph(&prog).unwrap();
4836
4837        let msg = graph
4838            .nodes
4839            .iter()
4840            .find(|n| n.object_name == "message")
4841            .expect("should have a message node");
4842
4843        assert_eq!(msg.attrs.len(), 1);
4844        assert_eq!(
4845            msg.attrs[0],
4846            ("patching_rect".to_string(), "100.".to_string())
4847        );
4848    }
4849
4850    #[test]
4851    fn test_wire_no_attrs_empty() {
4852        let prog = Program {
4853            in_decls: vec![],
4854            out_decls: vec![],
4855            wires: vec![Wire {
4856                name: "osc".to_string(),
4857                value: Expr::Call {
4858                    object: "cycle~".to_string(),
4859                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
4860                },
4861                span: None,
4862                attrs: vec![],
4863            }],
4864            destructuring_wires: vec![],
4865            msg_decls: vec![],
4866            out_assignments: vec![],
4867            direct_connections: vec![],
4868            feedback_decls: vec![],
4869            feedback_assignments: vec![],
4870            state_decls: vec![],
4871            state_assignments: vec![],
4872        };
4873
4874        let graph = build_graph(&prog).unwrap();
4875
4876        let osc = graph
4877            .nodes
4878            .iter()
4879            .find(|n| n.object_name == "cycle~")
4880            .expect("should have a cycle~ node");
4881
4882        assert!(osc.attrs.is_empty());
4883    }
4884
4885    // ================================================
4886    // infer_num_outlets / infer_num_inlets unit tests
4887    // ================================================
4888
4889    #[test]
4890    fn test_infer_outlets_select_single_arg() {
4891        // select 0 → 2 outlets (1 match + 1 unmatched)
4892        assert_eq!(infer_num_outlets("select", &["0".to_string()], None), 2);
4893    }
4894
4895    #[test]
4896    fn test_infer_outlets_select_multiple_args() {
4897        // select 1 2 3 → 4 outlets (3 matches + 1 unmatched)
4898        assert_eq!(
4899            infer_num_outlets(
4900                "select",
4901                &["1".to_string(), "2".to_string(), "3".to_string()],
4902                None
4903            ),
4904            4
4905        );
4906    }
4907
4908    #[test]
4909    fn test_infer_outlets_sel_alias() {
4910        // sel 0 → 2 outlets (same as select)
4911        assert_eq!(infer_num_outlets("sel", &["0".to_string()], None), 2);
4912    }
4913
4914    #[test]
4915    fn test_infer_outlets_select_no_args() {
4916        // select with no args → default 2
4917        assert_eq!(infer_num_outlets("select", &[], None), 2);
4918    }
4919
4920    #[test]
4921    fn test_infer_outlets_trigger_two_args() {
4922        // trigger b f → 2 outlets
4923        assert_eq!(
4924            infer_num_outlets("trigger", &["b".to_string(), "f".to_string()], None),
4925            2
4926        );
4927    }
4928
4929    #[test]
4930    fn test_infer_outlets_trigger_alias() {
4931        // t b i f → 3 outlets
4932        assert_eq!(
4933            infer_num_outlets(
4934                "t",
4935                &["b".to_string(), "i".to_string(), "f".to_string()],
4936                None
4937            ),
4938            3
4939        );
4940    }
4941
4942    #[test]
4943    fn test_infer_outlets_function() {
4944        // function → 2 outlets (list output + bang)
4945        assert_eq!(infer_num_outlets("function", &[], None), 2);
4946    }
4947
4948    #[test]
4949    fn test_infer_outlets_route() {
4950        // route a b c → 4 outlets (3 matches + 1 unmatched)
4951        assert_eq!(
4952            infer_num_outlets(
4953                "route",
4954                &["a".to_string(), "b".to_string(), "c".to_string()],
4955                None
4956            ),
4957            4
4958        );
4959    }
4960
4961    #[test]
4962    fn test_infer_outlets_gate() {
4963        // gate 3 → 3 outlets
4964        assert_eq!(infer_num_outlets("gate", &["3".to_string()], None), 3);
4965    }
4966
4967    #[test]
4968    fn test_infer_outlets_gate_default() {
4969        // gate with no args → 2
4970        assert_eq!(infer_num_outlets("gate", &[], None), 2);
4971    }
4972
4973    #[test]
4974    fn test_infer_outlets_unpack_with_args() {
4975        // unpack f f f → 3 outlets
4976        assert_eq!(
4977            infer_num_outlets(
4978                "unpack",
4979                &["f".to_string(), "f".to_string(), "f".to_string()],
4980                None
4981            ),
4982            3
4983        );
4984    }
4985
4986    #[test]
4987    fn test_infer_outlets_unpack_no_args() {
4988        // unpack with no args → default 2
4989        assert_eq!(infer_num_outlets("unpack", &[], None), 2);
4990    }
4991
4992    #[test]
4993    fn test_infer_outlets_pack() {
4994        // pack always → 1 outlet
4995        assert_eq!(
4996            infer_num_outlets("pack", &["0".to_string(), "0".to_string()], None),
4997            1
4998        );
4999    }
5000
5001    #[test]
5002    fn test_infer_outlets_fixed_objects() {
5003        // Verify expanded fixed-outlet table
5004        assert_eq!(infer_num_outlets("line", &[], None), 2);
5005        assert_eq!(infer_num_outlets("makenote", &[], None), 2);
5006        assert_eq!(infer_num_outlets("borax", &[], None), 8);
5007        assert_eq!(infer_num_outlets("counter", &[], None), 4);
5008        assert_eq!(infer_num_outlets("notein", &[], None), 3);
5009        assert_eq!(infer_num_outlets("noteout", &[], None), 0);
5010        assert_eq!(infer_num_outlets("ctlin", &[], None), 3);
5011        assert_eq!(infer_num_outlets("ctlout", &[], None), 0);
5012        assert_eq!(infer_num_outlets("midiin", &[], None), 1);
5013        assert_eq!(infer_num_outlets("midiout", &[], None), 0);
5014        assert_eq!(infer_num_outlets("coll", &[], None), 4);
5015        assert_eq!(infer_num_outlets("urn", &[], None), 2);
5016        assert_eq!(infer_num_outlets("drunk", &[], None), 1);
5017        assert_eq!(infer_num_outlets("random", &[], None), 1);
5018        assert_eq!(infer_num_outlets("match", &[], None), 2);
5019        assert_eq!(infer_num_outlets("zl", &[], None), 2);
5020        assert_eq!(infer_num_outlets("regexp", &[], None), 5);
5021        assert_eq!(infer_num_outlets("sprintf", &[], None), 1);
5022        assert_eq!(infer_num_outlets("thresh", &[], None), 2);
5023        assert_eq!(infer_num_outlets("metro", &[], None), 1);
5024        assert_eq!(infer_num_outlets("delay", &[], None), 1);
5025        assert_eq!(infer_num_outlets("speedlim", &[], None), 1);
5026    }
5027
5028    #[test]
5029    fn test_infer_outlets_signal_objects() {
5030        assert_eq!(infer_num_outlets("dspstate~", &[], None), 4);
5031        assert_eq!(infer_num_outlets("edge~", &[], None), 2);
5032        assert_eq!(infer_num_outlets("fftinfo~", &[], None), 4);
5033        assert_eq!(infer_num_outlets("fftin~", &[], None), 3);
5034        assert_eq!(infer_num_outlets("fftout~", &[], None), 1);
5035        assert_eq!(infer_num_outlets("cartopol~", &[], None), 2);
5036        assert_eq!(infer_num_outlets("poltocar~", &[], None), 2);
5037        assert_eq!(infer_num_outlets("freqshift~", &[], None), 2);
5038        assert_eq!(infer_num_outlets("curve~", &[], None), 2);
5039        assert_eq!(infer_num_outlets("adsr~", &[], None), 4);
5040        assert_eq!(infer_num_outlets("filtercoeff~", &[], None), 5);
5041        assert_eq!(infer_num_outlets("filtergraph~", &[], None), 7);
5042        assert_eq!(infer_num_outlets("noise~", &[], None), 1);
5043        assert_eq!(infer_num_outlets("phasor~", &[], None), 1);
5044        assert_eq!(infer_num_outlets("snapshot~", &[], None), 1);
5045        assert_eq!(infer_num_outlets("peakamp~", &[], None), 1);
5046        assert_eq!(infer_num_outlets("meter~", &[], None), 1);
5047    }
5048
5049    #[test]
5050    fn test_infer_inlets_expanded() {
5051        // Verify expanded inlet table
5052        assert_eq!(infer_num_inlets("function", &[], None), 2);
5053        assert_eq!(infer_num_inlets("counter", &[], None), 3);
5054        assert_eq!(infer_num_inlets("makenote", &[], None), 3);
5055        assert_eq!(infer_num_inlets("line", &[], None), 2);
5056        assert_eq!(infer_num_inlets("metro", &[], None), 2);
5057        assert_eq!(infer_num_inlets("delay", &[], None), 2);
5058        assert_eq!(infer_num_inlets("coll", &[], None), 1);
5059        assert_eq!(infer_num_inlets("urn", &[], None), 2);
5060        assert_eq!(infer_num_inlets("drunk", &[], None), 2);
5061        assert_eq!(infer_num_inlets("random", &[], None), 2);
5062    }
5063
5064    /// Integration test: select with literal arg produces correct outlet count in graph
5065    #[test]
5066    fn test_graph_select_outlet_count() {
5067        let prog = Program {
5068            in_decls: vec![],
5069            out_decls: vec![],
5070            wires: vec![Wire {
5071                name: "s".to_string(),
5072                value: Expr::Call {
5073                    object: "select".to_string(),
5074                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(0)))],
5075                },
5076                span: None,
5077                attrs: vec![],
5078            }],
5079            destructuring_wires: vec![],
5080            msg_decls: vec![],
5081            out_assignments: vec![],
5082            direct_connections: vec![],
5083            feedback_decls: vec![],
5084            feedback_assignments: vec![],
5085            state_decls: vec![],
5086            state_assignments: vec![],
5087        };
5088
5089        let graph = build_graph(&prog).unwrap();
5090        let sel = graph
5091            .nodes
5092            .iter()
5093            .find(|n| n.object_name == "select")
5094            .expect("should have a select node");
5095
5096        // select 0 → 2 outlets
5097        assert_eq!(sel.num_outlets, 2);
5098    }
5099
5100    /// Integration test: function produces correct outlet count in graph
5101    #[test]
5102    fn test_graph_function_outlet_count() {
5103        let prog = Program {
5104            in_decls: vec![],
5105            out_decls: vec![],
5106            wires: vec![Wire {
5107                name: "f".to_string(),
5108                value: Expr::Call {
5109                    object: "function".to_string(),
5110                    args: vec![],
5111                },
5112                span: None,
5113                attrs: vec![],
5114            }],
5115            destructuring_wires: vec![],
5116            msg_decls: vec![],
5117            out_assignments: vec![],
5118            direct_connections: vec![],
5119            feedback_decls: vec![],
5120            feedback_assignments: vec![],
5121            state_decls: vec![],
5122            state_assignments: vec![],
5123        };
5124
5125        let graph = build_graph(&prog).unwrap();
5126        let func = graph
5127            .nodes
5128            .iter()
5129            .find(|n| n.object_name == "function")
5130            .expect("should have a function node");
5131
5132        assert_eq!(func.num_outlets, 2);
5133    }
5134
5135    /// Integration test: trigger with multiple args
5136    #[test]
5137    fn test_graph_trigger_outlet_count() {
5138        let prog = Program {
5139            in_decls: vec![],
5140            out_decls: vec![],
5141            wires: vec![Wire {
5142                name: "tr".to_string(),
5143                value: Expr::Call {
5144                    object: "trigger".to_string(),
5145                    args: vec![
5146                        CallArg::positional(Expr::Lit(LitValue::Str("b".to_string()))),
5147                        CallArg::positional(Expr::Lit(LitValue::Str("f".to_string()))),
5148                    ],
5149                },
5150                span: None,
5151                attrs: vec![],
5152            }],
5153            destructuring_wires: vec![],
5154            msg_decls: vec![],
5155            out_assignments: vec![],
5156            direct_connections: vec![],
5157            feedback_decls: vec![],
5158            feedback_assignments: vec![],
5159            state_decls: vec![],
5160            state_assignments: vec![],
5161        };
5162
5163        let graph = build_graph(&prog).unwrap();
5164        let t = graph
5165            .nodes
5166            .iter()
5167            .find(|n| n.object_name == "trigger")
5168            .expect("should have a trigger node");
5169
5170        // trigger b f → 2 outlets
5171        assert_eq!(t.num_outlets, 2);
5172    }
5173
5174    /// Integration test: route with multiple args
5175    #[test]
5176    fn test_graph_route_outlet_count() {
5177        let prog = Program {
5178            in_decls: vec![],
5179            out_decls: vec![],
5180            wires: vec![Wire {
5181                name: "r".to_string(),
5182                value: Expr::Call {
5183                    object: "route".to_string(),
5184                    args: vec![
5185                        CallArg::positional(Expr::Lit(LitValue::Str("a".to_string()))),
5186                        CallArg::positional(Expr::Lit(LitValue::Str("b".to_string()))),
5187                        CallArg::positional(Expr::Lit(LitValue::Str("c".to_string()))),
5188                    ],
5189                },
5190                span: None,
5191                attrs: vec![],
5192            }],
5193            destructuring_wires: vec![],
5194            msg_decls: vec![],
5195            out_assignments: vec![],
5196            direct_connections: vec![],
5197            feedback_decls: vec![],
5198            feedback_assignments: vec![],
5199            state_decls: vec![],
5200            state_assignments: vec![],
5201        };
5202
5203        let graph = build_graph(&prog).unwrap();
5204        let r = graph
5205            .nodes
5206            .iter()
5207            .find(|n| n.object_name == "route")
5208            .expect("should have a route node");
5209
5210        // route a b c → 4 outlets
5211        assert_eq!(r.num_outlets, 4);
5212    }
5213
5214    #[test]
5215    fn test_codebox_with_code_files() {
5216        let mut code_files = CodeFiles::new();
5217        code_files.insert(
5218            "processor.js".to_string(),
5219            "function bang() { outlet(0, 42); }".to_string(),
5220        );
5221
5222        let prog = Program {
5223            in_decls: vec![],
5224            out_decls: vec![],
5225            wires: vec![Wire {
5226                name: "cb".to_string(),
5227                value: Expr::Call {
5228                    object: "v8.codebox".to_string(),
5229                    args: vec![CallArg::positional(Expr::Lit(LitValue::Str(
5230                        "processor.js".to_string(),
5231                    )))],
5232                },
5233                span: None,
5234                attrs: vec![],
5235            }],
5236            destructuring_wires: vec![],
5237            msg_decls: vec![],
5238            out_assignments: vec![],
5239            direct_connections: vec![],
5240            feedback_decls: vec![],
5241            feedback_assignments: vec![],
5242            state_decls: vec![],
5243            state_assignments: vec![],
5244        };
5245
5246        let graph = build_graph_with_code_files(&prog, None, Some(&code_files)).unwrap();
5247
5248        let cb_node = graph
5249            .nodes
5250            .iter()
5251            .find(|n| n.object_name == "v8.codebox")
5252            .expect("should have a v8.codebox node");
5253
5254        assert_eq!(
5255            cb_node.code,
5256            Some("function bang() { outlet(0, 42); }".to_string())
5257        );
5258        assert!(
5259            cb_node.args.is_empty(),
5260            "args should be cleared when code file is resolved"
5261        );
5262    }
5263
5264    #[test]
5265    fn test_codebox_without_code_files() {
5266        // When no code_files provided, codebox still works but code is None
5267        let prog = Program {
5268            in_decls: vec![],
5269            out_decls: vec![],
5270            wires: vec![Wire {
5271                name: "cb".to_string(),
5272                value: Expr::Call {
5273                    object: "v8.codebox".to_string(),
5274                    args: vec![CallArg::positional(Expr::Lit(LitValue::Str(
5275                        "processor.js".to_string(),
5276                    )))],
5277                },
5278                span: None,
5279                attrs: vec![],
5280            }],
5281            destructuring_wires: vec![],
5282            msg_decls: vec![],
5283            out_assignments: vec![],
5284            direct_connections: vec![],
5285            feedback_decls: vec![],
5286            feedback_assignments: vec![],
5287            state_decls: vec![],
5288            state_assignments: vec![],
5289        };
5290
5291        let graph = build_graph(&prog).unwrap();
5292
5293        let cb_node = graph
5294            .nodes
5295            .iter()
5296            .find(|n| n.object_name == "v8.codebox")
5297            .expect("should have a v8.codebox node");
5298
5299        assert_eq!(cb_node.code, None);
5300        assert_eq!(cb_node.args, vec!["processor.js"]);
5301    }
5302
5303    #[test]
5304    fn test_codebox_infer_inlets_outlets() {
5305        // v8.codebox and codebox should have default 1 inlet and 1 outlet
5306        assert_eq!(infer_num_inlets("v8.codebox", &[], None), 1);
5307        assert_eq!(infer_num_inlets("codebox", &[], None), 1);
5308        assert_eq!(infer_num_outlets("v8.codebox", &[], None), 1);
5309        assert_eq!(infer_num_outlets("codebox", &[], None), 1);
5310    }
5311
5312    #[test]
5313    fn test_infer_codebox_ports_basic() {
5314        // Simple: out1 = in1 * in2
5315        assert_eq!(infer_codebox_ports("out1 = in1 * in2;"), (2, 1));
5316    }
5317
5318    #[test]
5319    fn test_infer_codebox_ports_multiple_outputs() {
5320        let code = "out1 = in1 * in2;\nout2 = in1 + in2;\nout3 = in1 - in2;";
5321        assert_eq!(infer_codebox_ports(code), (2, 3));
5322    }
5323
5324    #[test]
5325    fn test_infer_codebox_ports_history() {
5326        // Real gen~ code with History, multiple ins/outs
5327        let code = "History hold(0), gate(0);\nout1 = in1 * in2 * in3;\nout2 = in4;";
5328        assert_eq!(infer_codebox_ports(code), (4, 2));
5329    }
5330
5331    #[test]
5332    fn test_infer_codebox_ports_no_refs() {
5333        // No in/out references → defaults to (1, 1)
5334        assert_eq!(infer_codebox_ports("x = 42;"), (1, 1));
5335    }
5336
5337    #[test]
5338    fn test_infer_codebox_ports_word_boundary() {
5339        // "into" and "output" should NOT match
5340        let code = "into = 5;\noutput = into + 1;\nout1 = in1;";
5341        assert_eq!(infer_codebox_ports(code), (1, 1));
5342    }
5343
5344    // ================================================
5345    // ObjectDb integration tests
5346    // ================================================
5347
5348    /// Registered objects return inlet/outlet counts from objdb
5349    #[test]
5350    fn test_infer_with_objdb() {
5351        use flutmax_objdb::{
5352            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5353        };
5354
5355        let mut db = ObjectDb::new();
5356        db.insert(ObjectDef {
5357            name: "myobj~".to_string(),
5358            module: Module::Msp,
5359            category: "test".to_string(),
5360            digest: "test object".to_string(),
5361            inlets: InletSpec::Fixed(vec![
5362                PortDef {
5363                    id: 0,
5364                    port_type: ObjPortType::Signal,
5365                    is_hot: true,
5366                    description: "in 0".to_string(),
5367                },
5368                PortDef {
5369                    id: 1,
5370                    port_type: ObjPortType::Signal,
5371                    is_hot: false,
5372                    description: "in 1".to_string(),
5373                },
5374                PortDef {
5375                    id: 2,
5376                    port_type: ObjPortType::Float,
5377                    is_hot: false,
5378                    description: "in 2".to_string(),
5379                },
5380            ]),
5381            outlets: OutletSpec::Fixed(vec![
5382                PortDef {
5383                    id: 0,
5384                    port_type: ObjPortType::Signal,
5385                    is_hot: false,
5386                    description: "out 0".to_string(),
5387                },
5388                PortDef {
5389                    id: 1,
5390                    port_type: ObjPortType::Signal,
5391                    is_hot: false,
5392                    description: "out 1".to_string(),
5393                },
5394            ]),
5395            args: vec![],
5396        });
5397
5398        // 3 inlets, 2 outlets returned from objdb
5399        assert_eq!(infer_num_inlets("myobj~", &[], Some(&db)), 3);
5400        assert_eq!(infer_num_outlets("myobj~", &[], Some(&db)), 2);
5401    }
5402
5403    /// Unregistered objects fall back to hardcoded table
5404    #[test]
5405    fn test_infer_objdb_fallback() {
5406        use flutmax_objdb::ObjectDb;
5407
5408        let db = ObjectDb::new(); // empty db
5409
5410        // "cycle~" is not in objdb -> 2 inlets, 1 outlet from hardcoded table
5411        assert_eq!(infer_num_inlets("cycle~", &[], Some(&db)), 2);
5412        assert_eq!(infer_num_outlets("cycle~", &[], Some(&db)), 1);
5413
5414        // "counter" also uses hardcoded fallback
5415        assert_eq!(infer_num_inlets("counter", &[], Some(&db)), 3);
5416        assert_eq!(infer_num_outlets("counter", &[], Some(&db)), 4);
5417    }
5418
5419    /// objdb Variable inlet/outlet works correctly with default arguments
5420    #[test]
5421    fn test_infer_objdb_variable_ports() {
5422        use flutmax_objdb::{
5423            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5424        };
5425
5426        let mut db = ObjectDb::new();
5427        db.insert(ObjectDef {
5428            name: "varobj".to_string(),
5429            module: Module::Max,
5430            category: "test".to_string(),
5431            digest: "variable port object".to_string(),
5432            inlets: InletSpec::Variable {
5433                defaults: vec![
5434                    PortDef {
5435                        id: 0,
5436                        port_type: ObjPortType::Any,
5437                        is_hot: true,
5438                        description: "in 0".to_string(),
5439                    },
5440                    PortDef {
5441                        id: 1,
5442                        port_type: ObjPortType::Any,
5443                        is_hot: false,
5444                        description: "in 1".to_string(),
5445                    },
5446                ],
5447                min_inlets: 1,
5448            },
5449            outlets: OutletSpec::Variable {
5450                defaults: vec![
5451                    PortDef {
5452                        id: 0,
5453                        port_type: ObjPortType::Any,
5454                        is_hot: false,
5455                        description: "out 0".to_string(),
5456                    },
5457                    PortDef {
5458                        id: 1,
5459                        port_type: ObjPortType::Any,
5460                        is_hot: false,
5461                        description: "out 1".to_string(),
5462                    },
5463                    PortDef {
5464                        id: 2,
5465                        port_type: ObjPortType::Any,
5466                        is_hot: false,
5467                        description: "out 2".to_string(),
5468                    },
5469                ],
5470                min_outlets: 1,
5471            },
5472            args: vec![],
5473        });
5474
5475        // No arguments -> returns defaults.len()
5476        assert_eq!(infer_num_inlets("varobj", &[], Some(&db)), 2);
5477        assert_eq!(infer_num_outlets("varobj", &[], Some(&db)), 3);
5478
5479        // With arguments -> returns args.len()
5480        assert_eq!(
5481            infer_num_inlets(
5482                "varobj",
5483                &["a".to_string(), "b".to_string(), "c".to_string()],
5484                Some(&db)
5485            ),
5486            3
5487        );
5488        assert_eq!(
5489            infer_num_outlets("varobj", &["x".to_string(), "y".to_string()], Some(&db)),
5490            2
5491        );
5492    }
5493
5494    // ── E52: OutDecl with inline value ──────────────────────
5495
5496    #[test]
5497    fn test_out_decl_inline_value_produces_edge() {
5498        // out audio: signal = osc; should produce the same graph as
5499        // out audio: signal; + out[0] = osc;
5500        let inline_program = Program {
5501            in_decls: vec![],
5502            out_decls: vec![OutDecl {
5503                index: 0,
5504                name: "audio".to_string(),
5505                port_type: PortType::Signal,
5506                value: Some(Expr::Ref("osc".to_string())),
5507            }],
5508            wires: vec![Wire {
5509                name: "osc".to_string(),
5510                value: Expr::Call {
5511                    object: "cycle~".to_string(),
5512                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
5513                },
5514                span: None,
5515                attrs: vec![],
5516            }],
5517            destructuring_wires: vec![],
5518            msg_decls: vec![],
5519            out_assignments: vec![],
5520            direct_connections: vec![],
5521            feedback_decls: vec![],
5522            feedback_assignments: vec![],
5523            state_decls: vec![],
5524            state_assignments: vec![],
5525        };
5526
5527        let separate_program = Program {
5528            in_decls: vec![],
5529            out_decls: vec![OutDecl {
5530                index: 0,
5531                name: "audio".to_string(),
5532                port_type: PortType::Signal,
5533                value: None,
5534            }],
5535            wires: vec![Wire {
5536                name: "osc".to_string(),
5537                value: Expr::Call {
5538                    object: "cycle~".to_string(),
5539                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
5540                },
5541                span: None,
5542                attrs: vec![],
5543            }],
5544            destructuring_wires: vec![],
5545            msg_decls: vec![],
5546            out_assignments: vec![OutAssignment {
5547                index: 0,
5548                value: Expr::Ref("osc".to_string()),
5549                span: None,
5550            }],
5551            direct_connections: vec![],
5552            feedback_decls: vec![],
5553            feedback_assignments: vec![],
5554            state_decls: vec![],
5555            state_assignments: vec![],
5556        };
5557
5558        let inline_graph = build_graph(&inline_program).expect("inline build failed");
5559        let separate_graph = build_graph(&separate_program).expect("separate build failed");
5560
5561        // Both should have same number of nodes and edges
5562        assert_eq!(
5563            inline_graph.nodes.len(),
5564            separate_graph.nodes.len(),
5565            "node count mismatch: inline={} vs separate={}",
5566            inline_graph.nodes.len(),
5567            separate_graph.nodes.len()
5568        );
5569        assert_eq!(
5570            inline_graph.edges.len(),
5571            separate_graph.edges.len(),
5572            "edge count mismatch: inline={} vs separate={}",
5573            inline_graph.edges.len(),
5574            separate_graph.edges.len()
5575        );
5576    }
5577
5578    // ── Named argument resolution tests ─────────────────────────
5579
5580    #[test]
5581    fn test_resolve_inlet_name_found() {
5582        use flutmax_objdb::{
5583            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5584        };
5585
5586        let mut db = ObjectDb::new();
5587        db.insert(ObjectDef {
5588            name: "cycle~".to_string(),
5589            module: Module::Msp,
5590            category: String::new(),
5591            digest: String::new(),
5592            inlets: InletSpec::Fixed(vec![
5593                PortDef {
5594                    id: 0,
5595                    port_type: ObjPortType::SignalFloat,
5596                    is_hot: true,
5597                    description: "Frequency".to_string(),
5598                },
5599                PortDef {
5600                    id: 1,
5601                    port_type: ObjPortType::SignalFloat,
5602                    is_hot: false,
5603                    description: "Phase offset".to_string(),
5604                },
5605            ]),
5606            outlets: OutletSpec::Fixed(vec![]),
5607            args: vec![],
5608        });
5609
5610        assert_eq!(
5611            resolve_inlet_name("cycle~", "frequency", Some(&db)),
5612            Some(0)
5613        );
5614        assert_eq!(
5615            resolve_inlet_name("cycle~", "phase_offset", Some(&db)),
5616            Some(1)
5617        );
5618    }
5619
5620    #[test]
5621    fn test_resolve_inlet_name_not_found() {
5622        use flutmax_objdb::{
5623            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5624        };
5625
5626        let mut db = ObjectDb::new();
5627        db.insert(ObjectDef {
5628            name: "cycle~".to_string(),
5629            module: Module::Msp,
5630            category: String::new(),
5631            digest: String::new(),
5632            inlets: InletSpec::Fixed(vec![PortDef {
5633                id: 0,
5634                port_type: ObjPortType::SignalFloat,
5635                is_hot: true,
5636                description: "Frequency".to_string(),
5637            }]),
5638            outlets: OutletSpec::Fixed(vec![]),
5639            args: vec![],
5640        });
5641
5642        assert_eq!(resolve_inlet_name("cycle~", "nonexistent", Some(&db)), None);
5643    }
5644
5645    #[test]
5646    fn test_resolve_inlet_name_no_objdb() {
5647        assert_eq!(resolve_inlet_name("cycle~", "frequency", None), None);
5648    }
5649
5650    #[test]
5651    fn test_resolve_abstraction_inlet_name() {
5652        use flutmax_ast::PortType;
5653        use flutmax_sema::registry::{AbstractionInterface, AbstractionRegistry, PortInfo};
5654
5655        let mut reg = AbstractionRegistry::new();
5656        reg.register_interface(AbstractionInterface {
5657            name: "simpleFM".to_string(),
5658            in_ports: vec![
5659                PortInfo {
5660                    index: 0,
5661                    name: "carrier_freq".to_string(),
5662                    port_type: PortType::Float,
5663                },
5664                PortInfo {
5665                    index: 1,
5666                    name: "harmonicity".to_string(),
5667                    port_type: PortType::Float,
5668                },
5669                PortInfo {
5670                    index: 2,
5671                    name: "mod_index".to_string(),
5672                    port_type: PortType::Float,
5673                },
5674            ],
5675            out_ports: vec![PortInfo {
5676                index: 0,
5677                name: "output".to_string(),
5678                port_type: PortType::Signal,
5679            }],
5680        });
5681
5682        assert_eq!(
5683            resolve_abstraction_inlet_name("simpleFM", "carrier_freq", Some(&reg)),
5684            Some(0)
5685        );
5686        assert_eq!(
5687            resolve_abstraction_inlet_name("simpleFM", "harmonicity", Some(&reg)),
5688            Some(1)
5689        );
5690        assert_eq!(
5691            resolve_abstraction_inlet_name("simpleFM", "mod_index", Some(&reg)),
5692            Some(2)
5693        );
5694        assert_eq!(
5695            resolve_abstraction_inlet_name("simpleFM", "nonexistent", Some(&reg)),
5696            None
5697        );
5698        assert_eq!(
5699            resolve_abstraction_inlet_name("unknown", "carrier_freq", Some(&reg)),
5700            None
5701        );
5702        assert_eq!(
5703            resolve_abstraction_inlet_name("simpleFM", "carrier_freq", None),
5704            None
5705        );
5706    }
5707
5708    #[test]
5709    fn test_named_arg_codegen() {
5710        // Named args should resolve to correct inlet indices
5711        use flutmax_objdb::{
5712            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5713        };
5714
5715        let mut db = ObjectDb::new();
5716        db.insert(ObjectDef {
5717            name: "biquad~".to_string(),
5718            module: Module::Msp,
5719            category: String::new(),
5720            digest: String::new(),
5721            inlets: InletSpec::Fixed(vec![
5722                PortDef {
5723                    id: 0,
5724                    port_type: ObjPortType::Signal,
5725                    is_hot: true,
5726                    description: "Input".to_string(),
5727                },
5728                PortDef {
5729                    id: 1,
5730                    port_type: ObjPortType::SignalFloat,
5731                    is_hot: false,
5732                    description: "Frequency".to_string(),
5733                },
5734                PortDef {
5735                    id: 2,
5736                    port_type: ObjPortType::SignalFloat,
5737                    is_hot: false,
5738                    description: "Q factor".to_string(),
5739                },
5740            ]),
5741            outlets: OutletSpec::Fixed(vec![PortDef {
5742                id: 0,
5743                port_type: ObjPortType::Signal,
5744                is_hot: false,
5745                description: "Output".to_string(),
5746            }]),
5747            args: vec![],
5748        });
5749
5750        // Build a program with named args
5751        let program = Program {
5752            in_decls: vec![
5753                InDecl {
5754                    index: 0,
5755                    name: "sig".to_string(),
5756                    port_type: PortType::Signal,
5757                },
5758                InDecl {
5759                    index: 1,
5760                    name: "freq".to_string(),
5761                    port_type: PortType::Float,
5762                },
5763            ],
5764            out_decls: vec![OutDecl {
5765                index: 0,
5766                name: "out".to_string(),
5767                port_type: PortType::Signal,
5768                value: None,
5769            }],
5770            wires: vec![Wire {
5771                name: "filtered".to_string(),
5772                value: Expr::Call {
5773                    object: "biquad~".to_string(),
5774                    args: vec![
5775                        // Use named args: "frequency" maps to inlet 1
5776                        CallArg::named("frequency", Expr::Ref("freq".to_string())),
5777                        // "input" maps to inlet 0
5778                        CallArg::named("input", Expr::Ref("sig".to_string())),
5779                    ],
5780                },
5781                span: None,
5782                attrs: vec![],
5783            }],
5784            out_assignments: vec![OutAssignment {
5785                index: 0,
5786                value: Expr::Ref("filtered".to_string()),
5787                span: None,
5788            }],
5789            destructuring_wires: vec![],
5790            msg_decls: vec![],
5791            direct_connections: vec![],
5792            feedback_decls: vec![],
5793            feedback_assignments: vec![],
5794            state_decls: vec![],
5795            state_assignments: vec![],
5796        };
5797
5798        let graph =
5799            build_graph_with_objdb(&program, None, None, Some(&db)).expect("should build graph");
5800
5801        // Verify that the named args resolved to the correct inlet indices
5802        // "frequency" → inlet 1, "input" → inlet 0
5803        // Find the biquad~ node ID
5804        let biquad_node = graph
5805            .nodes
5806            .iter()
5807            .find(|n| n.object_name == "biquad~")
5808            .expect("should have biquad~ node");
5809        let biquad_id = &biquad_node.id;
5810
5811        let biquad_edges: Vec<_> = graph
5812            .edges
5813            .iter()
5814            .filter(|e| &e.dest_id == biquad_id)
5815            .collect();
5816
5817        // Should have 2 edges going into biquad~
5818        assert_eq!(
5819            biquad_edges.len(),
5820            2,
5821            "expected 2 edges to biquad~, got {}: {:?}",
5822            biquad_edges.len(),
5823            biquad_edges
5824        );
5825
5826        // Check inlet assignments: freq→inlet 1, sig→inlet 0
5827        let freq_edge = biquad_edges.iter().find(|e| e.dest_inlet == 1);
5828        let sig_edge = biquad_edges.iter().find(|e| e.dest_inlet == 0);
5829        assert!(
5830            freq_edge.is_some(),
5831            "should have edge to inlet 1 (frequency)"
5832        );
5833        assert!(sig_edge.is_some(), "should have edge to inlet 0 (input)");
5834    }
5835}