Skip to main content

template_model/
resolver.rs

1use crate::coercion::type_compatible;
2use crate::consts::*;
3use crate::ir::*;
4use crate::json_value::JsonValue;
5use crate::message::render_str_list;
6use crate::pattern::{default_matches_pattern, is_service_valid};
7use crate::regions::*;
8use base64::Engine as _;
9use log::{debug, warn};
10use serde::{Deserialize, Serialize};
11use std::collections::{HashMap, HashSet};
12
13/// A property value after CloudFormation intrinsics (Ref, Fn::GetAtt, Fn::Sub, Fn::If, ...)
14/// have been resolved as far as possible. Depending on how much can be known before
15/// deployment, a value is fully concrete, a reference to another resource, one of several
16/// possible values, conditional on a template condition, or opaque until deploy time.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
19#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
20pub enum ResolvedValue {
21    /// A fully known literal value (string, number, boolean, list, or object).
22    Concrete { value: JsonValue },
23    /// A list whose elements are not all concrete; each element is itself a resolved value.
24    #[cfg_attr(feature = "uniffi-bindings", uniffi(name = "ListValue"))]
25    List { items: Vec<ResolvedValue> },
26    /// A map whose entry values are not all concrete; each entry value is itself a resolved value.
27    Map { entries: Vec<MapEntry> },
28    /// One of several possible values, such as the AllowedValues of a parameter; each candidate is a resolved value.
29    #[cfg_attr(feature = "uniffi-bindings", uniffi(name = "EnumValue"))]
30    Enum { variants: Vec<ResolvedValue> },
31    /// A value that depends on a template condition: `if_true` when the named condition holds, `if_false` otherwise.
32    Conditional { condition: String, if_true: Box<ResolvedValue>, if_false: Box<ResolvedValue> },
33    /// A reference to another resource (via Ref or Fn::GetAtt) rather than a concrete value.
34    Reference { target: String, kind: RefKind },
35    /// A value that cannot be known until deployment; `reason` is a human-readable explanation of why it is unresolved.
36    Dynamic { reason: String },
37    /// A value unknown until deployment but whose CloudFormation type is known; `param_type` is that type and `reason` explains why the value is unresolved.
38    TypedDynamic { reason: String, param_type: String },
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
43#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
44pub struct MapEntry {
45    pub key: String,
46    pub value: ResolvedValue,
47}
48
49/// How one template item refers to another: a Ref, an Fn::GetAtt attribute lookup,
50/// an Fn::Sub variable, or an explicit DependsOn.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
53#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
54#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
55pub enum RefKind {
56    Ref,
57    /// An Fn::GetAtt reference; `attr` is the referenced attribute name.
58    GetAtt {
59        attr: String,
60    },
61    /// An Fn::Sub reference; `var` is the substituted variable name.
62    Sub {
63        var: String,
64    },
65    DependsOn,
66}
67
68#[derive(Debug, Clone)]
69pub struct ResolverEdge {
70    pub source_resource: String,
71    pub source_path: String,
72    pub target: String,
73    pub kind: RefKind,
74    pub span: SourceSpan,
75    pub condition_context: Option<String>,
76}
77
78/// A template Parameter's declaration: its type, constraints (allowed values/pattern,
79/// length and value bounds), default, and description.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
82#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
83#[serde(rename_all = "camelCase")]
84pub struct ParameterInfo {
85    /// The parameter's declared CloudFormation type (for example String, Number, or an AWS-specific type); defaults to String when the template omits Type.
86    pub param_type: String,
87    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
88    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
89    pub default: Option<String>,
90    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
91    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
92    pub allowed_values: Option<Vec<String>>,
93    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
94    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95    pub allowed_pattern: Option<String>,
96    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
97    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
98    pub min_length: Option<u64>,
99    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
100    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
101    pub max_length: Option<u64>,
102    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
103    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104    pub min_value: Option<i64>,
105    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
106    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
107    pub max_value: Option<i64>,
108    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
109    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
110    pub description: Option<String>,
111    /// Whether the parameter is declared with NoEcho, meaning its value is masked in CloudFormation output.
112    pub no_echo: bool,
113    /// Whether the AllowedPattern is a valid, supported regular expression; absent when no AllowedPattern is declared.
114    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
115    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
116    pub allowed_pattern_valid: Option<bool>,
117    /// Whether the Default value satisfies the AllowedPattern; absent unless both a Default and an AllowedPattern are declared.
118    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
119    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
120    pub default_matches_allowed_pattern: Option<bool>,
121}
122
123pub type MappingData = HashMap<String, HashMap<String, HashMap<String, serde_json::Value>>>;
124
125pub(crate) struct Resolver<'a> {
126    arena: &'a Arena,
127    parameters: &'a HashMap<String, ParameterInfo>,
128    mappings: &'a MappingData,
129    resource_ids: HashSet<String>,
130    pub(crate) edges: Vec<ResolverEdge>,
131    pub(crate) diagnostics: Vec<ParseDefect>,
132    pub(crate) find_in_map_refs: HashMap<String, Vec<String>>,
133    pub(crate) simple_subs: HashMap<String, Vec<(String, String)>>,
134    pub(crate) redundant_subs: HashMap<String, Vec<String>>,
135    pub(crate) empty_joins: HashMap<String, Vec<String>>,
136    pub(crate) hardcoded_partition_arns: HashMap<String, Vec<String>>,
137    pub(crate) foreach_expansions: HashMap<String, Vec<(String, String, String)>>,
138    pub(crate) unsubstituted_variables: HashMap<String, Vec<(String, String)>>,
139    pub(crate) unused_sub_keys: HashMap<String, Vec<(String, String)>>,
140    pub(crate) raw_pseudo_params: HashMap<String, Vec<(String, String)>>,
141    pub(crate) secretsmanager_ref_paths: HashMap<String, Vec<String>>,
142    pub(crate) invalid_refs: HashMap<String, Vec<(String, String)>>,
143    pub(crate) extra_condition_refs: HashMap<String, Vec<String>>,
144    pub(crate) inline_conditions: Vec<(String, crate::conditions::ConditionExpr)>,
145    resolution_source_map: HashMap<(String, String), String>, // (resource_id, property_path) → source description
146    /// (resource_id, property_path) → authored expression retained for opaque
147    /// value identity or for inspecting an intrinsic that resolved concretely.
148    value_node_map: HashMap<(String, String), NodeRef>,
149    parameter_overrides: &'a HashMap<String, String>,
150    pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides,
151
152    pub(crate) current_resource: Option<String>,
153    condition_stack: Vec<(String, bool)>, // (condition_name, is_true_branch)
154    current_path: String,
155    depth: u32,
156    /// Whether the resolver exceeded its recursion depth limit during resolution.
157    pub(crate) depth_exceeded: bool,
158    /// Whether enum variant expansion exceeded its limit during resolution.
159    pub(crate) enum_expansion_exceeded: bool,
160    local_bindings: HashMap<String, ResolvedValue>,
161    /// Per-resource `DefinitionSubstitutions` keys: a `${var}` placeholder in a
162    /// Step Functions definition is legitimate exactly when `var` is one of the
163    /// resource's substitution keys.
164    pub(crate) def_subs_resources: HashMap<String, HashSet<String>>,
165}
166
167impl<'a> Resolver<'a> {
168    pub fn new(
169        arena: &'a Arena,
170        parameters: &'a HashMap<String, ParameterInfo>,
171        mappings: &'a MappingData,
172        resource_ids: HashSet<String>,
173        parameter_overrides: &'a HashMap<String, String>,
174        pseudo_parameter_overrides: &'a crate::model::PseudoParameterOverrides,
175    ) -> Self {
176        Self {
177            arena,
178            parameters,
179            mappings,
180            resource_ids,
181            edges: Vec::new(),
182            diagnostics: Vec::new(),
183            find_in_map_refs: HashMap::new(),
184            simple_subs: HashMap::new(),
185            redundant_subs: HashMap::new(),
186            empty_joins: HashMap::new(),
187            hardcoded_partition_arns: HashMap::new(),
188            foreach_expansions: HashMap::new(),
189            unsubstituted_variables: HashMap::new(),
190            unused_sub_keys: HashMap::new(),
191            raw_pseudo_params: HashMap::new(),
192            secretsmanager_ref_paths: HashMap::new(),
193            invalid_refs: HashMap::new(),
194            extra_condition_refs: HashMap::new(),
195            inline_conditions: Vec::new(),
196            resolution_source_map: HashMap::new(),
197            value_node_map: HashMap::new(),
198            parameter_overrides,
199            pseudo_parameter_overrides,
200
201            current_resource: None,
202            condition_stack: Vec::new(),
203            current_path: String::new(),
204            depth: 0,
205            depth_exceeded: false,
206            enum_expansion_exceeded: false,
207            local_bindings: HashMap::new(),
208            def_subs_resources: HashMap::new(),
209        }
210    }
211
212    pub fn resolution_sources(&self) -> HashMap<(String, String), String> {
213        self.resolution_source_map.clone()
214    }
215
216    pub fn value_nodes(&self) -> HashMap<(String, String), NodeRef> {
217        self.value_node_map.clone()
218    }
219
220    pub fn resolve_node(&mut self, node_ref: NodeRef) -> ResolvedValue {
221        if node_ref == NULL_REF {
222            warn!("Attempted to resolve NULL_REF node at path '{}'", self.current_path);
223            return ResolvedValue::Dynamic { reason: "null reference".into() };
224        }
225        if self.depth >= MAX_RESOLVE_DEPTH {
226            warn!("Recursion depth limit ({}) exceeded at path '{}'", MAX_RESOLVE_DEPTH, self.current_path);
227            self.depth_exceeded = true;
228            return ResolvedValue::Dynamic { reason: "recursion limit exceeded".into() };
229        }
230        self.depth += 1;
231        let result = self.resolve_node_inner(node_ref);
232        self.depth -= 1;
233        let result = opaque_if_dynamic_reference(result);
234        // Opaque values retain their authored expression because it is the only
235        // way to establish value identity. Intrinsics also retain their node when
236        // they resolve concretely so validators can inspect authored structure
237        // rather than mistaking a parameter default for the deploy-time value.
238        let authored_intrinsic = matches!(self.arena.node(node_ref), Node::Intrinsic(_));
239        if (!matches!(result, ResolvedValue::Concrete { value: _ }) || authored_intrinsic)
240            && let Some(ref resource_id) = self.current_resource
241        {
242            self.value_node_map.insert((resource_id.clone(), self.current_path.clone()), node_ref);
243        }
244        result
245    }
246
247    fn resolve_node_inner(&mut self, node_ref: NodeRef) -> ResolvedValue {
248        let spanned = self.arena.get(node_ref);
249        let span = spanned.span;
250        match &spanned.node {
251            Node::Null => ResolvedValue::Concrete { value: serde_json::Value::Null.into() },
252            Node::Bool(b) => ResolvedValue::Concrete { value: serde_json::Value::Bool(*b).into() },
253            Node::Int(i) => ResolvedValue::Concrete { value: serde_json::json!(*i).into() },
254            Node::Float(f) => ResolvedValue::Concrete { value: serde_json::json!(*f).into() },
255            Node::String(s) => {
256                // Embedded dynamic references (`{{resolve:...}}`, even mid-string or
257                // produced by Sub/Join/Select) are collapsed to a deploy-time-opaque
258                // value centrally in `resolve_node`, so no per-node guard is needed here.
259                self.detect_unsubstituted_variables(s);
260                self.detect_raw_pseudo_param(s);
261                self.detect_secretsmanager_ref(s);
262                ResolvedValue::Concrete { value: serde_json::Value::String(s.clone()).into() }
263            }
264            Node::List(items) => {
265                let items = items.clone(); // clone Vec<NodeRef> (cheap: Vec of u32)
266                let saved = self.current_path.clone();
267                let resolved: Vec<ResolvedValue> = items
268                    .iter()
269                    .enumerate()
270                    .map(|(i, r)| {
271                        self.current_path = format!("{}.{}", saved, i);
272                        self.resolve_node(*r)
273                    })
274                    .collect();
275                self.current_path = saved;
276                if resolved.iter().all(|v| matches!(v, ResolvedValue::Concrete { value: _ })) {
277                    let vals: Vec<serde_json::Value> = resolved
278                        .into_iter()
279                        .map(|v| match v {
280                            ResolvedValue::Concrete { value: c } => c.into_inner(),
281                            _ => unreachable!(),
282                        })
283                        .collect();
284                    ResolvedValue::Concrete { value: serde_json::Value::Array(vals).into() }
285                } else {
286                    ResolvedValue::List { items: resolved }
287                }
288            }
289            Node::Map(entries) => {
290                let refs: Vec<(String, NodeRef)> = entries.iter().map(|(k, v)| (k.clone(), *v)).collect();
291                let saved = self.current_path.clone();
292                let resolved: Vec<MapEntry> = refs
293                    .into_iter()
294                    .map(|(k, v)| {
295                        self.current_path = format!("{}.{}", saved, k);
296                        let val = self.resolve_node(v);
297                        MapEntry { key: k, value: val }
298                    })
299                    .collect();
300                self.current_path = saved;
301                if resolved.iter().all(|entry| matches!(entry.value, ResolvedValue::Concrete { value: _ })) {
302                    let mut map = serde_json::Map::new();
303                    for entry in resolved {
304                        if let ResolvedValue::Concrete { value: c } = entry.value {
305                            map.insert(entry.key, c.into_inner());
306                        }
307                    }
308                    ResolvedValue::Concrete { value: serde_json::Value::Object(map).into() }
309                } else {
310                    ResolvedValue::Map { entries: resolved }
311                }
312            }
313            Node::Intrinsic(intrinsic) => {
314                let intrinsic = intrinsic.clone(); // clone only the intrinsic
315                self.resolve_intrinsic(&intrinsic, &span)
316            }
317        }
318    }
319
320    fn resolve_intrinsic(&mut self, intrinsic: &IntrinsicFn, span: &SourceSpan) -> ResolvedValue {
321        debug!("Resolve intrinsic {} at {}", intrinsic_name(intrinsic), self.current_path);
322        // Record that the value at this path is produced by a string-building
323        // intrinsic, even when it resolves to a concrete string. Rules that must
324        // distinguish an intrinsic-built value from a written literal (e.g. the
325        // `package`-command and pattern checks, which only apply to written
326        // string literals) rely on `is_from_intrinsic` to see this. Ref/GetAtt
327        // already record reference edges, and Fn::If is tracked per branch, so
328        // only the value-producing structural intrinsics need an explicit marker.
329        if let Some(ref rid) = self.current_resource
330            && matches!(
331                intrinsic,
332                IntrinsicFn::Join(_, _)
333                    | IntrinsicFn::Sub(_, _)
334                    | IntrinsicFn::Select(_, _)
335                    | IntrinsicFn::Split(_, _)
336                    | IntrinsicFn::FindInMap(_, _, _, _)
337                    | IntrinsicFn::Base64(_)
338                    | IntrinsicFn::Cidr(_, _, _)
339            )
340        {
341            self.resolution_source_map
342                .entry((rid.clone(), self.current_path.clone()))
343                .or_insert_with(|| format!("Intrinsic/{}", intrinsic_name(intrinsic)));
344        }
345        match intrinsic {
346            IntrinsicFn::Ref(target) => self.resolve_ref(target, span),
347            IntrinsicFn::GetAtt(resource, attr) => {
348                if self.is_output_context() {
349                    let path = format!("{}.Fn::GetAtt", self.current_path);
350                    self.record_edge_at_path(resource, RefKind::GetAtt { attr: attr.clone() }, span, path);
351                } else {
352                    self.record_edge(resource, RefKind::GetAtt { attr: attr.clone() }, span);
353                }
354                ResolvedValue::Reference { target: resource.clone(), kind: RefKind::GetAtt { attr: attr.clone() } }
355            }
356            IntrinsicFn::If(cond, t_ref, f_ref) => {
357                let saved = self.current_path.clone();
358                self.current_path = format!("{}.Fn::If", saved);
359                self.condition_stack.push((cond.clone(), true));
360                self.current_path = format!("{}.Fn::If.1", saved);
361                let true_branch = self.resolve_node(*t_ref);
362                self.condition_stack.pop();
363                self.condition_stack.push((cond.clone(), false));
364                self.current_path = format!("{}.Fn::If.2", saved);
365                let false_branch = self.resolve_node(*f_ref);
366                self.condition_stack.pop();
367                self.current_path = saved;
368                ResolvedValue::Conditional {
369                    condition: cond.clone(),
370                    if_true: Box::new(true_branch),
371                    if_false: Box::new(false_branch),
372                }
373            }
374            IntrinsicFn::IfExpr(cond_ref, t_ref, f_ref) => {
375                let saved = self.current_path.clone();
376                self.current_path = format!("{}.Fn::If.1", saved);
377                let true_branch = self.resolve_node(*t_ref);
378                self.current_path = format!("{}.Fn::If.2", saved);
379                let false_branch = self.resolve_node(*f_ref);
380                self.current_path = saved;
381                // Resolve the condition expression node to capture edges/side effects
382                let _cond_resolved = self.resolve_node(*cond_ref);
383
384                // Try to parse and eagerly evaluate the inline condition
385                let parsed_expr = crate::conditions::parse_condition_expr(self.arena, *cond_ref, self.parameters);
386                // Literal-only Equals can be evaluated immediately
387                if let crate::conditions::ConditionExpr::Equals(
388                    crate::conditions::ValueExpr::Literal(a),
389                    crate::conditions::ValueExpr::Literal(b),
390                ) = &parsed_expr
391                {
392                    return if a == b { true_branch } else { false_branch };
393                }
394
395                let cond_label = format!("__inline_cond_{}", cond_ref);
396                self.inline_conditions.push((cond_label.clone(), parsed_expr));
397                ResolvedValue::Conditional {
398                    condition: cond_label,
399                    if_true: Box::new(true_branch),
400                    if_false: Box::new(false_branch),
401                }
402            }
403            IntrinsicFn::FindInMap(map_name_ref, k1_ref, k2_ref, default_ref) => {
404                self.resolve_findinmap(*map_name_ref, *k1_ref, *k2_ref, *default_ref)
405            }
406            IntrinsicFn::Sub(template, subs) => self.resolve_sub(template, subs, span),
407            IntrinsicFn::Join(delim_ref, values_ref) => {
408                let saved = self.current_path.clone();
409                let join_path = format!("{}.Fn::Join", saved);
410                self.current_path = format!("{}.0", join_path);
411                let delim = self.resolve_node(*delim_ref);
412                // Track Fn::Join with empty delimiter for join-without-delimiter detection
413                if let ResolvedValue::Concrete { value: d } = &delim
414                    && d.as_str() == Some("")
415                    && self.is_simple_join(*values_ref)
416                {
417                    let key = self.current_resource.clone().unwrap_or_else(|| OUTPUTS_PSEUDO_RESOURCE.into());
418                    self.empty_joins.entry(key).or_default().push(join_path.clone());
419                }
420                self.current_path = format!("{}.1", join_path);
421                let values = self.resolve_node(*values_ref);
422                self.current_path = saved;
423                match (&delim, &values) {
424                    (ResolvedValue::Concrete { value: d }, ResolvedValue::Concrete { value: v }) => {
425                        if let (Some(ds), Some(arr)) = (d.as_str(), v.as_array()) {
426                            let parts: Vec<String> =
427                                arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
428                            if parts.len() == arr.len() {
429                                return ResolvedValue::Concrete {
430                                    value: serde_json::Value::String(parts.join(ds)).into(),
431                                };
432                            }
433                        }
434                        self.collect_extra_condition_refs(&values);
435                        ResolvedValue::Dynamic { reason: "Join with non-string elements".into() }
436                    }
437                    (
438                        ResolvedValue::Concrete { value: d },
439                        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
440                    ) => {
441                        if let Some(ds) = d.as_str() {
442                            return ResolvedValue::Conditional {
443                                condition: cond.clone(),
444                                if_true: Box::new(join_resolved(ds, t)),
445                                if_false: Box::new(join_resolved(ds, f)),
446                            };
447                        }
448                        self.collect_extra_condition_refs(&values);
449                        ResolvedValue::Dynamic { reason: "Join with non-string delimiter".into() }
450                    }
451                    (ResolvedValue::Concrete { value: d }, ResolvedValue::List { items }) => {
452                        if let Some(ds) = d.as_str() {
453                            if items.iter().any(|v| {
454                                matches!(
455                                    v,
456                                    ResolvedValue::Enum { variants: _ }
457                                        | ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ }
458                                )
459                            }) {
460                                let (result, was_curtailed) = join_with_enum_list(ds, items);
461                                if was_curtailed {
462                                    self.enum_expansion_exceeded = true;
463                                }
464                                return result;
465                            }
466                            // Partial join: substitute concrete items, placeholder for others
467                            let parts: Vec<String> = items
468                                .iter()
469                                .map(|v| match v {
470                                    ResolvedValue::Concrete { value: cv } => cv.as_str().unwrap_or("").to_string(),
471                                    ResolvedValue::Reference { target, .. } => {
472                                        format!("{}{}}}", UNRESOLVED_REF_PLACEHOLDER_PREFIX, target)
473                                    }
474                                    _ => UNRESOLVED_DYNAMIC_PLACEHOLDER.to_string(),
475                                })
476                                .collect();
477                            self.collect_extra_condition_refs(&values);
478                            return ResolvedValue::Dynamic {
479                                reason: format!("{}{}", JOIN_PARTIAL_PREFIX, parts.join(ds)),
480                            };
481                        }
482                        self.collect_extra_condition_refs(&values);
483                        ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() }
484                    }
485                    (ResolvedValue::Concrete { value: d }, ResolvedValue::Enum { variants }) => {
486                        if let Some(ds) = d.as_str() {
487                            let results: Vec<ResolvedValue> = variants.iter().map(|v| join_resolved(ds, v)).collect();
488                            return ResolvedValue::Enum { variants: results };
489                        }
490                        self.collect_extra_condition_refs(&values);
491                        ResolvedValue::Dynamic { reason: "Join with non-string delimiter".into() }
492                    }
493                    _ => {
494                        self.collect_extra_condition_refs(&values);
495                        ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() }
496                    }
497                }
498            }
499            IntrinsicFn::Select(idx_ref, list_ref) => {
500                let idx = self.resolve_node(*idx_ref);
501                let list = self.resolve_node(*list_ref);
502                match (&idx, &list) {
503                    (ResolvedValue::Concrete { value: i }, ResolvedValue::Concrete { value: l }) => {
504                        if let Some(arr) = l.as_array()
505                            && let Some(idx) = i.as_u64()
506                        {
507                            if (idx as usize) < arr.len() {
508                                return ResolvedValue::Concrete { value: arr[idx as usize].clone().into() };
509                            }
510                            return ResolvedValue::Dynamic { reason: "Select index out of bounds".into() };
511                        }
512                        ResolvedValue::Dynamic { reason: "Select on non-list value".into() }
513                    }
514                    (
515                        ResolvedValue::Concrete { value: i },
516                        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
517                    ) => ResolvedValue::Conditional {
518                        condition: cond.clone(),
519                        if_true: Box::new(select_resolved(i, t)),
520                        if_false: Box::new(select_resolved(i, f)),
521                    },
522                    (ResolvedValue::Concrete { value: i }, ResolvedValue::Enum { variants }) => {
523                        let results: Vec<ResolvedValue> = variants.iter().map(|v| select_resolved(i, v)).collect();
524                        ResolvedValue::Enum { variants: results }
525                    }
526                    (ResolvedValue::Concrete { value: i }, ResolvedValue::List { items }) => {
527                        if let Some(idx) = i.as_u64() {
528                            if (idx as usize) < items.len() {
529                                return items[idx as usize].clone();
530                            }
531                            return ResolvedValue::Dynamic { reason: "Select index out of bounds".into() };
532                        }
533                        ResolvedValue::Dynamic { reason: "Select with non-integer index".into() }
534                    }
535                    _ => ResolvedValue::Dynamic { reason: "Select with unresolvable arguments".into() },
536                }
537            }
538            IntrinsicFn::Split(delim_ref, src_ref) => {
539                let delim = self.resolve_node(*delim_ref);
540                let src = self.resolve_node(*src_ref);
541                match (&delim, &src) {
542                    (ResolvedValue::Concrete { value: d }, ResolvedValue::Concrete { value: s }) => {
543                        if let (Some(ds), Some(ss)) = (d.as_str(), s.as_str()) {
544                            let parts: Vec<serde_json::Value> =
545                                ss.split(ds).map(|p| serde_json::Value::String(p.to_string())).collect();
546                            return ResolvedValue::Concrete { value: serde_json::Value::Array(parts).into() };
547                        }
548                        ResolvedValue::Dynamic { reason: "Split with non-string arguments".into() }
549                    }
550                    (ResolvedValue::Concrete { value: d }, ResolvedValue::Enum { variants }) => {
551                        if let Some(ds) = d.as_str() {
552                            let results: Vec<ResolvedValue> = variants.iter().map(|v| split_resolved(ds, v)).collect();
553                            return ResolvedValue::Enum { variants: results };
554                        }
555                        ResolvedValue::Dynamic { reason: "Split with non-string delimiter".into() }
556                    }
557                    (
558                        ResolvedValue::Concrete { value: d },
559                        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
560                    ) => {
561                        if let Some(ds) = d.as_str() {
562                            return ResolvedValue::Conditional {
563                                condition: cond.clone(),
564                                if_true: Box::new(split_resolved(ds, t)),
565                                if_false: Box::new(split_resolved(ds, f)),
566                            };
567                        }
568                        ResolvedValue::Dynamic { reason: "Split with non-string delimiter".into() }
569                    }
570                    _ => ResolvedValue::Dynamic { reason: "Split with unresolvable arguments".into() },
571                }
572            }
573            IntrinsicFn::Base64(val_ref) => {
574                let saved = self.current_path.clone();
575                self.current_path = format!("{}.Fn::Base64", saved);
576                let val = self.resolve_node(*val_ref);
577                self.current_path = saved;
578                match &val {
579                    ResolvedValue::Concrete { value: v } => {
580                        if let Some(s) = v.as_str() {
581                            let encoded = base64::engine::general_purpose::STANDARD.encode(s);
582                            return ResolvedValue::Concrete { value: serde_json::Value::String(encoded).into() };
583                        }
584                        ResolvedValue::Dynamic { reason: "Base64 with non-string argument".into() }
585                    }
586                    ResolvedValue::Enum { variants } => {
587                        let results: Vec<ResolvedValue> = variants.iter().map(base64_resolved).collect();
588                        ResolvedValue::Enum { variants: results }
589                    }
590                    ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
591                        ResolvedValue::Conditional {
592                            condition: cond.clone(),
593                            if_true: Box::new(base64_resolved(t)),
594                            if_false: Box::new(base64_resolved(f)),
595                        }
596                    }
597                    _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() },
598                }
599            }
600            IntrinsicFn::ImportValue(arg) => {
601                let reason = match self.resolve_node(*arg) {
602                    ResolvedValue::Concrete { value } => match value.as_str() {
603                        Some(export) => format!("cross-stack import: {export}"),
604                        None => "cross-stack import".into(),
605                    },
606                    _ => "cross-stack import".into(),
607                };
608                ResolvedValue::TypedDynamic { reason, param_type: PARAM_TYPE_STRING.into() }
609            }
610            IntrinsicFn::GetStackOutput(args) => {
611                let saved = self.current_path.clone();
612                let mut concrete_arguments: Vec<(&str, String)> = Vec::with_capacity(args.len());
613                for (key, arg) in args {
614                    self.current_path = format!("{}.{}", saved, key);
615                    if let ResolvedValue::Concrete { value } = self.resolve_node(*arg)
616                        && let Some(literal) = value.as_str()
617                    {
618                        concrete_arguments.push((key.as_str(), literal.to_string()));
619                    }
620                }
621                self.current_path = saved;
622
623                // Fixed key order, not template order, so two calls that list the same
624                // arguments in a different order stay equal. RoleArn is left out: it does
625                // not change which output is read, so two calls differing only there are
626                // still the same value. Quoting keeps a separator inside a value from
627                // reading as a field boundary.
628                let identity: Vec<String> = [KEY_STACK_NAME, KEY_REGION, KEY_OUTPUT_NAME]
629                    .into_iter()
630                    .filter_map(|key| {
631                        concrete_arguments
632                            .iter()
633                            .find(|(name, _)| *name == key)
634                            .map(|(_, literal)| format!("{key}={literal:?}"))
635                    })
636                    .collect();
637                let reason = if identity.is_empty() {
638                    "cross-stack output".to_string()
639                } else {
640                    format!("cross-stack output: {}", identity.join(", "))
641                };
642                ResolvedValue::Dynamic { reason }
643            }
644            IntrinsicFn::Transform(_, parameters) => {
645                let saved = self.current_path.clone();
646                for (key, argument) in parameters {
647                    self.current_path = format!("{}.Fn::Transform.Parameters.{}", saved, key);
648                    self.resolve_node(*argument);
649                }
650                self.current_path = saved;
651                ResolvedValue::Dynamic { reason: "macro output".into() }
652            }
653            IntrinsicFn::GetAZs(region_ref) => {
654                if let Some(ref rid) = self.current_resource {
655                    self.resolution_source_map
656                        .insert((rid.clone(), self.current_path.clone()), "Intrinsic/Fn::GetAZs".to_string());
657                }
658                let region_val = self.resolve_node(*region_ref);
659                resolve_getazs_value(&region_val, self.pseudo_parameter_overrides)
660            }
661            IntrinsicFn::Cidr(ip_ref, count_ref, bits_ref) => {
662                let ip_val = self.resolve_node(*ip_ref);
663                let count_val = self.resolve_node(*count_ref);
664                let bits_val = self.resolve_node(*bits_ref);
665                match (&ip_val, &count_val, &bits_val) {
666                    (
667                        ResolvedValue::Concrete { value: ip },
668                        ResolvedValue::Concrete { value: cnt },
669                        ResolvedValue::Concrete { value: bits },
670                    ) => {
671                        let ip_str = ip.as_str().unwrap_or("");
672                        let count = cnt.as_u64().or_else(|| cnt.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
673                        let cidr_bits =
674                            bits.as_u64().or_else(|| bits.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
675                        match calculate_cidr_blocks(ip_str, count, cidr_bits) {
676                            Some(blocks) => ResolvedValue::Concrete {
677                                value: serde_json::Value::Array(
678                                    blocks.into_iter().map(serde_json::Value::String).collect(),
679                                )
680                                .into(),
681                            },
682                            None => ResolvedValue::Dynamic { reason: "Cidr calculation failed".into() },
683                        }
684                    }
685                    _ => {
686                        // Propagate Enum ip_block through Cidr
687                        if let ResolvedValue::Enum { variants } = &ip_val {
688                            let results: Vec<ResolvedValue> =
689                                variants.iter().map(|v| resolve_cidr_value(v, &count_val, &bits_val)).collect();
690                            return ResolvedValue::Enum { variants: results };
691                        }
692                        // Propagate Conditional ip_block through Cidr
693                        if let ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } = &ip_val {
694                            return ResolvedValue::Conditional {
695                                condition: cond.clone(),
696                                if_true: Box::new(resolve_cidr_value(t, &count_val, &bits_val)),
697                                if_false: Box::new(resolve_cidr_value(f, &count_val, &bits_val)),
698                            };
699                        }
700                        ResolvedValue::Dynamic { reason: "Cidr runtime value".into() }
701                    }
702                }
703            }
704            IntrinsicFn::Equals(a_ref, b_ref) => {
705                let left = self.resolve_node(*a_ref);
706                let right = self.resolve_node(*b_ref);
707                self.collect_extra_condition_refs(&left);
708                self.collect_extra_condition_refs(&right);
709                match (&left, &right) {
710                    (ResolvedValue::Concrete { value: lv }, ResolvedValue::Concrete { value: rv }) => {
711                        ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
712                    }
713                    (ResolvedValue::Enum { variants }, ResolvedValue::Concrete { value: rv }) => {
714                        let results: Vec<ResolvedValue> = variants
715                            .iter()
716                            .map(|v| match v {
717                                ResolvedValue::Concrete { value: lv } => {
718                                    ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
719                                }
720                                _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
721                            })
722                            .collect();
723                        ResolvedValue::Enum { variants: results }
724                    }
725                    (ResolvedValue::Concrete { value: lv }, ResolvedValue::Enum { variants }) => {
726                        let results: Vec<ResolvedValue> = variants
727                            .iter()
728                            .map(|v| match v {
729                                ResolvedValue::Concrete { value: rv } => {
730                                    ResolvedValue::Concrete { value: serde_json::Value::Bool(lv == rv).into() }
731                                }
732                                _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
733                            })
734                            .collect();
735                        ResolvedValue::Enum { variants: results }
736                    }
737                    _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
738                }
739            }
740            IntrinsicFn::And(children) => {
741                let resolved: Vec<ResolvedValue> = children.iter().map(|c| self.resolve_node(*c)).collect();
742                for v in &resolved {
743                    self.collect_extra_condition_refs(v);
744                }
745                let all_concrete_bool = resolved
746                    .iter()
747                    .all(|v| matches!(v, ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(_)) }));
748                if all_concrete_bool {
749                    let all_true = resolved.iter().all(|v| match v {
750                        ResolvedValue::Concrete { value: bv } => bv.as_bool().unwrap_or(false),
751                        _ => false,
752                    });
753                    ResolvedValue::Concrete { value: serde_json::Value::Bool(all_true).into() }
754                } else {
755                    ResolvedValue::Dynamic { reason: "condition expression".into() }
756                }
757            }
758            IntrinsicFn::Or(children) => {
759                let resolved: Vec<ResolvedValue> = children.iter().map(|c| self.resolve_node(*c)).collect();
760                for v in &resolved {
761                    self.collect_extra_condition_refs(v);
762                }
763                let all_concrete_bool = resolved
764                    .iter()
765                    .all(|v| matches!(v, ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(_)) }));
766                if all_concrete_bool {
767                    let any_true = resolved.iter().any(|v| match v {
768                        ResolvedValue::Concrete { value: bv } => bv.as_bool().unwrap_or(false),
769                        _ => false,
770                    });
771                    ResolvedValue::Concrete { value: serde_json::Value::Bool(any_true).into() }
772                } else {
773                    ResolvedValue::Dynamic { reason: "condition expression".into() }
774                }
775            }
776            IntrinsicFn::Not(child) => {
777                let resolved = self.resolve_node(*child);
778                self.collect_extra_condition_refs(&resolved);
779                match &resolved {
780                    ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Bool(b)) } => {
781                        ResolvedValue::Concrete { value: serde_json::Value::Bool(!b).into() }
782                    }
783                    _ => ResolvedValue::Dynamic { reason: "condition expression".into() },
784                }
785            }
786            IntrinsicFn::RefAll(_) => ResolvedValue::Dynamic { reason: "rules-only function".into() },
787            IntrinsicFn::ValueOf(param_name, _attr) | IntrinsicFn::ValueOfAll(param_name, _attr) => {
788                // The first argument is a parameter (or parameter group) name -
789                // record a Ref edge so the parameter is counted as referenced.
790                self.record_edge(param_name, RefKind::Ref, span);
791                ResolvedValue::Dynamic { reason: "rules-only function".into() }
792            }
793            IntrinsicFn::Contains(list_ref, value_ref)
794            | IntrinsicFn::EachMemberEquals(list_ref, value_ref)
795            | IntrinsicFn::EachMemberIn(list_ref, value_ref) => {
796                // Walk children for ref-edge side effects. Each argument may
797                // contain `Ref`/`Fn::Sub`/etc. that must register a reference
798                // even though the function itself resolves to a dynamic value.
799                self.resolve_node(*list_ref);
800                self.resolve_node(*value_ref);
801                ResolvedValue::Dynamic { reason: "rules-only function".into() }
802            }
803            IntrinsicFn::ToJsonString(val_ref) => {
804                let val = self.resolve_node(*val_ref);
805                match &val {
806                    ResolvedValue::Concrete { value: v } => {
807                        ResolvedValue::Concrete { value: serde_json::Value::String(v.to_string()).into() }
808                    }
809                    ResolvedValue::Enum { variants } => {
810                        let results: Vec<ResolvedValue> = variants.iter().map(to_json_string_resolved).collect();
811                        ResolvedValue::Enum { variants: results }
812                    }
813                    ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
814                        ResolvedValue::Conditional {
815                            condition: cond.clone(),
816                            if_true: Box::new(to_json_string_resolved(t)),
817                            if_false: Box::new(to_json_string_resolved(f)),
818                        }
819                    }
820                    _ => ResolvedValue::Dynamic { reason: "ToJsonString with unresolvable argument".into() },
821                }
822            }
823            IntrinsicFn::Length(val_ref) => {
824                let val = self.resolve_node(*val_ref);
825                match &val {
826                    ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Array(arr)) } => {
827                        ResolvedValue::Concrete { value: serde_json::json!(arr.len()).into() }
828                    }
829                    ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Object(map)) } => {
830                        ResolvedValue::Concrete { value: serde_json::json!(map.len()).into() }
831                    }
832                    ResolvedValue::List { items } => {
833                        ResolvedValue::Concrete { value: serde_json::json!(items.len()).into() }
834                    }
835                    ResolvedValue::Map { entries } => {
836                        ResolvedValue::Concrete { value: serde_json::json!(entries.len()).into() }
837                    }
838                    ResolvedValue::Enum { variants } => {
839                        let results: Vec<ResolvedValue> = variants.iter().map(length_resolved).collect();
840                        ResolvedValue::Enum { variants: results }
841                    }
842                    ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
843                        ResolvedValue::Conditional {
844                            condition: cond.clone(),
845                            if_true: Box::new(length_resolved(t)),
846                            if_false: Box::new(length_resolved(f)),
847                        }
848                    }
849                    _ => ResolvedValue::Dynamic { reason: "Length with unresolvable argument".into() },
850                }
851            }
852            IntrinsicFn::ForEach(unique_id, identifier, collection_ref, body_ref) => {
853                let unique_id = unique_id.clone();
854                let identifier = identifier.clone();
855                let collection_ref = *collection_ref;
856                let body_ref = *body_ref;
857                if let Some(ref rid) = self.current_resource {
858                    let collection_desc = match self.arena.node(collection_ref) {
859                        Node::Intrinsic(IntrinsicFn::Ref(t)) => format!("Ref({})", t),
860                        Node::List(items) => format!("[{} items]", items.len()),
861                        _ => "dynamic".to_string(),
862                    };
863                    self.foreach_expansions.entry(rid.clone()).or_default().push((
864                        self.current_path.clone(),
865                        identifier.clone(),
866                        collection_desc,
867                    ));
868                }
869                let collection = self.resolve_node(collection_ref);
870                let elements: Option<Vec<ResolvedValue>> = match &collection {
871                    ResolvedValue::Concrete { value: v } => v.as_array().map(|arr| {
872                        arr.iter().map(|item| ResolvedValue::Concrete { value: item.clone().into() }).collect()
873                    }),
874                    ResolvedValue::List { items } => Some(items.clone()),
875                    _ => None,
876                };
877                if let Some(elements) = elements {
878                    let mut result_entries = Vec::new();
879                    for element in &elements {
880                        self.local_bindings.insert(identifier.clone(), element.clone());
881                        let resolved_body = self.resolve_node(body_ref);
882                        self.local_bindings.remove(&identifier);
883                        // ForEach body produces a map entry keyed by unique_id+element
884                        let key = match element {
885                            ResolvedValue::Concrete { value: v } => {
886                                format!("{}{}", unique_id, v.as_str().unwrap_or(&v.to_string()))
887                            }
888                            _ => format!("{}{}", unique_id, "{dynamic}"),
889                        };
890                        result_entries.push(MapEntry { key, value: resolved_body });
891                    }
892                    return ResolvedValue::Map { entries: result_entries };
893                }
894                ResolvedValue::Dynamic { reason: "ForEach macro output".into() }
895            }
896        }
897    }
898
899    fn resolve_ref(&mut self, target: &str, span: &SourceSpan) -> ResolvedValue {
900        if let Some(resolved) = self.lookup_ref(target, span) {
901            return resolved;
902        }
903        // The unresolved target is recorded in `invalid_refs`; the engines
904        // surface it as the invalid-reference diagnostic. This is an expected
905        // outcome for an invalid template, so log it at debug rather than warn.
906        debug!("Ref '{}' does not reference a valid target", target);
907        if let Some(ref rid) = self.current_resource {
908            self.invalid_refs.entry(rid.clone()).or_default().push((self.current_path.clone(), target.to_string()));
909        }
910        ResolvedValue::Dynamic { reason: format!("unknown ref target: {}", target) }
911    }
912
913    /// Resolves a Ref target to a value, recording edges and resolution sources
914    /// for known targets. Returns `None` when the target is not a condition,
915    /// ForEach binding, pseudo-parameter, parameter, or resource. Recording an
916    /// invalid ref is deliberately left to the caller: Fn::Sub variable
917    /// expansion shares this resolution path but an unresolved Sub variable is
918    /// not an invalid Ref and must not be registered as one.
919    fn lookup_ref(&mut self, target: &str, span: &SourceSpan) -> Option<ResolvedValue> {
920        if let Some(cond_name) = target.strip_prefix(CONDITION_REF_PREFIX) {
921            return Some(ResolvedValue::Dynamic { reason: format!("condition reference: {}", cond_name) });
922        }
923
924        // ForEach loop bindings take precedence
925        if let Some(bound) = self.local_bindings.get(target) {
926            return Some(bound.clone());
927        }
928
929        if PSEUDO_PARAMETERS.contains(&target) {
930            if let Some(ref rid) = self.current_resource {
931                self.resolution_source_map
932                    .entry((rid.clone(), self.current_path.clone()))
933                    .or_insert_with(|| format!("Intrinsic/{}", TAG_REF));
934            }
935            if target == PSEUDO_NO_VALUE {
936                return Some(ResolvedValue::Concrete { value: serde_json::Value::Null.into() });
937            }
938            if let Some(value) = self.pseudo_parameter_overrides.get(target) {
939                return Some(ResolvedValue::Concrete { value: pseudo_parameter_ref_to_json(target, value).into() });
940            }
941            return Some(ResolvedValue::Dynamic { reason: format!("pseudo-parameter {}", target) });
942        }
943
944        // Overrides take precedence over AllowedValues/Default
945        if let Some(override_val) = self.parameter_overrides.get(target)
946            && let Some(param) = self.parameters.get(target)
947        {
948            self.record_edge(target, RefKind::Ref, span);
949            if let Some(ref rid) = self.current_resource {
950                self.resolution_source_map
951                    .insert((rid.clone(), self.current_path.clone()), format!("Parameters/{}/Override", target));
952            }
953            let json_val = param_string_to_json(override_val, &param.param_type);
954            return Some(ResolvedValue::Concrete { value: json_val.into() });
955        }
956
957        if let Some(param) = self.parameters.get(target) {
958            self.record_edge(target, RefKind::Ref, span);
959            if let Some(ref rid) = self.current_resource {
960                let source = if param.allowed_values.is_some() {
961                    format!("Parameters/{}/AllowedValues", target)
962                } else if param.default.is_some() {
963                    format!("Parameters/{}/Default", target)
964                } else {
965                    format!("Parameters/{}", target)
966                };
967                self.resolution_source_map.insert((rid.clone(), self.current_path.clone()), source);
968            }
969            if let Some(ref allowed) = param.allowed_values {
970                return Some(ResolvedValue::Enum {
971                    variants: allowed
972                        .iter()
973                        .map(|v| ResolvedValue::Concrete { value: serde_json::Value::String(v.clone()).into() })
974                        .collect(),
975                });
976            }
977            if let Some(ref default) = param.default {
978                let json_val = param_string_to_json(default, &param.param_type);
979                return Some(ResolvedValue::Concrete { value: json_val.into() });
980            }
981            return Some(ResolvedValue::TypedDynamic {
982                reason: format!("parameter {} value unknown", target),
983                param_type: param.param_type.clone(),
984            });
985        }
986
987        if self.resource_ids.contains(target) {
988            self.record_edge(target, RefKind::Ref, span);
989            return Some(ResolvedValue::Reference { target: target.to_string(), kind: RefKind::Ref });
990        }
991
992        None
993    }
994
995    fn resolve_findinmap(
996        &mut self,
997        map_name_ref: NodeRef,
998        first_key_ref: NodeRef,
999        second_key_ref: NodeRef,
1000        default_ref: Option<NodeRef>,
1001    ) -> ResolvedValue {
1002        let saved_path = self.current_path.clone();
1003        let fim_path = format!("{}.Fn::FindInMap", saved_path);
1004
1005        self.current_path = format!("{}.0", fim_path);
1006        let map_name_resolved = self.resolve_node(map_name_ref);
1007
1008        self.current_path = format!("{}.1", fim_path);
1009        let first_key = self.resolve_node(first_key_ref);
1010
1011        self.current_path = format!("{}.2", fim_path);
1012        let second_key = self.resolve_node(second_key_ref);
1013
1014        self.current_path = saved_path;
1015
1016        match &map_name_resolved {
1017            ResolvedValue::Concrete { value: name_val } => {
1018                let map_name = name_val.as_str().unwrap_or("");
1019                if let Some(ref rid) = self.current_resource {
1020                    self.find_in_map_refs.entry(rid.clone()).or_default().push(map_name.to_string());
1021                }
1022                self.lookup_mapping(map_name, &first_key, &second_key, default_ref)
1023            }
1024            ResolvedValue::Enum { variants: name_variants } => {
1025                let results: Vec<ResolvedValue> = name_variants
1026                    .iter()
1027                    .map(|name_val| {
1028                        let map_name = match name_val {
1029                            ResolvedValue::Concrete { value: v } => v.as_str().unwrap_or("").to_string(),
1030                            _ => {
1031                                return ResolvedValue::Dynamic { reason: "non-concrete enum map name".into() };
1032                            }
1033                        };
1034                        if let Some(ref rid) = self.current_resource {
1035                            self.find_in_map_refs.entry(rid.clone()).or_default().push(map_name.clone());
1036                        }
1037                        self.lookup_mapping(&map_name, &first_key, &second_key, default_ref)
1038                    })
1039                    .collect();
1040                ResolvedValue::Enum { variants: results }
1041            }
1042            _ => {
1043                if let Some(def) = default_ref {
1044                    return self.resolve_node(def);
1045                }
1046                ResolvedValue::Dynamic { reason: "FindInMap with dynamic map name".into() }
1047            }
1048        }
1049    }
1050
1051    fn lookup_mapping(
1052        &mut self,
1053        map_name: &str,
1054        first_key: &ResolvedValue,
1055        second_key: &ResolvedValue,
1056        default_ref: Option<NodeRef>,
1057    ) -> ResolvedValue {
1058        let Some(mapping) = self.mappings.get(map_name) else {
1059            warn!("FindInMap references non-existent mapping '{}'", map_name);
1060            if let Some(def) = default_ref {
1061                return self.resolve_node(def);
1062            }
1063            return ResolvedValue::Dynamic { reason: format!("mapping '{}' not found", map_name) };
1064        };
1065
1066        match (first_key, second_key) {
1067            (ResolvedValue::Concrete { value: k1v }, ResolvedValue::Concrete { value: k2v }) => {
1068                let k1s = k1v.as_str().unwrap_or("");
1069                let k2s = k2v.as_str().unwrap_or("");
1070                match mapping.get(k1s).and_then(|m| m.get(k2s)) {
1071                    Some(v) => ResolvedValue::Concrete { value: v.clone().into() },
1072                    None => {
1073                        if let Some(def) = default_ref {
1074                            return self.resolve_node(def);
1075                        }
1076                        ResolvedValue::Dynamic { reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s) }
1077                    }
1078                }
1079            }
1080            (ResolvedValue::Concrete { value: k1v }, ResolvedValue::Enum { variants: k2_variants }) => {
1081                let k1s = k1v.as_str().unwrap_or("");
1082                let results: Vec<ResolvedValue> = k2_variants
1083                    .iter()
1084                    .map(|k2v| match k2v {
1085                        ResolvedValue::Concrete { value: v } => {
1086                            let k2s = v.as_str().unwrap_or("");
1087                            match mapping.get(k1s).and_then(|m| m.get(k2s)) {
1088                                Some(val) => ResolvedValue::Concrete { value: val.clone().into() },
1089                                None => ResolvedValue::Dynamic {
1090                                    reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s),
1091                                },
1092                            }
1093                        }
1094                        _ => ResolvedValue::Dynamic { reason: "non-concrete enum key".into() },
1095                    })
1096                    .collect();
1097                ResolvedValue::Enum { variants: results }
1098            }
1099            (
1100                ResolvedValue::Concrete { value: _ },
1101                ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f },
1102            ) => ResolvedValue::Conditional {
1103                condition: cond.clone(),
1104                if_true: Box::new(self.lookup_mapping(map_name, first_key, t, default_ref)),
1105                if_false: Box::new(self.lookup_mapping(map_name, first_key, f, default_ref)),
1106            },
1107            (ResolvedValue::Enum { variants: k1_vals }, _) => {
1108                let results: Vec<ResolvedValue> = k1_vals
1109                    .iter()
1110                    .map(|k1v| {
1111                        let k1s = match k1v {
1112                            ResolvedValue::Concrete { value: v } => v.as_str().unwrap_or("").to_string(),
1113                            _ => {
1114                                return ResolvedValue::Dynamic { reason: "non-concrete enum key".into() };
1115                            }
1116                        };
1117                        match second_key {
1118                            ResolvedValue::Concrete { value: k2v } => {
1119                                let k2s = k2v.as_str().unwrap_or("");
1120                                match mapping.get(&k1s).and_then(|m| m.get(k2s)) {
1121                                    Some(v) => ResolvedValue::Concrete { value: v.clone().into() },
1122                                    None => ResolvedValue::Dynamic {
1123                                        reason: format!("no mapping entry for {}/{}/{}", map_name, k1s, k2s),
1124                                    },
1125                                }
1126                            }
1127                            ResolvedValue::Enum { variants: k2_variants } => {
1128                                // Cartesian product: each k1 variant × each k2 variant
1129                                let inner: Vec<ResolvedValue> = k2_variants
1130                                    .iter()
1131                                    .map(|k2v| match k2v {
1132                                        ResolvedValue::Concrete { value: v } => {
1133                                            let k2s = v.as_str().unwrap_or("");
1134                                            match mapping.get(&k1s).and_then(|m| m.get(k2s)) {
1135                                                Some(val) => ResolvedValue::Concrete { value: val.clone().into() },
1136                                                None => ResolvedValue::Dynamic {
1137                                                    reason: format!(
1138                                                        "no mapping entry for {}/{}/{}",
1139                                                        map_name, k1s, k2s
1140                                                    ),
1141                                                },
1142                                            }
1143                                        }
1144                                        _ => ResolvedValue::Dynamic { reason: "non-concrete enum key".into() },
1145                                    })
1146                                    .collect();
1147                                ResolvedValue::Enum { variants: inner }
1148                            }
1149                            ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => {
1150                                let k1_concrete =
1151                                    ResolvedValue::Concrete { value: serde_json::Value::String(k1s).into() };
1152                                ResolvedValue::Conditional {
1153                                    condition: cond.clone(),
1154                                    if_true: Box::new(self.lookup_mapping(map_name, &k1_concrete, t, default_ref)),
1155                                    if_false: Box::new(self.lookup_mapping(map_name, &k1_concrete, f, default_ref)),
1156                                }
1157                            }
1158                            _ => ResolvedValue::Dynamic { reason: "FindInMap with dynamic key2".into() },
1159                        }
1160                    })
1161                    .collect();
1162                // Flatten nested Enums from cartesian product into a single Enum
1163                let flattened: Vec<ResolvedValue> = results
1164                    .into_iter()
1165                    .flat_map(|v| match v {
1166                        ResolvedValue::Enum { variants } => variants,
1167                        other => vec![other],
1168                    })
1169                    .collect();
1170                ResolvedValue::Enum { variants: flattened }
1171            }
1172            (_, ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f }) => {
1173                ResolvedValue::Conditional {
1174                    condition: cond.clone(),
1175                    if_true: Box::new(self.lookup_mapping(map_name, first_key, t, default_ref)),
1176                    if_false: Box::new(self.lookup_mapping(map_name, first_key, f, default_ref)),
1177                }
1178            }
1179            _ => {
1180                if let Some(def) = default_ref {
1181                    return self.resolve_node(def);
1182                }
1183                ResolvedValue::Dynamic { reason: "FindInMap with dynamic keys".into() }
1184            }
1185        }
1186    }
1187
1188    /// Whether the current resource declares `var` as a
1189    /// `DefinitionSubstitutions` key.
1190    fn is_definition_substitution(&self, var: &str) -> bool {
1191        self.current_resource
1192            .as_ref()
1193            .and_then(|rid| self.def_subs_resources.get(rid))
1194            .is_some_and(|keys| keys.contains(var))
1195    }
1196
1197    fn detect_unsubstituted_variables(&mut self, s: &str) {
1198        if !s.contains("${") {
1199            return;
1200        }
1201        let Some(ref rid) = self.current_resource else {
1202            return;
1203        };
1204        // Skip TemplateBody properties (nested stacks)
1205        let path = &self.current_path;
1206        if path.contains("TemplateBody") {
1207            return;
1208        }
1209
1210        let mut i = 0;
1211        let bytes = s.as_bytes();
1212        while i < bytes.len() {
1213            if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1214                if i + 2 < bytes.len() && bytes[i + 2] == b'!' {
1215                    i += 3;
1216                    continue;
1217                }
1218                let start = i + 2;
1219                if let Some(end) = s[start..].find('}') {
1220                    let var = s[start..start + end].trim();
1221                    if var.starts_with("stageVariables.") {
1222                        i = start + end + 1;
1223                        continue;
1224                    }
1225                    // A variable fires when it names a known ref target
1226                    // (parameter,
1227                    // resource, pseudo-parameter, or `Resource.Attr`) - or,
1228                    // regardless of target validity, inside a Step Functions
1229                    // `DefinitionString`, where every `${...}` placeholder is
1230                    // expected to come from `DefinitionSubstitutions` (that
1231                    // resource-level exemption is applied above).
1232                    let is_sub_style_variable = !var.is_empty()
1233                        && var.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '.'));
1234                    let names_known_target = self.resource_ids.contains(var)
1235                        || self.parameters.contains_key(var)
1236                        || PSEUDO_PARAMETERS.contains(&var)
1237                        || (var.contains('.')
1238                            && var.split('.').next().map(|prefix| self.resource_ids.contains(prefix)).unwrap_or(false));
1239                    // A Step Functions definition placeholder is exempt when it
1240                    // names one of the resource's DefinitionSubstitutions keys
1241                    // - and reportable otherwise, whether or not it names a
1242                    // known ref target.
1243                    let in_definition = path.contains("DefinitionString") || path.contains("Definition");
1244                    if in_definition && self.is_definition_substitution(var) {
1245                        i = start + end + 1;
1246                        continue;
1247                    }
1248                    if is_sub_style_variable && (names_known_target || in_definition) {
1249                        self.unsubstituted_variables
1250                            .entry(rid.clone())
1251                            .or_default()
1252                            .push((path.clone(), format!("${{{}}}", var)));
1253                    }
1254                    i = start + end + 1;
1255                } else {
1256                    break;
1257                }
1258            } else {
1259                i += 1;
1260            }
1261        }
1262    }
1263
1264    fn detect_raw_pseudo_param(&mut self, s: &str) {
1265        if !s.starts_with(PSEUDO_PREFIX) {
1266            return;
1267        }
1268        let Some(ref rid) = self.current_resource else {
1269            return;
1270        };
1271        if PSEUDO_PARAMETERS.contains(&s) {
1272            self.raw_pseudo_params.entry(rid.clone()).or_default().push((self.current_path.clone(), s.to_string()));
1273        }
1274    }
1275
1276    fn detect_secretsmanager_ref(&mut self, s: &str) {
1277        if !s.contains("{{resolve:secretsmanager:") {
1278            return;
1279        }
1280        let Some(ref rid) = self.current_resource else {
1281            return;
1282        };
1283        // The "secret value where an ARN is expected" warning only applies
1284        // inside a resource's `Properties`. A secretsmanager dynamic reference
1285        // elsewhere (Metadata, DependsOn, etc.) is governed by the
1286        // not-supported-location check, not this one, so restrict collection to
1287        // property paths.
1288        if !self.current_path.starts_with("Properties.") && self.current_path != "Properties" {
1289            return;
1290        }
1291        self.secretsmanager_ref_paths.entry(rid.clone()).or_default().push(self.current_path.clone());
1292    }
1293
1294    /// Returns true if the Fn::Join values array can be converted to Fn::Sub.
1295    fn is_simple_join(&self, values_ref: NodeRef) -> bool {
1296        let items = match self.arena.as_list(values_ref) {
1297            Some(items) => items,
1298            None => return false,
1299        };
1300        items.iter().all(|item_ref| {
1301            match &self.arena.node(*item_ref) {
1302                Node::String(_) | Node::Int(_) | Node::Float(_) | Node::Bool(_) => true,
1303                Node::Intrinsic(intrinsic) => matches!(
1304                    intrinsic,
1305                    IntrinsicFn::Ref(_) | IntrinsicFn::GetAtt(_, _) | IntrinsicFn::Sub(_, None) // Sub with string-only template (no substitution map)
1306                ),
1307                _ => false,
1308            }
1309        })
1310    }
1311
1312    fn resolve_sub(
1313        &mut self,
1314        template: &str,
1315        subs: &Option<Vec<(String, NodeRef)>>,
1316        span: &SourceSpan,
1317    ) -> ResolvedValue {
1318        let mut vars: Vec<String> = Vec::new();
1319        let mut i = 0;
1320        let bytes = template.as_bytes();
1321        while i < bytes.len() {
1322            if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
1323                let start = i + 2;
1324                if let Some(end) = template[start..].find('}') {
1325                    let variable = template[start..start + end].trim();
1326                    if !variable.starts_with('!') {
1327                        vars.push(variable.to_string());
1328                    }
1329                    i = start + end + 1;
1330                } else {
1331                    i += 1;
1332                }
1333            } else {
1334                i += 1;
1335            }
1336        }
1337
1338        let mut sub_map: HashMap<String, ResolvedValue> = HashMap::new();
1339        let invalid_refs_before = self.invalid_ref_count();
1340        if let Some(explicit_subs) = subs {
1341            for (k, v) in explicit_subs {
1342                sub_map.insert(k.clone(), self.resolve_node(*v));
1343            }
1344            if let Some(ref rid) = self.current_resource {
1345                for (k, _) in explicit_subs {
1346                    if !vars.iter().any(|v| v == k) {
1347                        self.unused_sub_keys
1348                            .entry(rid.clone())
1349                            .or_default()
1350                            .push((self.current_path.clone(), k.clone()));
1351                    }
1352                }
1353            }
1354        }
1355        let sub_map_is_valid = self.invalid_ref_count() == invalid_refs_before;
1356
1357        for var in &vars {
1358            if sub_map.contains_key(var) {
1359                continue;
1360            }
1361            // Check for Resource.Attribute syntax (implicit GetAtt in Sub)
1362            if let Some(dot_pos) = var.find('.') {
1363                let resource = &var[..dot_pos];
1364                let attr = &var[dot_pos + 1..];
1365                if self.resource_ids.contains(resource) {
1366                    if self.is_output_context() {
1367                        let path = format!("{}.Fn::Sub", self.current_path);
1368                        self.record_edge_at_path(resource, RefKind::GetAtt { attr: attr.to_string() }, span, path);
1369                    } else {
1370                        self.record_edge(resource, RefKind::GetAtt { attr: attr.to_string() }, span);
1371                    }
1372                    sub_map.insert(
1373                        var.clone(),
1374                        ResolvedValue::Reference {
1375                            target: resource.to_string(),
1376                            kind: RefKind::GetAtt { attr: attr.to_string() },
1377                        },
1378                    );
1379                    continue;
1380                }
1381            }
1382            // Fn::Sub variables share Ref resolution, but an unresolved Sub
1383            // variable is not an invalid Ref, so resolve it without recording
1384            // it as one. When the variable names a resource, `lookup_ref` has
1385            // already recorded a `Ref` edge - CloudFormation treats a bare
1386            // `${Resource}` substitution as a `Ref`, so recording an extra `Sub`
1387            // edge would double-count the dependency (surfacing a spurious second
1388            // dependency finding under a `Sub` label that misrepresents the edge).
1389            let resolved = match self.lookup_ref(var, span) {
1390                Some(value) => value,
1391                None => {
1392                    if sub_map_is_valid {
1393                        let path = if self.is_output_context() {
1394                            format!("{}.Fn::Sub", self.current_path)
1395                        } else {
1396                            self.current_path.clone()
1397                        };
1398                        self.record_edge_at_path(var, RefKind::Sub { var: var.clone() }, span, path);
1399                    }
1400                    ResolvedValue::Dynamic { reason: format!("unknown sub variable: {}", var) }
1401                }
1402            };
1403            sub_map.insert(var.clone(), resolved);
1404        }
1405
1406        if vars.len() == 1 && subs.is_none() {
1407            let var = &vars[0];
1408            let expected = format!("${{{}}}", var);
1409            if template == expected
1410                && let Some(ref rid) = self.current_resource
1411            {
1412                self.simple_subs.entry(rid.clone()).or_default().push((self.current_path.clone(), var.clone()));
1413            }
1414        }
1415
1416        if vars.is_empty()
1417            && subs.is_none()
1418            && !template.contains("${!")
1419            && let Some(ref rid) = self.current_resource
1420        {
1421            self.redundant_subs.entry(rid.clone()).or_default().push(format!("{}.Fn::Sub", self.current_path));
1422        }
1423
1424        if template.contains("arn:aws:")
1425            && let Some(ref rid) = self.current_resource
1426        {
1427            // This finding is anchored at the Fn::Sub node, so its reported path
1428            // ends in `.Fn::Sub` (the intrinsic that builds the hardcoded ARN).
1429            self.hardcoded_partition_arns
1430                .entry(rid.clone())
1431                .or_default()
1432                .push(format!("{}.Fn::Sub", self.current_path));
1433        }
1434
1435        let all_concrete = sub_map.values().all(|v| matches!(v, ResolvedValue::Concrete { value: _ }));
1436        if all_concrete {
1437            let mut result = template.to_string();
1438            for (var, val) in &sub_map {
1439                if let ResolvedValue::Concrete { value: v } = val {
1440                    let replacement = v.as_str().unwrap_or(&v.to_string()).to_string();
1441                    result = result.replace(&format!("${{{}}}", var), &replacement);
1442                }
1443            }
1444            return ResolvedValue::Concrete {
1445                value: serde_json::Value::String(crate::transform_expansion::unescape_sub_literals(&result)).into(),
1446            };
1447        }
1448
1449        let has_enum = sub_map.values().any(|v| matches!(v, ResolvedValue::Enum { variants: _ }));
1450        if has_enum {
1451            let mut enum_vars: Vec<(String, Vec<String>)> = Vec::new();
1452            for (var, val) in &sub_map {
1453                if let ResolvedValue::Enum { variants } = val {
1454                    let concrete: Vec<String> = variants
1455                        .iter()
1456                        .filter_map(|v| {
1457                            if let ResolvedValue::Concrete { value: cv } = v {
1458                                Some(cv.as_str().unwrap_or("").to_string())
1459                            } else {
1460                                None
1461                            }
1462                        })
1463                        .collect();
1464                    if !concrete.is_empty() {
1465                        enum_vars.push((var.clone(), concrete));
1466                    }
1467                }
1468            }
1469            if !enum_vars.is_empty() {
1470                let mut combos: Vec<Vec<(String, String)>> = vec![vec![]];
1471                for (var, vals) in &enum_vars {
1472                    let mut new_combos = Vec::new();
1473                    for combo in &combos {
1474                        for val in vals {
1475                            let mut c = combo.clone();
1476                            c.push((var.clone(), val.clone()));
1477                            new_combos.push(c);
1478                        }
1479                    }
1480                    combos = new_combos;
1481                }
1482                let results: Vec<ResolvedValue> = combos
1483                    .into_iter()
1484                    .map(|combo| {
1485                        let mut result = template.to_string();
1486                        for (var, val) in &combo {
1487                            result = result.replace(&format!("${{{}}}", var), val);
1488                        }
1489                        for (var, val) in &sub_map {
1490                            if let ResolvedValue::Concrete { value: cv } = val {
1491                                let replacement = cv.as_str().unwrap_or(&cv.to_string()).to_string();
1492                                result = result.replace(&format!("${{{}}}", var), &replacement);
1493                            }
1494                        }
1495                        // If unresolved vars remain, produce Dynamic with partial info
1496                        if crate::transform_expansion::contains_sub_variable(&result) {
1497                            ResolvedValue::Dynamic { reason: format!("{}{}", SUB_PARTIAL_PREFIX, result) }
1498                        } else {
1499                            ResolvedValue::Concrete {
1500                                value: serde_json::Value::String(crate::transform_expansion::unescape_sub_literals(
1501                                    &result,
1502                                ))
1503                                .into(),
1504                            }
1505                        }
1506                    })
1507                    .collect();
1508                if !results.is_empty() {
1509                    return ResolvedValue::Enum { variants: results };
1510                }
1511            }
1512        }
1513
1514        let mut partial = template.to_string();
1515        for (var, val) in &sub_map {
1516            if let ResolvedValue::Concrete { value: v } = val {
1517                let replacement = v.as_str().unwrap_or(&v.to_string()).to_string();
1518                partial = partial.replace(&format!("${{{}}}", var), &replacement);
1519            }
1520        }
1521        // Must capture condition refs before collapsing to Dynamic
1522        for val in sub_map.values() {
1523            self.collect_extra_condition_refs(val);
1524        }
1525        ResolvedValue::Dynamic { reason: format!("{}{}", SUB_PARTIAL_PREFIX, partial) }
1526    }
1527
1528    fn collect_extra_condition_refs(&mut self, val: &ResolvedValue) {
1529        let key = match &self.current_resource {
1530            Some(r) => r.clone(),
1531            None => return,
1532        };
1533        let mut conds = Vec::new();
1534        crate::resolved_value::collect_condition_refs_from_resolved(val, &mut conds);
1535        if !conds.is_empty() {
1536            self.extra_condition_refs.entry(key).or_default().append(&mut conds);
1537        }
1538    }
1539
1540    fn invalid_ref_count(&self) -> usize {
1541        self.invalid_refs.values().map(Vec::len).sum()
1542    }
1543
1544    fn record_edge(&mut self, target: &str, kind: RefKind, span: &SourceSpan) {
1545        self.record_edge_at_path(target, kind, span, self.current_path.clone());
1546    }
1547
1548    /// Records a reference edge with an explicit source path. Use this when the
1549    /// edge path must include a terminal intrinsic segment that differs from the
1550    /// resolver's walking path (e.g. output edges that must carry the intrinsic
1551    /// function name at the end for precise diagnostic locations).
1552    fn record_edge_at_path(&mut self, target: &str, kind: RefKind, span: &SourceSpan, path: String) {
1553        if let Some(ref resource) = self.current_resource.clone() {
1554            let condition_context = if self.condition_stack.is_empty() {
1555                None
1556            } else {
1557                let ctx: Vec<String> =
1558                    self.condition_stack.iter().map(|(c, b)| if *b { c.clone() } else { format!("!{}", c) }).collect();
1559                Some(ctx.join(","))
1560            };
1561            self.edges.push(ResolverEdge {
1562                source_resource: resource.clone(),
1563                source_path: path,
1564                target: target.to_string(),
1565                kind,
1566                span: *span,
1567                condition_context,
1568            });
1569        }
1570    }
1571
1572    /// Whether the currently resolving resource is an output pseudo-resource.
1573    fn is_output_context(&self) -> bool {
1574        self.current_resource.as_ref().is_some_and(|r| r.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX))
1575    }
1576
1577    pub fn set_current_resource(&mut self, resource_id: &str) {
1578        self.current_resource = Some(resource_id.to_string());
1579    }
1580
1581    pub fn set_current_path(&mut self, path: &str) {
1582        self.current_path = path.to_string();
1583    }
1584}
1585
1586fn pseudo_parameter_ref_to_json(name: &str, value: String) -> serde_json::Value {
1587    if name == PSEUDO_NOTIFICATION_ARNS {
1588        return serde_json::Value::Array(vec![serde_json::Value::String(value)]);
1589    }
1590    serde_json::Value::String(value)
1591}
1592
1593fn param_string_to_json(value: &str, param_type: &str) -> serde_json::Value {
1594    if param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<") {
1595        return serde_json::Value::Array(
1596            value.split(',').map(|v| serde_json::Value::String(v.trim().to_string())).collect(),
1597        );
1598    }
1599    match param_type {
1600        // Parse whole numbers as integers so a Number parameter value of "30"
1601        // resolves to 30, not 30.0 - the float form would fail integer enum
1602        // comparisons and render as '30.0' in diagnostics.
1603        PARAM_TYPE_NUMBER => value
1604            .parse::<i64>()
1605            .map(|i| serde_json::Value::Number(i.into()))
1606            .or_else(|_| {
1607                value.parse::<f64>().map(|n| {
1608                    serde_json::Number::from_f64(n)
1609                        .map(serde_json::Value::Number)
1610                        .unwrap_or(serde_json::Value::String(value.to_string()))
1611                })
1612            })
1613            .unwrap_or(serde_json::Value::String(value.to_string())),
1614        _ => serde_json::Value::String(value.to_string()),
1615    }
1616}
1617
1618fn calculate_cidr_blocks(ip_block: &str, count: u64, cidr_bits: u64) -> Option<Vec<String>> {
1619    let (ip_str, prefix_str) = ip_block.split_once('/')?;
1620    let prefix_len: u32 = prefix_str.parse().ok()?;
1621    let new_prefix = 32 - cidr_bits as u32;
1622    if new_prefix <= prefix_len {
1623        return None;
1624    }
1625    let ip: u32 = ip_str.split('.').enumerate().try_fold(0u32, |acc, (i, octet)| {
1626        let o: u32 = octet.parse().ok()?;
1627        Some(acc | (o << (24 - i * 8)))
1628    })?;
1629    let subnet_size = 1u32 << cidr_bits;
1630    let base = ip & !((1u32 << (32 - prefix_len)) - 1);
1631    let mut results = Vec::new();
1632    for i in 0..count.min(256) {
1633        let subnet_ip = base + (i as u32) * subnet_size;
1634        let a = (subnet_ip >> 24) & 0xFF;
1635        let b = (subnet_ip >> 16) & 0xFF;
1636        let c = (subnet_ip >> 8) & 0xFF;
1637        let d = subnet_ip & 0xFF;
1638        results.push(format!("{}.{}.{}.{}/{}", a, b, c, d, new_prefix));
1639    }
1640    Some(results)
1641}
1642
1643/// Collapses a resolved concrete string that embeds a dynamic reference
1644/// (`{{resolve:ssm:...}}`, `{{resolve:ssm-secure:...}}`, `{{resolve:secretsmanager:...}}`)
1645/// into a deploy-time-opaque value, mirroring how `Fn::ImportValue` resolves.
1646fn opaque_if_dynamic_reference(value: ResolvedValue) -> ResolvedValue {
1647    if let ResolvedValue::Concrete { value: ref json } = value
1648        && let Some(s) = json.as_str()
1649        && s.contains("{{resolve:")
1650    {
1651        return ResolvedValue::TypedDynamic {
1652            reason: format!("dynamic reference: {}", s),
1653            param_type: PARAM_TYPE_STRING.into(),
1654        };
1655    }
1656    value
1657}
1658
1659fn join_resolved(delim: &str, values: &ResolvedValue) -> ResolvedValue {
1660    match values {
1661        ResolvedValue::Concrete { value: v } => {
1662            if let Some(arr) = v.as_array() {
1663                let parts: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
1664                if parts.len() == arr.len() {
1665                    return ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() };
1666                }
1667            }
1668            ResolvedValue::Dynamic { reason: "Join with non-string elements".into() }
1669        }
1670        ResolvedValue::Enum { variants } => {
1671            let results: Vec<ResolvedValue> = variants.iter().map(|v| join_resolved(delim, v)).collect();
1672            ResolvedValue::Enum { variants: results }
1673        }
1674        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1675            condition: cond.clone(),
1676            if_true: Box::new(join_resolved(delim, t)),
1677            if_false: Box::new(join_resolved(delim, f)),
1678        },
1679        _ => ResolvedValue::Dynamic { reason: "Join with unresolvable arguments".into() },
1680    }
1681}
1682
1683pub(crate) fn join_with_enum_list(delim: &str, items: &[ResolvedValue]) -> (ResolvedValue, bool) {
1684    // Build cartesian product of all items, expanding Enum variants.
1685    // Returns (result, curtailed) where curtailed is true only if a combination
1686    // that would have been the (MAX_ENUM_EXPANSION+1)th was attempted.
1687    let mut combos: Vec<Vec<String>> = vec![vec![]];
1688    let mut curtailed = false;
1689    for item in items {
1690        match item {
1691            ResolvedValue::Concrete { value: v } => {
1692                let s = v.as_str().unwrap_or("").to_string();
1693                for combo in &mut combos {
1694                    combo.push(s.clone());
1695                }
1696            }
1697            ResolvedValue::Enum { variants } => {
1698                let strs: Vec<String> = variants
1699                    .iter()
1700                    .filter_map(|v| {
1701                        if let ResolvedValue::Concrete { value: cv } = v {
1702                            cv.as_str().map(|s| s.to_string())
1703                        } else {
1704                            None
1705                        }
1706                    })
1707                    .collect();
1708                if strs.is_empty() {
1709                    return (ResolvedValue::Dynamic { reason: "Join with non-concrete enum".into() }, false);
1710                }
1711                let mut new_combos = Vec::new();
1712                for combo in &combos {
1713                    for s in &strs {
1714                        if new_combos.len() == MAX_ENUM_EXPANSION {
1715                            curtailed = true;
1716                            break;
1717                        }
1718                        let mut c = combo.clone();
1719                        c.push(s.clone());
1720                        new_combos.push(c);
1721                    }
1722                    if curtailed {
1723                        break;
1724                    }
1725                }
1726                combos = new_combos;
1727            }
1728            _ => {
1729                return (ResolvedValue::Dynamic { reason: "Join with unresolvable list element".into() }, false);
1730            }
1731        }
1732    }
1733    let results: Vec<ResolvedValue> = combos
1734        .into_iter()
1735        .map(|parts| ResolvedValue::Concrete { value: serde_json::Value::String(parts.join(delim)).into() })
1736        .collect();
1737    let result = if results.len() == 1 {
1738        results.into_iter().next().unwrap()
1739    } else {
1740        ResolvedValue::Enum { variants: results }
1741    };
1742    (result, curtailed)
1743}
1744
1745fn select_resolved(idx: &serde_json::Value, list: &ResolvedValue) -> ResolvedValue {
1746    match list {
1747        ResolvedValue::Concrete { value: l } => {
1748            if let (Some(arr), Some(i)) = (l.as_array(), idx.as_u64())
1749                && (i as usize) < arr.len()
1750            {
1751                return ResolvedValue::Concrete { value: arr[i as usize].clone().into() };
1752            }
1753            ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1754        }
1755        ResolvedValue::List { items } => {
1756            if let Some(i) = idx.as_u64()
1757                && (i as usize) < items.len()
1758            {
1759                return items[i as usize].clone();
1760            }
1761            ResolvedValue::Dynamic { reason: "Select index out of bounds".into() }
1762        }
1763        ResolvedValue::Enum { variants } => {
1764            let results: Vec<ResolvedValue> = variants.iter().map(|v| select_resolved(idx, v)).collect();
1765            ResolvedValue::Enum { variants: results }
1766        }
1767        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1768            condition: cond.clone(),
1769            if_true: Box::new(select_resolved(idx, t)),
1770            if_false: Box::new(select_resolved(idx, f)),
1771        },
1772        _ => ResolvedValue::Dynamic { reason: "Select with unresolvable arguments".into() },
1773    }
1774}
1775
1776fn split_resolved(delim: &str, src: &ResolvedValue) -> ResolvedValue {
1777    match src {
1778        ResolvedValue::Concrete { value: s } => {
1779            if let Some(ss) = s.as_str() {
1780                let parts: Vec<serde_json::Value> =
1781                    ss.split(delim).map(|p| serde_json::Value::String(p.to_string())).collect();
1782                return ResolvedValue::Concrete { value: serde_json::Value::Array(parts).into() };
1783            }
1784            ResolvedValue::Dynamic { reason: "Split with non-string argument".into() }
1785        }
1786        ResolvedValue::Enum { variants } => {
1787            let results: Vec<ResolvedValue> = variants.iter().map(|v| split_resolved(delim, v)).collect();
1788            ResolvedValue::Enum { variants: results }
1789        }
1790        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1791            condition: cond.clone(),
1792            if_true: Box::new(split_resolved(delim, t)),
1793            if_false: Box::new(split_resolved(delim, f)),
1794        },
1795        _ => ResolvedValue::Dynamic { reason: "Split with unresolvable argument".into() },
1796    }
1797}
1798
1799fn base64_resolved(val: &ResolvedValue) -> ResolvedValue {
1800    match val {
1801        ResolvedValue::Concrete { value: v } => {
1802            if let Some(s) = v.as_str() {
1803                let encoded = base64::engine::general_purpose::STANDARD.encode(s);
1804                return ResolvedValue::Concrete { value: serde_json::Value::String(encoded).into() };
1805            }
1806            ResolvedValue::Dynamic { reason: "Base64 with non-string argument".into() }
1807        }
1808        ResolvedValue::Enum { variants } => {
1809            let results: Vec<ResolvedValue> = variants.iter().map(base64_resolved).collect();
1810            ResolvedValue::Enum { variants: results }
1811        }
1812        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1813            condition: cond.clone(),
1814            if_true: Box::new(base64_resolved(t)),
1815            if_false: Box::new(base64_resolved(f)),
1816        },
1817        _ => ResolvedValue::Dynamic { reason: "Base64 with unresolvable argument".into() },
1818    }
1819}
1820
1821fn length_resolved(val: &ResolvedValue) -> ResolvedValue {
1822    match val {
1823        ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Array(arr)) } => {
1824            ResolvedValue::Concrete { value: serde_json::json!(arr.len()).into() }
1825        }
1826        ResolvedValue::Concrete { value: JsonValue(serde_json::Value::Object(map)) } => {
1827            ResolvedValue::Concrete { value: serde_json::json!(map.len()).into() }
1828        }
1829        ResolvedValue::Enum { variants } => {
1830            let results: Vec<ResolvedValue> = variants.iter().map(length_resolved).collect();
1831            ResolvedValue::Enum { variants: results }
1832        }
1833        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1834            condition: cond.clone(),
1835            if_true: Box::new(length_resolved(t)),
1836            if_false: Box::new(length_resolved(f)),
1837        },
1838        _ => ResolvedValue::Dynamic { reason: "Length with unresolvable argument".into() },
1839    }
1840}
1841
1842fn resolve_getazs_value(
1843    region_val: &ResolvedValue,
1844    pseudo_overrides: &crate::model::PseudoParameterOverrides,
1845) -> ResolvedValue {
1846    match region_val {
1847        ResolvedValue::Concrete { value: v } => {
1848            let region_str = v.as_str().unwrap_or("");
1849            let effective_region = if region_str.is_empty() { pseudo_overrides.region() } else { region_str };
1850            match availability_zones_for_region(effective_region) {
1851                Some(azs) => ResolvedValue::Concrete {
1852                    value: serde_json::Value::Array(
1853                        azs.iter().map(|z| serde_json::Value::String((*z).to_string())).collect(),
1854                    )
1855                    .into(),
1856                },
1857                None => ResolvedValue::Dynamic { reason: format!("GetAZs unknown region {}", effective_region) },
1858            }
1859        }
1860        ResolvedValue::Enum { variants } => {
1861            let results: Vec<ResolvedValue> =
1862                variants.iter().map(|v| resolve_getazs_value(v, pseudo_overrides)).collect();
1863            ResolvedValue::Enum { variants: results }
1864        }
1865        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1866            condition: cond.clone(),
1867            if_true: Box::new(resolve_getazs_value(t, pseudo_overrides)),
1868            if_false: Box::new(resolve_getazs_value(f, pseudo_overrides)),
1869        },
1870        _ => ResolvedValue::Dynamic { reason: "GetAZs runtime value".into() },
1871    }
1872}
1873
1874fn resolve_cidr_value(ip_val: &ResolvedValue, count_val: &ResolvedValue, bits_val: &ResolvedValue) -> ResolvedValue {
1875    match (ip_val, count_val, bits_val) {
1876        (
1877            ResolvedValue::Concrete { value: ip },
1878            ResolvedValue::Concrete { value: cnt },
1879            ResolvedValue::Concrete { value: bits },
1880        ) => {
1881            let ip_str = ip.as_str().unwrap_or("");
1882            let count = cnt.as_u64().or_else(|| cnt.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1883            let cidr_bits = bits.as_u64().or_else(|| bits.as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
1884            match calculate_cidr_blocks(ip_str, count, cidr_bits) {
1885                Some(blocks) => ResolvedValue::Concrete {
1886                    value: serde_json::Value::Array(blocks.into_iter().map(serde_json::Value::String).collect()).into(),
1887                },
1888                None => ResolvedValue::Dynamic { reason: "Cidr calculation failed".into() },
1889            }
1890        }
1891        _ => ResolvedValue::Dynamic { reason: "Cidr runtime value".into() },
1892    }
1893}
1894
1895fn to_json_string_resolved(val: &ResolvedValue) -> ResolvedValue {
1896    match val {
1897        ResolvedValue::Concrete { value: v } => {
1898            ResolvedValue::Concrete { value: serde_json::Value::String(v.to_string()).into() }
1899        }
1900        ResolvedValue::Enum { variants } => {
1901            let results: Vec<ResolvedValue> = variants.iter().map(to_json_string_resolved).collect();
1902            ResolvedValue::Enum { variants: results }
1903        }
1904        ResolvedValue::Conditional { condition: cond, if_true: t, if_false: f } => ResolvedValue::Conditional {
1905            condition: cond.clone(),
1906            if_true: Box::new(to_json_string_resolved(t)),
1907            if_false: Box::new(to_json_string_resolved(f)),
1908        },
1909        _ => ResolvedValue::Dynamic { reason: "ToJsonString with unresolvable argument".into() },
1910    }
1911}
1912
1913/// Whether a parameter-constraint node satisfies the expected JSON Schema type
1914/// under CloudFormation's loose coercion. CloudFormation stringifies scalar
1915/// values, so a quoted number/bool such as `MaxLength: '12'` and `NoEcho: 'true'`
1916/// is accepted just like its native form. A native match always passes; a string
1917/// that coerces to the expected type passes too.
1918fn node_matches_param_type(node: &Node, expected: &str) -> bool {
1919    match (expected, node) {
1920        ("integer", Node::Int(_)) => true,
1921        ("number", Node::Int(_) | Node::Float(_)) => true,
1922        ("boolean", Node::Bool(_)) => true,
1923        (_, Node::String(s)) => type_compatible(&serde_json::Value::String(s.clone()), expected),
1924        _ => false,
1925    }
1926}
1927
1928pub fn extract_parameters(ir: &TemplateIR) -> (HashMap<String, ParameterInfo>, Vec<ParseDefect>) {
1929    let mut params = HashMap::new();
1930    let mut diags = Vec::new();
1931    if ir.parameters == NULL_REF {
1932        return (params, diags);
1933    }
1934    let Some(entries) = ir.arena.as_map(ir.parameters) else {
1935        return (params, diags);
1936    };
1937
1938    let e2001 = |msg: String, param_name: &str, prop: Option<&str>, span: SourceSpan| -> ParseDefect {
1939        // Section-absolute slash form, so identity and span derivation can
1940        // attribute the finding to the parameter.
1941        let path = match prop {
1942            Some(p) => format!("Parameters/{}/{}", param_name, p),
1943            None => format!("Parameters/{}", param_name),
1944        };
1945        let mut d = crate::make_parse_defect("E2001", msg, span);
1946        d.property_path = Some(path);
1947        d
1948    };
1949
1950    const VALID_KEYS: &[&str] = &[
1951        KEY_ALLOWED_PATTERN,
1952        KEY_ALLOWED_VALUES,
1953        KEY_CONSTRAINT_DESCRIPTION,
1954        KEY_DEFAULT,
1955        SECTION_DESCRIPTION,
1956        KEY_MAX_LENGTH,
1957        KEY_MIN_LENGTH,
1958        KEY_MAX_VALUE,
1959        KEY_MIN_VALUE,
1960        KEY_NO_ECHO,
1961        KEY_TYPE,
1962    ];
1963
1964    for (name, node_ref) in entries {
1965        let param_span = ir.arena.span(*node_ref);
1966
1967        // Parameter value must be an object (map)
1968        let Some(param_map) = ir.arena.as_map(*node_ref) else {
1969            if matches!(ir.arena.node(*node_ref), Node::Null) {
1970                diags.push(e2001(
1971                    format!("Parameter '{}': None is not of type 'object'", name),
1972                    name,
1973                    None,
1974                    param_span,
1975                ));
1976            }
1977            continue;
1978        };
1979
1980        let has_type = param_map.iter().any(|(k, _)| k == KEY_TYPE);
1981        if !has_type {
1982            diags.push(e2001(format!("Parameter '{}': 'Type' is a required property", name), name, None, param_span));
1983        }
1984
1985        for (key, val_ref) in param_map {
1986            let val_span = ir.arena.span(*val_ref);
1987            let node = ir.arena.node(*val_ref);
1988
1989            if !VALID_KEYS.contains(&key.as_str()) {
1990                diags.push(e2001(
1991                    format!("Parameter '{}': '{}' is not one of {}", name, key, render_str_list(VALID_KEYS)),
1992                    name,
1993                    Some(key),
1994                    val_span,
1995                ));
1996                continue;
1997            }
1998
1999            match key.as_str() {
2000                KEY_TYPE => {
2001                    if matches!(node, Node::Null) {
2002                        diags.push(e2001(
2003                            format!("Parameter '{}': Type must not be null", name),
2004                            name,
2005                            Some("Type"),
2006                            val_span,
2007                        ));
2008                    } else if !matches!(node, Node::String(_)) {
2009                        diags.push(e2001(
2010                            format!("Parameter '{}': Type must be a string", name),
2011                            name,
2012                            Some("Type"),
2013                            val_span,
2014                        ));
2015                    }
2016                }
2017                KEY_ALLOWED_VALUES => {
2018                    if !matches!(node, Node::List(_)) {
2019                        diags.push(e2001(
2020                            format!("Parameter '{}': AllowedValues must be an array", name),
2021                            name,
2022                            Some("AllowedValues"),
2023                            val_span,
2024                        ));
2025                    }
2026                }
2027                KEY_NO_ECHO => {
2028                    if matches!(node, Node::Null) {
2029                        diags.push(e2001(
2030                            format!("Parameter '{}': NoEcho must not be null", name),
2031                            name,
2032                            Some("NoEcho"),
2033                            val_span,
2034                        ));
2035                    } else if !node_matches_param_type(node, "boolean") {
2036                        diags.push(e2001(
2037                            format!("Parameter '{}': NoEcho must be a boolean", name),
2038                            name,
2039                            Some("NoEcho"),
2040                            val_span,
2041                        ));
2042                    }
2043                }
2044                KEY_MIN_VALUE | KEY_MAX_VALUE => {
2045                    if matches!(node, Node::Null) {
2046                        diags.push(e2001(
2047                            format!("Parameter '{}': {} must not be null", name, key),
2048                            name,
2049                            Some(key),
2050                            val_span,
2051                        ));
2052                    } else if !node_matches_param_type(node, "number") {
2053                        diags.push(e2001(
2054                            format!("Parameter '{}': {} must be a number", name, key),
2055                            name,
2056                            Some(key),
2057                            val_span,
2058                        ));
2059                    }
2060                }
2061                KEY_MIN_LENGTH | KEY_MAX_LENGTH => {
2062                    if matches!(node, Node::Null) {
2063                        diags.push(e2001(
2064                            format!("Parameter '{}': {} must not be null", name, key),
2065                            name,
2066                            Some(key),
2067                            val_span,
2068                        ));
2069                    } else if !node_matches_param_type(node, "integer") {
2070                        diags.push(e2001(
2071                            format!("Parameter '{}': {} must be an integer", name, key),
2072                            name,
2073                            Some(key),
2074                            val_span,
2075                        ));
2076                    }
2077                }
2078                KEY_DEFAULT | SECTION_DESCRIPTION | KEY_ALLOWED_PATTERN | KEY_CONSTRAINT_DESCRIPTION => {
2079                    if matches!(node, Node::Null) {
2080                        diags.push(e2001(
2081                            format!("Parameter '{}': {} must not be null", name, key),
2082                            name,
2083                            Some(key),
2084                            val_span,
2085                        ));
2086                    } else if matches!(node, Node::Map(_) | Node::Intrinsic(_)) {
2087                        diags.push(e2001(
2088                            format!("Parameter '{}': {} must be a string", name, key),
2089                            name,
2090                            Some(key),
2091                            val_span,
2092                        ));
2093                    }
2094                }
2095                _ => {}
2096            }
2097        }
2098
2099        let param_type = param_map
2100            .iter()
2101            .find(|(k, _)| k == KEY_TYPE)
2102            .and_then(|(_, v)| ir.arena.as_str(*v))
2103            .unwrap_or(PARAM_TYPE_STRING)
2104            .to_string();
2105
2106        let default = param_map.iter().find(|(k, _)| k == KEY_DEFAULT).and_then(|(_, v)| match ir.arena.node(*v) {
2107            Node::String(s) => Some(s.clone()),
2108            Node::Int(i) => Some(i.to_string()),
2109            Node::Float(f) => Some(f.to_string()),
2110            Node::Bool(b) => Some(b.to_string()),
2111            _ => None,
2112        });
2113
2114        let allowed_values =
2115            param_map.iter().find(|(k, _)| k == KEY_ALLOWED_VALUES).and_then(|(_, v)| ir.arena.as_list(*v)).map(
2116                |items| {
2117                    items
2118                        .iter()
2119                        .filter_map(|r| match ir.arena.node(*r) {
2120                            Node::String(s) => Some(s.clone()),
2121                            Node::Int(i) => Some(i.to_string()),
2122                            Node::Float(f) => Some(f.to_string()),
2123                            Node::Bool(b) => Some(b.to_string()),
2124                            _ => None,
2125                        })
2126                        .collect()
2127                },
2128            );
2129
2130        let description = param_map
2131            .iter()
2132            .find(|(k, _)| k == SECTION_DESCRIPTION)
2133            .and_then(|(_, v)| ir.arena.as_str(*v))
2134            .map(|s| s.to_string());
2135
2136        let no_echo = param_map
2137            .iter()
2138            .find(|(k, _)| k == KEY_NO_ECHO)
2139            .and_then(|(_, v)| match ir.arena.node(*v) {
2140                Node::Bool(b) => Some(*b),
2141                Node::String(s) => Some(s.eq_ignore_ascii_case("true")),
2142                _ => None,
2143            })
2144            .unwrap_or(false);
2145
2146        let allowed_pattern = param_map
2147            .iter()
2148            .find(|(k, _)| k == KEY_ALLOWED_PATTERN)
2149            .and_then(|(_, v)| ir.arena.as_str(*v))
2150            .map(|s| s.to_string());
2151
2152        let min_length =
2153            param_map.iter().find(|(k, _)| k == KEY_MIN_LENGTH).and_then(|(_, v)| match ir.arena.node(*v) {
2154                Node::Int(i) => Some(*i as u64),
2155                Node::String(s) => s.parse().ok(),
2156                _ => None,
2157            });
2158
2159        let max_length =
2160            param_map.iter().find(|(k, _)| k == KEY_MAX_LENGTH).and_then(|(_, v)| match ir.arena.node(*v) {
2161                Node::Int(i) => Some(*i as u64),
2162                Node::String(s) => s.parse().ok(),
2163                _ => None,
2164            });
2165
2166        let min_value = param_map.iter().find(|(k, _)| k == KEY_MIN_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2167            Node::Int(i) => Some(*i),
2168            Node::String(s) => s.parse().ok(),
2169            _ => None,
2170        });
2171
2172        let max_value = param_map.iter().find(|(k, _)| k == KEY_MAX_VALUE).and_then(|(_, v)| match ir.arena.node(*v) {
2173            Node::Int(i) => Some(*i),
2174            Node::String(s) => s.parse().ok(),
2175            _ => None,
2176        });
2177
2178        let allowed_pattern_valid = allowed_pattern.as_deref().map(is_service_valid);
2179        let is_comma_delimited = param_type == PARAM_TYPE_COMMA_DELIMITED_LIST || param_type.starts_with("List<");
2180        let default_matches_allowed_pattern = match (&allowed_pattern, &default) {
2181            (Some(pattern), Some(value)) => default_matches_pattern(pattern, value, is_comma_delimited),
2182            _ => None,
2183        };
2184
2185        params.insert(
2186            name.clone(),
2187            ParameterInfo {
2188                param_type,
2189                default,
2190                allowed_values,
2191                allowed_pattern,
2192                min_length,
2193                max_length,
2194                min_value,
2195                max_value,
2196                description,
2197                no_echo,
2198                allowed_pattern_valid,
2199                default_matches_allowed_pattern,
2200            },
2201        );
2202    }
2203    let with_allowed = params.values().filter(|p| p.allowed_values.is_some()).count();
2204    let with_defaults = params.values().filter(|p| p.default.is_some()).count();
2205    debug!(
2206        "Extracted {} parameters ({} with AllowedValues, {} with Default)",
2207        params.len(),
2208        with_allowed,
2209        with_defaults
2210    );
2211    (params, diags)
2212}
2213
2214pub fn extract_mappings(ir: &TemplateIR) -> (MappingData, Vec<ParseDefect>) {
2215    let mut mappings = MappingData::new();
2216    let mut diagnostics = Vec::new();
2217    if ir.mappings == NULL_REF {
2218        return (mappings, diagnostics);
2219    }
2220    let Some(entries) = ir.arena.as_map(ir.mappings) else {
2221        return (mappings, diagnostics);
2222    };
2223    for (map_name, map_ref) in entries {
2224        let Some(level1) = ir.arena.as_map(*map_ref) else {
2225            diagnostics.push(crate::make_parse_defect_at(
2226                "F0017",
2227                format!("Mapping '{}' must be a map, not a scalar value", map_name),
2228                ir.arena.span(*map_ref),
2229                &format!("Mappings/{}", map_name),
2230            ));
2231            continue;
2232        };
2233        let mut l1_map = HashMap::new();
2234        for (k1, k1_ref) in level1 {
2235            let Some(level2) = ir.arena.as_map(*k1_ref) else {
2236                diagnostics.push(crate::make_parse_defect_at(
2237                    "F0017",
2238                    format!("Mapping '{}' second level key '{}' must be a map", map_name, k1),
2239                    ir.arena.span(*k1_ref),
2240                    &format!("Mappings/{}/{}", map_name, k1),
2241                ));
2242                continue;
2243            };
2244            let mut l2_map = HashMap::new();
2245            for (k2, k2_ref) in level2 {
2246                let val = node_to_json(&ir.arena, *k2_ref);
2247                l2_map.insert(k2.clone(), val);
2248            }
2249            l1_map.insert(k1.clone(), l2_map);
2250        }
2251        mappings.insert(map_name.clone(), l1_map);
2252    }
2253    let total_entries: usize = mappings.values().map(|l1| l1.values().map(|l2| l2.len()).sum::<usize>()).sum();
2254    debug!("Extracted {} mappings with {} total leaf entries", mappings.len(), total_entries);
2255    (mappings, diagnostics)
2256}
2257
2258pub fn node_to_json(arena: &Arena, node_ref: NodeRef) -> serde_json::Value {
2259    if node_ref == NULL_REF {
2260        return serde_json::Value::Null;
2261    }
2262    match arena.node(node_ref) {
2263        Node::Null => serde_json::Value::Null,
2264        Node::Bool(b) => serde_json::Value::Bool(*b),
2265        Node::Int(i) => serde_json::json!(*i),
2266        Node::Float(f) => serde_json::json!(*f),
2267        Node::String(s) => serde_json::Value::String(s.clone()),
2268        Node::List(items) => serde_json::Value::Array(items.iter().map(|r| node_to_json(arena, *r)).collect()),
2269        Node::Map(entries) => {
2270            let mut map = serde_json::Map::new();
2271            for (k, v) in entries {
2272                map.insert(k.clone(), node_to_json(arena, *v));
2273            }
2274            serde_json::Value::Object(map)
2275        }
2276        Node::Intrinsic(intrinsic) => {
2277            serde_json::json!({MARKER_INTRINSIC: intrinsic_name(intrinsic)})
2278        }
2279    }
2280}
2281
2282fn intrinsic_name(intrinsic: &IntrinsicFn) -> &'static str {
2283    match intrinsic {
2284        IntrinsicFn::Ref(_) => TAG_REF,
2285        IntrinsicFn::GetAtt(_, _) => TAG_GET_ATT,
2286        IntrinsicFn::If(_, _, _) => TAG_IF,
2287        IntrinsicFn::IfExpr(_, _, _) => TAG_IF_EXPR,
2288        IntrinsicFn::FindInMap(_, _, _, _) => TAG_FIND_IN_MAP,
2289        IntrinsicFn::Sub(_, _) => TAG_SUB,
2290        IntrinsicFn::Join(_, _) => TAG_JOIN,
2291        IntrinsicFn::Select(_, _) => TAG_SELECT,
2292        IntrinsicFn::Split(_, _) => TAG_SPLIT,
2293        IntrinsicFn::Base64(_) => TAG_BASE64,
2294        IntrinsicFn::ImportValue(_) => TAG_IMPORT_VALUE,
2295        IntrinsicFn::GetStackOutput(_) => TAG_GET_STACK_OUTPUT,
2296        IntrinsicFn::Transform(_, _) => TAG_TRANSFORM,
2297        IntrinsicFn::GetAZs(_) => TAG_GET_AZS,
2298        IntrinsicFn::Cidr(_, _, _) => TAG_CIDR,
2299        IntrinsicFn::And(_) => TAG_AND,
2300        IntrinsicFn::Or(_) => TAG_OR,
2301        IntrinsicFn::Not(_) => TAG_NOT,
2302        IntrinsicFn::Equals(_, _) => TAG_EQUALS,
2303        IntrinsicFn::ToJsonString(_) => TAG_TO_JSON_STRING,
2304        IntrinsicFn::Length(_) => TAG_LENGTH,
2305        IntrinsicFn::ForEach(_, _, _, _) => TAG_FOR_EACH,
2306        IntrinsicFn::ValueOf(_, _) => TAG_VALUE_OF,
2307        IntrinsicFn::ValueOfAll(_, _) => TAG_VALUE_OF_ALL,
2308        IntrinsicFn::RefAll(_) => TAG_REF_ALL,
2309        IntrinsicFn::Contains(_, _) => TAG_CONTAINS,
2310        IntrinsicFn::EachMemberEquals(_, _) => TAG_EACH_MEMBER_EQUALS,
2311        IntrinsicFn::EachMemberIn(_, _) => TAG_EACH_MEMBER_IN,
2312    }
2313}
2314
2315#[cfg(test)]
2316mod tests {
2317    use super::*;
2318    use crate::parser;
2319
2320    fn concrete_property<'a>(
2321        model: &'a crate::model::SemanticModel,
2322        resource_id: &str,
2323        property_path: &str,
2324    ) -> &'a serde_json::Value {
2325        match model.resolve(resource_id, property_path) {
2326            Some(ResolvedValue::Concrete { value }) => value,
2327            other => panic!("Expected concrete value at {resource_id}.{property_path}, got {other:?}"),
2328        }
2329    }
2330
2331    #[test]
2332    fn param_number_whole_value_resolves_to_integer() {
2333        // A whole-number Number parameter must resolve to a JSON integer, not a
2334        // float - 30.0 fails integer enum comparisons and renders as '30.0'.
2335        assert_eq!(param_string_to_json("30", "Number"), serde_json::json!(30));
2336        assert!(param_string_to_json("30", "Number").is_i64(), "whole number must be an integer");
2337        assert_eq!(param_string_to_json("1.5", "Number"), serde_json::json!(1.5));
2338        assert_eq!(param_string_to_json("not-a-number", "Number"), serde_json::json!("not-a-number"));
2339    }
2340
2341    #[test]
2342    fn resolve_ref_param_with_allowed_values() {
2343        let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2344        let ir = parser::parse(input.as_bytes()).unwrap();
2345        let (params, _) = extract_parameters(&ir);
2346        let resource_ids: HashSet<String> = ["R".to_string()].into_iter().collect();
2347        let (mappings, _) = extract_mappings(&ir);
2348        let no_param_overrides = HashMap::new();
2349        let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2350        let mut resolver =
2351            Resolver::new(&ir.arena, &params, &mappings, resource_ids, &no_param_overrides, &no_pseudo_overrides);
2352        let res_map = ir.arena.as_map(ir.resources).unwrap();
2353        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2354        let v_ref = ir.arena.map_get(props, "V").unwrap();
2355        let result = resolver.resolve_node(v_ref);
2356        match result {
2357            ResolvedValue::Enum { variants: vals } => assert_eq!(vals.len(), 2),
2358            other => panic!("Expected Enum, got {:?}", other),
2359        }
2360    }
2361
2362    #[test]
2363    fn resolve_ref_pseudo_params() {
2364        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2365        let ir = parser::parse(input.as_bytes()).unwrap();
2366        let (params, _) = extract_parameters(&ir);
2367        let (mappings, _) = extract_mappings(&ir);
2368        let no_param_overrides = HashMap::new();
2369        let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2370        let mut resolver = Resolver::new(
2371            &ir.arena,
2372            &params,
2373            &mappings,
2374            ["R".to_string()].into_iter().collect(),
2375            &no_param_overrides,
2376            &no_pseudo_overrides,
2377        );
2378        let res_map = ir.arena.as_map(ir.resources).unwrap();
2379        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2380        let v_ref = ir.arena.map_get(props, "V").unwrap();
2381        let result = resolver.resolve_node(v_ref);
2382        match result {
2383            ResolvedValue::Concrete { value: JsonValue(serde_json::Value::String(s)) } => {
2384                assert_eq!(s, DEFAULT_REGION);
2385            }
2386            other => panic!("Expected Concrete(\"us-east-1\"), got {:?}", other),
2387        }
2388    }
2389
2390    #[test]
2391    fn resolve_ref_notification_arns_as_list() {
2392        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::NotificationARNs"}}}}}"#;
2393        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).expect("template parses");
2394        assert_eq!(
2395            concrete_property(&model, "R", "Properties.V"),
2396            &serde_json::json!(["arn:aws:sns:us-east-1:123456789012:notification"]),
2397            "NotificationARNs must keep its CloudFormation list shape"
2398        );
2399    }
2400
2401    #[test]
2402    fn notification_arns_override_preserves_list_shape_through_intrinsics() {
2403        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2404            "Notifications":{"Ref":"AWS::NotificationARNs"},
2405            "First":{"Fn::Select":[0,{"Ref":"AWS::NotificationARNs"}]},
2406            "Joined":{"Fn::Join":["|",{"Ref":"AWS::NotificationARNs"}]}
2407        }}}}"#;
2408        let configured_arn = "arn:aws:sns:us-east-1:123456789012:configured";
2409        let config = crate::model::ParseConfig {
2410            parameters: HashMap::new(),
2411            pseudo_parameters: crate::model::PseudoParameterOverrides {
2412                notification_arns: Some(configured_arn.to_string()),
2413                ..Default::default()
2414            },
2415        };
2416        let model = crate::model::SemanticModel::parse(input.as_bytes(), config).expect("template parses").model;
2417
2418        assert_eq!(concrete_property(&model, "R", "Properties.Notifications"), &serde_json::json!([configured_arn]));
2419        assert_eq!(concrete_property(&model, "R", "Properties.First"), &serde_json::json!(configured_arn));
2420        assert_eq!(concrete_property(&model, "R", "Properties.Joined"), &serde_json::json!(configured_arn));
2421    }
2422
2423    #[test]
2424    fn resolve_if() {
2425        let input = r#"{"Conditions":{"C":{"Fn::Equals":["a","a"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::If":["C",100,20]}}}}}"#;
2426        let ir = parser::parse(input.as_bytes()).unwrap();
2427        let (params, _) = extract_parameters(&ir);
2428        let (mappings, _) = extract_mappings(&ir);
2429        let no_param_overrides = HashMap::new();
2430        let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2431        let mut resolver = Resolver::new(
2432            &ir.arena,
2433            &params,
2434            &mappings,
2435            ["R".to_string()].into_iter().collect(),
2436            &no_param_overrides,
2437            &no_pseudo_overrides,
2438        );
2439        let res_map = ir.arena.as_map(ir.resources).unwrap();
2440        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2441        let v_ref = ir.arena.map_get(props, "V").unwrap();
2442        let result = resolver.resolve_node(v_ref);
2443        assert!(matches!(result, ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ }));
2444    }
2445
2446    #[test]
2447    fn resolve_join_concrete() {
2448        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["a","b","c"]]}}}}}"#;
2449        let ir = parser::parse(input.as_bytes()).unwrap();
2450        let (params, _) = extract_parameters(&ir);
2451        let (mappings, _) = extract_mappings(&ir);
2452        let no_param_overrides = HashMap::new();
2453        let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2454        let mut resolver = Resolver::new(
2455            &ir.arena,
2456            &params,
2457            &mappings,
2458            ["R".to_string()].into_iter().collect(),
2459            &no_param_overrides,
2460            &no_pseudo_overrides,
2461        );
2462        let res_map = ir.arena.as_map(ir.resources).unwrap();
2463        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2464        let v_ref = ir.arena.map_get(props, "V").unwrap();
2465        let result = resolver.resolve_node(v_ref);
2466        match result {
2467            ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "a-b-c"),
2468            other => panic!("Expected Concrete, got {:?}", other),
2469        }
2470    }
2471
2472    #[test]
2473    fn resolve_ref_param_override_bypasses_allowed_values() {
2474        let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
2475        let ir = parser::parse(input.as_bytes()).unwrap();
2476        let (params, _) = extract_parameters(&ir);
2477        let (mappings, _) = extract_mappings(&ir);
2478        let param_overrides = [("Env".to_string(), "staging".to_string())].into_iter().collect();
2479        let no_pseudo_overrides = crate::model::PseudoParameterOverrides::default();
2480        let mut resolver = Resolver::new(
2481            &ir.arena,
2482            &params,
2483            &mappings,
2484            ["R".to_string()].into_iter().collect(),
2485            &param_overrides,
2486            &no_pseudo_overrides,
2487        );
2488        let res_map = ir.arena.as_map(ir.resources).unwrap();
2489        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2490        let v_ref = ir.arena.map_get(props, "V").unwrap();
2491        let result = resolver.resolve_node(v_ref);
2492        match result {
2493            ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "staging"),
2494            other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
2495        }
2496    }
2497
2498    #[test]
2499    fn resolve_ref_pseudo_param_override() {
2500        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
2501        let ir = parser::parse(input.as_bytes()).unwrap();
2502        let (params, _) = extract_parameters(&ir);
2503        let (mappings, _) = extract_mappings(&ir);
2504        let no_param_overrides = HashMap::new();
2505        let pseudo_overrides =
2506            crate::model::PseudoParameterOverrides { region: Some("us-west-2".to_string()), ..Default::default() };
2507        let mut resolver = Resolver::new(
2508            &ir.arena,
2509            &params,
2510            &mappings,
2511            ["R".to_string()].into_iter().collect(),
2512            &no_param_overrides,
2513            &pseudo_overrides,
2514        );
2515        let res_map = ir.arena.as_map(ir.resources).unwrap();
2516        let props = ir.arena.map_get(res_map[0].1, "Properties").unwrap();
2517        let v_ref = ir.arena.map_get(props, "V").unwrap();
2518        let result = resolver.resolve_node(v_ref);
2519        match result {
2520            ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "us-west-2"),
2521            other => panic!("Expected Concrete(\"us-west-2\"), got {:?}", other),
2522        }
2523    }
2524
2525    #[test]
2526    fn resolve_sub_concrete_interpolation() {
2527        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"prefix-${AWS::Region}-suffix"}}}}}"#;
2528        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2529        match model.resolve("R", "Properties.V") {
2530            Some(ResolvedValue::Concrete { value: v }) => {
2531                assert_eq!(v.as_str().unwrap(), "prefix-us-east-1-suffix");
2532            }
2533            other => panic!("Expected Concrete, got {:?}", other),
2534        }
2535    }
2536
2537    #[test]
2538    fn resolve_sub_with_enum_produces_enum() {
2539        let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"app-${Env}"}}}}}"#;
2540        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2541        match model.resolve("R", "Properties.V") {
2542            Some(ResolvedValue::Enum { variants: vals }) => {
2543                assert_eq!(vals.len(), 2);
2544                let strs: Vec<String> = vals
2545                    .iter()
2546                    .filter_map(|v| match v {
2547                        ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2548                        _ => None,
2549                    })
2550                    .collect();
2551                assert!(strs.contains(&"app-dev".to_string()));
2552                assert!(strs.contains(&"app-prod".to_string()));
2553            }
2554            other => panic!("Expected Enum, got {:?}", other),
2555        }
2556    }
2557
2558    #[test]
2559    fn resolve_sub_no_variables_is_redundant() {
2560        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"no-vars-here"}}}}}"#;
2561        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2562        let r = model.resource("R").unwrap();
2563        assert!(!r.diagnostics.redundant_subs.is_empty());
2564    }
2565
2566    #[test]
2567    fn resolve_sub_single_variable_is_simple() {
2568        let input = r#"{"Parameters":{"P":{"Type":"String","Default":"val"}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${P}"}}}}}"#;
2569        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2570        let r = model.resource("R").unwrap();
2571        assert!(!r.diagnostics.simple_subs.is_empty());
2572    }
2573
2574    #[test]
2575    fn resolve_split_concrete() {
2576        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Split":[",","a,b,c"]}}}}}"#;
2577        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2578        match model.resolve("R", "Properties.V") {
2579            Some(ResolvedValue::Concrete { value: v }) => {
2580                let arr = v.as_array().unwrap();
2581                assert_eq!(arr.len(), 3);
2582                assert_eq!(arr[0], "a");
2583                assert_eq!(arr[1], "b");
2584                assert_eq!(arr[2], "c");
2585            }
2586            other => panic!("Expected Concrete array, got {:?}", other),
2587        }
2588    }
2589
2590    #[test]
2591    fn resolve_base64_concrete() {
2592        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Base64":"hello"}}}}}"#;
2593        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2594        match model.resolve("R", "Properties.V") {
2595            Some(ResolvedValue::Concrete { value: v }) => {
2596                assert_eq!(v.as_str().unwrap(), "aGVsbG8=");
2597            }
2598            other => panic!("Expected Concrete base64, got {:?}", other),
2599        }
2600    }
2601
2602    #[test]
2603    fn resolve_select_concrete() {
2604        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","b","c"]]}}}}}"#;
2605        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2606        match model.resolve("R", "Properties.V") {
2607            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "b"),
2608            other => panic!("Expected Concrete(\"b\"), got {:?}", other),
2609        }
2610    }
2611
2612    #[test]
2613    fn resolve_select_out_of_bounds() {
2614        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a","b"]]}}}}}"#;
2615        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2616        match model.resolve("R", "Properties.V") {
2617            Some(ResolvedValue::Dynamic { reason: msg }) => assert!(msg.contains("out of bounds")),
2618            other => panic!("Expected Dynamic, got {:?}", other),
2619        }
2620    }
2621
2622    #[test]
2623    fn resolve_getazs_default_region() {
2624        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":""}}}}}"#;
2625        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2626        match model.resolve("R", "Properties.V") {
2627            Some(ResolvedValue::Concrete { value: v }) => {
2628                let arr = v.as_array().unwrap();
2629                assert!(arr.len() >= 3);
2630                assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
2631            }
2632            other => panic!("Expected Concrete AZ array, got {:?}", other),
2633        }
2634    }
2635
2636    #[test]
2637    fn resolve_to_json_string() {
2638        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"key":"value"}}}}}}"#;
2639        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2640        match model.resolve("R", "Properties.V") {
2641            Some(ResolvedValue::Concrete { value: v }) => {
2642                let s = v.as_str().unwrap();
2643                assert!(s.contains("key"));
2644                assert!(s.contains("value"));
2645            }
2646            other => panic!("Expected Concrete JSON string, got {:?}", other),
2647        }
2648    }
2649
2650    #[test]
2651    fn resolve_length_array() {
2652        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Length":["a","b","c"]}}}}}"#;
2653        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2654        match model.resolve("R", "Properties.V") {
2655            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_i64().unwrap(), 3),
2656            other => panic!("Expected Concrete(3), got {:?}", other),
2657        }
2658    }
2659
2660    #[test]
2661    fn resolve_cidr_concrete() {
2662        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":["10.0.0.0/16","2","8"]}}}}}"#;
2663        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2664        match model.resolve("R", "Properties.V") {
2665            Some(ResolvedValue::Concrete { value: v }) => {
2666                let arr = v.as_array().unwrap();
2667                assert_eq!(arr.len(), 2);
2668                assert!(arr[0].as_str().unwrap().contains("/24"));
2669            }
2670            other => panic!("Expected Concrete CIDR array, got {:?}", other),
2671        }
2672    }
2673
2674    #[test]
2675    fn resolve_import_value_is_typed_dynamic() {
2676        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"StackExport"}}}}}"#;
2677        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2678        match model.resolve("R", "Properties.V") {
2679            Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2680                assert_eq!(t, "String");
2681                // The export name is carried so distinct imports stay distinct.
2682                assert!(reason.contains("StackExport"), "reason should carry the export name, got {reason:?}");
2683            }
2684            other => panic!("Expected TypedDynamic, got {:?}", other),
2685        }
2686    }
2687
2688    #[test]
2689    fn resolve_import_value_distinct_exports_differ() {
2690        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2691            "A":{"Fn::ImportValue":"ExportOne"},
2692            "B":{"Fn::ImportValue":"ExportTwo"}
2693        }}}}"#;
2694        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2695        let a = model.resolve("R", "Properties.A").cloned();
2696        let b = model.resolve("R", "Properties.B").cloned();
2697        match (a, b) {
2698            (
2699                Some(ResolvedValue::TypedDynamic { reason: ra, .. }),
2700                Some(ResolvedValue::TypedDynamic { reason: rb, .. }),
2701            ) => assert_ne!(ra, rb, "distinct exports must produce distinct symbolic values"),
2702            other => panic!("Expected two TypedDynamic, got {:?}", other),
2703        }
2704    }
2705
2706    #[test]
2707    fn resolve_get_stack_output_carries_source_identity() {
2708        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetStackOutput":{
2709            "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2710        }}}}}}"#;
2711        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2712        match model.resolve("R", "Properties.V") {
2713            Some(ResolvedValue::Dynamic { reason }) => {
2714                // The source is carried so distinct outputs stay distinct.
2715                for field in ["VpcStack", "ap-northeast-1", "PublicSubnetOne"] {
2716                    assert!(reason.contains(field), "reason should carry {field}, got {reason:?}");
2717                }
2718            }
2719            other => panic!("Expected Dynamic, got {:?}", other),
2720        }
2721    }
2722
2723    #[test]
2724    fn resolve_get_stack_output_distinct_sources_differ() {
2725        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{
2726            "Baseline":{"Fn::GetStackOutput":{
2727                "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2728            }},
2729            "OtherStack":{"Fn::GetStackOutput":{
2730                "StackName":"OtherVpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne"
2731            }},
2732            "OtherRegion":{"Fn::GetStackOutput":{
2733                "StackName":"VpcStack","Region":"us-east-1","OutputName":"PublicSubnetOne"
2734            }},
2735            "OtherOutput":{"Fn::GetStackOutput":{
2736                "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetTwo"
2737            }},
2738            "Reordered":{"Fn::GetStackOutput":{
2739                "OutputName":"PublicSubnetOne","StackName":"VpcStack","Region":"ap-northeast-1"
2740            }},
2741            "OtherRole":{"Fn::GetStackOutput":{
2742                "StackName":"VpcStack","Region":"ap-northeast-1","OutputName":"PublicSubnetOne",
2743                "RoleArn":"arn:aws:iam::444455556666:role/Lookup"
2744            }}
2745        }}}}"#;
2746        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2747        let source_of = |property: &str| match model.resolve("R", &format!("Properties.{property}")) {
2748            Some(ResolvedValue::Dynamic { reason }) => reason.clone(),
2749            other => panic!("Expected Dynamic for {property}, got {other:?}"),
2750        };
2751
2752        let baseline = source_of("Baseline");
2753        for distinct in ["OtherStack", "OtherRegion", "OtherOutput"] {
2754            assert_ne!(baseline, source_of(distinct), "{distinct} must not collapse onto the baseline output");
2755        }
2756        assert_eq!(baseline, source_of("Reordered"), "argument order must not make the same output distinct");
2757        // RoleArn selects nothing about which output is read, so a call that differs
2758        // only there is still the same value and must stay a duplicate.
2759        assert_eq!(baseline, source_of("OtherRole"), "RoleArn must not make the same output distinct");
2760    }
2761
2762    #[test]
2763    fn embedded_dynamic_reference_is_opaque() {
2764        // A dynamic reference embedded mid-string resolves at deploy time, so it
2765        // must become opaque even though it does not start with `{{resolve:`.
2766        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"prefix-{{resolve:ssm:/my/param}}"}}}}"#;
2767        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2768        match model.resolve("R", "Properties.V") {
2769            Some(ResolvedValue::TypedDynamic { reason, param_type: t }) => {
2770                assert_eq!(t, "String");
2771                assert!(
2772                    reason.contains("{{resolve:ssm:/my/param}}"),
2773                    "reason should carry the literal, got {reason:?}"
2774                );
2775            }
2776            other => panic!("Expected TypedDynamic, got {:?}", other),
2777        }
2778    }
2779
2780    #[test]
2781    fn sub_producing_dynamic_reference_is_opaque() {
2782        // Fn::Sub passes `{{resolve:...}}` through literally; the concatenated
2783        // result still embeds a dynamic reference and must be opaque.
2784        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"${AWS::Region}-{{resolve:ssm:/my/param}}"}}}}}"#;
2785        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2786        match model.resolve("R", "Properties.V") {
2787            Some(ResolvedValue::TypedDynamic { reason, .. }) => {
2788                assert!(
2789                    reason.contains("{{resolve:ssm:/my/param}}"),
2790                    "reason should carry the literal, got {reason:?}"
2791                );
2792            }
2793            other => panic!("Expected TypedDynamic, got {:?}", other),
2794        }
2795    }
2796
2797    #[test]
2798    fn join_producing_dynamic_reference_is_opaque() {
2799        // Fn::Join over a list whose element is a dynamic reference: the element
2800        // is made opaque before the join runs, so the join sees a non-concrete
2801        // argument and yields an opaque value the value-format rules skip.
2802        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2803        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2804        let resolved = model.resolve("R", "Properties.V").expect("V should resolve");
2805        assert!(
2806            crate::resolved_value::contains_dynamic_resolved(resolved),
2807            "Fn::Join embedding a dynamic reference must be opaque, got {resolved:?}"
2808        );
2809        assert!(
2810            !matches!(resolved, ResolvedValue::Concrete { .. }),
2811            "the join result must not be a concrete literal, got {resolved:?}"
2812        );
2813    }
2814
2815    #[test]
2816    fn select_producing_dynamic_reference_is_opaque() {
2817        // Fn::Select picking a dynamic-reference element must be opaque.
2818        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Select":[1,["a","{{resolve:ssm:/my/param}}"]]}}}}}"#;
2819        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2820        match model.resolve("R", "Properties.V") {
2821            Some(ResolvedValue::TypedDynamic { reason, .. }) => {
2822                assert!(
2823                    reason.contains("{{resolve:ssm:/my/param}}"),
2824                    "reason should carry the literal, got {reason:?}"
2825                );
2826            }
2827            other => panic!("Expected TypedDynamic, got {:?}", other),
2828        }
2829    }
2830
2831    #[test]
2832    fn nested_dynamic_reference_stays_inside_list() {
2833        // A dynamic reference nested in a list element becomes opaque without
2834        // collapsing the parent list, so the list is preserved with an opaque entry.
2835        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":["plain","{{resolve:ssm:/my/param}}"]}}}}"#;
2836        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2837        match model.resolve("R", "Properties.V") {
2838            Some(ResolvedValue::List { items }) => {
2839                assert_eq!(items.len(), 2);
2840                assert!(matches!(items[0], ResolvedValue::Concrete { .. }));
2841                assert!(
2842                    matches!(items[1], ResolvedValue::TypedDynamic { .. }),
2843                    "the embedded reference element must be opaque, got {:?}",
2844                    items[1]
2845                );
2846            }
2847            other => panic!("Expected List with an opaque element, got {:?}", other),
2848        }
2849    }
2850
2851    #[test]
2852    fn malformed_dynamic_reference_with_space_stays_concrete() {
2853        // `{{ resolve:...}}` (a space after `{{`) is not a valid dynamic reference
2854        // and CloudFormation will not resolve it, so it must stay concrete for the
2855        // spaces-in-dynamic-reference warning to inspect.
2856        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"{{ resolve:ssm:/my/param}}"}}}}"#;
2857        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2858        match model.resolve("R", "Properties.V") {
2859            Some(ResolvedValue::Concrete { value: v }) => {
2860                assert_eq!(v.as_str().unwrap(), "{{ resolve:ssm:/my/param}}");
2861            }
2862            other => panic!("Expected Concrete, got {:?}", other),
2863        }
2864    }
2865
2866    #[test]
2867    fn comma_delimited_list_default_resolves_to_array() {
2868        let input = r#"{"Parameters":{"P":{"Type":"CommaDelimitedList","Default":"GET, PUT ,POST"}},
2869            "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"P"}}}}}"#;
2870        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2871        match model.resolve("R", "Properties.V") {
2872            Some(ResolvedValue::Concrete { value }) => {
2873                assert_eq!(
2874                    **value,
2875                    serde_json::json!(["GET", "PUT", "POST"]),
2876                    "default must split into a trimmed array"
2877                );
2878            }
2879            other => panic!("Expected Concrete array, got {:?}", other),
2880        }
2881    }
2882
2883    #[test]
2884    fn resolve_no_value_is_null() {
2885        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::NoValue"}}}}}"#;
2886        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2887        match model.resolve("R", "Properties.V") {
2888            Some(ResolvedValue::Concrete { value: v }) => assert!(v.is_null()),
2889            other => panic!("Expected Concrete(null), got {:?}", other),
2890        }
2891        assert!(model.is_from_intrinsic("R", "Properties.V"));
2892    }
2893
2894    #[test]
2895    fn extract_parameters_with_constraints() {
2896        let input = r#"{"Parameters":{"P":{"Type":"String","MinLength":1,"MaxLength":100,"AllowedPattern":"^[a-z]+$","Default":"abc","NoEcho":true}},"Resources":{"R":{"Type":"T"}}}"#;
2897        let ir = parser::parse(input.as_bytes()).unwrap();
2898        let (params, _) = extract_parameters(&ir);
2899        let p = &params["P"];
2900        assert_eq!(p.param_type, "String");
2901        assert_eq!(p.default.as_deref(), Some("abc"));
2902        assert_eq!(p.min_length, Some(1));
2903        assert_eq!(p.max_length, Some(100));
2904        assert_eq!(p.allowed_pattern.as_deref(), Some("^[a-z]+$"));
2905        assert!(p.no_echo);
2906    }
2907
2908    #[test]
2909    fn extract_parameters_number_type() {
2910        let input = r#"{"Parameters":{"N":{"Type":"Number","Default":"42","MinValue":0,"MaxValue":100}},"Resources":{"R":{"Type":"T"}}}"#;
2911        let ir = parser::parse(input.as_bytes()).unwrap();
2912        let (params, _) = extract_parameters(&ir);
2913        let n = &params["N"];
2914        assert_eq!(n.param_type, "Number");
2915        assert_eq!(n.default.as_deref(), Some("42"));
2916        assert_eq!(n.min_value, Some(0));
2917        assert_eq!(n.max_value, Some(100));
2918    }
2919
2920    #[test]
2921    fn extract_mappings_three_level() {
2922        let input = r#"{"Mappings":{"M":{"k1":{"k2":"val"}}},"Resources":{"R":{"Type":"T"}}}"#;
2923        let ir = parser::parse(input.as_bytes()).unwrap();
2924        let (mappings, diags) = extract_mappings(&ir);
2925        assert!(diags.is_empty());
2926        assert_eq!(mappings["M"]["k1"]["k2"], serde_json::json!("val"));
2927    }
2928
2929    #[test]
2930    fn extract_mappings_invalid_structure_produces_diagnostic() {
2931        let input = r#"{"Mappings":{"M":"not-a-map"},"Resources":{"R":{"Type":"T"}}}"#;
2932        let ir = parser::parse(input.as_bytes()).unwrap();
2933        let (_, diags) = extract_mappings(&ir);
2934        assert!(!diags.is_empty());
2935        assert_eq!(diags[0].rule_id, "F0017");
2936    }
2937
2938    #[test]
2939    fn resolve_findinmap_with_enum_key() {
2940        let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Mappings":{"M":{"dev":{"v":"d"},"prod":{"v":"p"}}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M",{"Ref":"Env"},"v"]}}}}}"#;
2941        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2942        match model.resolve("R", "Properties.V") {
2943            Some(ResolvedValue::Enum { variants: vals }) => {
2944                assert_eq!(vals.len(), 2);
2945                let strs: Vec<String> = vals
2946                    .iter()
2947                    .filter_map(|v| match v {
2948                        ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2949                        _ => None,
2950                    })
2951                    .collect();
2952                assert!(strs.contains(&"d".to_string()));
2953                assert!(strs.contains(&"p".to_string()));
2954            }
2955            other => panic!("Expected Enum, got {:?}", other),
2956        }
2957    }
2958
2959    #[test]
2960    fn resolve_hardcoded_partition_arn_tracked() {
2961        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:aws:s3:::my-bucket"}}}}}"#;
2962        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2963        let r = model.resource("R").unwrap();
2964        assert!(!r.diagnostics.hardcoded_partition_arns.is_empty());
2965    }
2966
2967    #[test]
2968    fn resolve_getatt_produces_reference() {
2969        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},"Other":{"Type":"T2"}}}"#;
2970        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2971        match model.resolve("R", "Properties.V") {
2972            Some(ResolvedValue::Reference { target: t, kind: RefKind::GetAtt { attr: a } }) => {
2973                assert_eq!(t, "Other");
2974                assert_eq!(a, "Arn");
2975            }
2976            other => panic!("Expected Reference(GetAtt), got {:?}", other),
2977        }
2978    }
2979
2980    #[test]
2981    fn findinmap_concrete_first_key_enum_second_key_produces_enum() {
2982        let input = r#"{
2983            "Parameters":{"K2":{"Type":"String","AllowedValues":["a","b"]}},
2984            "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
2985            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Ref":"K2"}]}}}}
2986        }"#;
2987        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
2988        match model.resolve("R", "Properties.V") {
2989            Some(ResolvedValue::Enum { variants }) => {
2990                assert_eq!(variants.len(), 2);
2991                let strs: Vec<String> = variants
2992                    .iter()
2993                    .filter_map(|v| match v {
2994                        ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
2995                        _ => None,
2996                    })
2997                    .collect();
2998                assert!(strs.contains(&"va".to_string()));
2999                assert!(strs.contains(&"vb".to_string()));
3000            }
3001            other => panic!("Expected Enum, got {:?}", other),
3002        }
3003    }
3004
3005    #[test]
3006    fn findinmap_enum_first_key_enum_second_key_produces_flattened_enum() {
3007        let input = r#"{
3008            "Parameters":{
3009                "K1":{"Type":"String","AllowedValues":["r1","r2"]},
3010                "K2":{"Type":"String","AllowedValues":["a","b"]}
3011            },
3012            "Mappings":{"M":{"r1":{"a":"r1a","b":"r1b"},"r2":{"a":"r2a","b":"r2b"}}},
3013            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M",{"Ref":"K1"},{"Ref":"K2"}]}}}}
3014        }"#;
3015        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3016        match model.resolve("R", "Properties.V") {
3017            Some(ResolvedValue::Enum { variants }) => {
3018                // 2 k1 variants × 2 k2 variants = 4 total
3019                assert_eq!(variants.len(), 4);
3020                let strs: Vec<String> = variants
3021                    .iter()
3022                    .filter_map(|v| match v {
3023                        ResolvedValue::Concrete { value: j } => j.as_str().map(|s| s.to_string()),
3024                        _ => None,
3025                    })
3026                    .collect();
3027                assert!(strs.contains(&"r1a".to_string()));
3028                assert!(strs.contains(&"r1b".to_string()));
3029                assert!(strs.contains(&"r2a".to_string()));
3030                assert!(strs.contains(&"r2b".to_string()));
3031            }
3032            other => panic!("Expected Enum with 4 variants, got {:?}", other),
3033        }
3034    }
3035
3036    #[test]
3037    fn findinmap_conditional_second_key_produces_conditional() {
3038        let input = r#"{
3039            "Conditions":{"C":{"Fn::Equals":["a","a"]}},
3040            "Mappings":{"M":{"x":{"a":"va","b":"vb"}}},
3041            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["M","x",{"Fn::If":["C","a","b"]}]}}}}
3042        }"#;
3043        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3044        match model.resolve("R", "Properties.V") {
3045            Some(ResolvedValue::Conditional { .. }) => {}
3046            other => panic!("Expected Conditional, got {:?}", other),
3047        }
3048    }
3049
3050    #[test]
3051    fn select_from_list_with_mixed_items_returns_element() {
3052        // When the list contains a Ref (non-concrete), it becomes a List variant.
3053        // Select index 0 should return the concrete element.
3054        let input = r#"{
3055            "Resources":{
3056                "Other":{"Type":"T2"},
3057                "R":{"Type":"T","Properties":{"V":{"Fn::Select":[0,[{"Fn::GetAZs":"us-east-1"},{"Ref":"Other"}]]}}}
3058            }
3059        }"#;
3060        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3061        match model.resolve("R", "Properties.V") {
3062            Some(ResolvedValue::Concrete { value: v }) => {
3063                let arr = v.as_array().unwrap();
3064                assert!(arr[0].as_str().unwrap().starts_with("us-east-1"));
3065            }
3066            other => panic!("Expected Concrete AZ array from Select on List, got {:?}", other),
3067        }
3068    }
3069
3070    #[test]
3071    fn select_from_list_out_of_bounds_returns_dynamic() {
3072        let input = r#"{
3073            "Resources":{
3074                "Other":{"Type":"T2"},
3075                "R":{"Type":"T","Properties":{"V":{"Fn::Select":[5,["a",{"Ref":"Other"}]]}}}
3076            }
3077        }"#;
3078        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3079        match model.resolve("R", "Properties.V") {
3080            Some(ResolvedValue::Dynamic { reason }) => {
3081                assert!(reason.contains("out of bounds"));
3082            }
3083            other => panic!("Expected Dynamic out of bounds, got {:?}", other),
3084        }
3085    }
3086
3087    #[test]
3088    fn equals_two_concrete_strings_returns_true() {
3089        let input = r#"{
3090            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["hello","hello"]}}}}
3091        }"#;
3092        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3093        match model.resolve("R", "Properties.V") {
3094            Some(ResolvedValue::Concrete { value: v }) => {
3095                assert_eq!(v.as_bool(), Some(true));
3096            }
3097            other => panic!("Expected Concrete(true), got {:?}", other),
3098        }
3099    }
3100
3101    #[test]
3102    fn equals_two_different_strings_returns_false() {
3103        let input = r#"{
3104            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":["a","b"]}}}}
3105        }"#;
3106        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3107        match model.resolve("R", "Properties.V") {
3108            Some(ResolvedValue::Concrete { value: v }) => {
3109                assert_eq!(v.as_bool(), Some(false));
3110            }
3111            other => panic!("Expected Concrete(false), got {:?}", other),
3112        }
3113    }
3114
3115    #[test]
3116    fn equals_enum_against_concrete_produces_enum_of_bools() {
3117        let input = r#"{
3118            "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3119            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Equals":[{"Ref":"Env"},"prod"]}}}}
3120        }"#;
3121        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3122        match model.resolve("R", "Properties.V") {
3123            Some(ResolvedValue::Enum { variants }) => {
3124                assert_eq!(variants.len(), 2);
3125                let bools: Vec<bool> = variants
3126                    .iter()
3127                    .filter_map(|v| match v {
3128                        ResolvedValue::Concrete { value: j } => j.as_bool(),
3129                        _ => None,
3130                    })
3131                    .collect();
3132                assert!(bools.contains(&true));
3133                assert!(bools.contains(&false));
3134            }
3135            other => panic!("Expected Enum of bools, got {:?}", other),
3136        }
3137    }
3138
3139    #[test]
3140    fn and_all_true_returns_true() {
3141        let input = r#"{
3142            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["b","b"]}]}}}}
3143        }"#;
3144        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3145        match model.resolve("R", "Properties.V") {
3146            Some(ResolvedValue::Concrete { value: v }) => {
3147                assert_eq!(v.as_bool(), Some(true));
3148            }
3149            other => panic!("Expected Concrete(true), got {:?}", other),
3150        }
3151    }
3152
3153    #[test]
3154    fn and_one_false_returns_false() {
3155        let input = r#"{
3156            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":["a","b"]}]}}}}
3157        }"#;
3158        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3159        match model.resolve("R", "Properties.V") {
3160            Some(ResolvedValue::Concrete { value: v }) => {
3161                assert_eq!(v.as_bool(), Some(false));
3162            }
3163            other => panic!("Expected Concrete(false), got {:?}", other),
3164        }
3165    }
3166
3167    #[test]
3168    fn or_one_true_returns_true() {
3169        let input = r#"{
3170            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","c"]}]}}}}
3171        }"#;
3172        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3173        match model.resolve("R", "Properties.V") {
3174            Some(ResolvedValue::Concrete { value: v }) => {
3175                assert_eq!(v.as_bool(), Some(true));
3176            }
3177            other => panic!("Expected Concrete(true), got {:?}", other),
3178        }
3179    }
3180
3181    #[test]
3182    fn or_all_false_returns_false() {
3183        let input = r#"{
3184            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Or":[{"Fn::Equals":["a","b"]},{"Fn::Equals":["c","d"]}]}}}}
3185        }"#;
3186        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3187        match model.resolve("R", "Properties.V") {
3188            Some(ResolvedValue::Concrete { value: v }) => {
3189                assert_eq!(v.as_bool(), Some(false));
3190            }
3191            other => panic!("Expected Concrete(false), got {:?}", other),
3192        }
3193    }
3194
3195    #[test]
3196    fn not_true_returns_false() {
3197        let input = r#"{
3198            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Not":[{"Fn::Equals":["a","a"]}]}}}}
3199        }"#;
3200        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3201        match model.resolve("R", "Properties.V") {
3202            Some(ResolvedValue::Concrete { value: v }) => {
3203                assert_eq!(v.as_bool(), Some(false));
3204            }
3205            other => panic!("Expected Concrete(false), got {:?}", other),
3206        }
3207    }
3208
3209    #[test]
3210    fn and_with_dynamic_child_returns_dynamic() {
3211        let input = r#"{
3212            "Resources":{
3213                "Other":{"Type":"T2"},
3214                "R":{"Type":"T","Properties":{"V":{"Fn::And":[{"Fn::Equals":["a","a"]},{"Fn::Equals":[{"Ref":"Other"},"x"]}]}}}
3215            }
3216        }"#;
3217        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3218        match model.resolve("R", "Properties.V") {
3219            Some(ResolvedValue::Dynamic { .. }) => {}
3220            other => panic!("Expected Dynamic, got {:?}", other),
3221        }
3222    }
3223
3224    #[test]
3225    fn sub_enum_plus_reference_produces_enum_with_dynamic_variants() {
3226        let input = r#"{
3227            "Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},
3228            "Resources":{
3229                "Bucket":{"Type":"AWS::S3::Bucket"},
3230                "R":{"Type":"T","Properties":{"V":{"Fn::Sub":"arn:${Env}:${Bucket}"}}}
3231            }
3232        }"#;
3233        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3234        match model.resolve("R", "Properties.V") {
3235            Some(ResolvedValue::Enum { variants }) => {
3236                assert_eq!(variants.len(), 2);
3237                // Both should be Dynamic because ${Bucket} is unresolved
3238                for v in variants {
3239                    match v {
3240                        ResolvedValue::Dynamic { reason } => {
3241                            assert!(reason.starts_with("Sub:"));
3242                        }
3243                        _ => panic!("Expected Dynamic variant, got {:?}", v),
3244                    }
3245                }
3246            }
3247            other => panic!("Expected Enum with Dynamic variants, got {:?}", other),
3248        }
3249    }
3250
3251    #[test]
3252    fn join_list_with_mixed_concrete_and_reference_produces_partial_dynamic() {
3253        let input = r#"{
3254            "Resources":{
3255                "Other":{"Type":"T2"},
3256                "R":{"Type":"T","Properties":{"V":{"Fn::Join":["-",["prefix",{"Ref":"Other"},"suffix"]]}}}
3257            }
3258        }"#;
3259        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3260        match model.resolve("R", "Properties.V") {
3261            Some(ResolvedValue::Dynamic { reason }) => {
3262                assert!(reason.starts_with("Join:"));
3263                assert!(reason.contains("prefix"));
3264                assert!(reason.contains("suffix"));
3265            }
3266            other => panic!("Expected Dynamic with Join: prefix, got {:?}", other),
3267        }
3268    }
3269
3270    #[test]
3271    fn getazs_enum_region_produces_enum_of_az_arrays() {
3272        let input = r#"{
3273            "Parameters":{"Region":{"Type":"String","AllowedValues":["us-east-1","us-west-2"]}},
3274            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::GetAZs":{"Ref":"Region"}}}}}
3275        }"#;
3276        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3277        match model.resolve("R", "Properties.V") {
3278            Some(ResolvedValue::Enum { variants }) => {
3279                assert_eq!(variants.len(), 2);
3280                for v in variants {
3281                    match v {
3282                        ResolvedValue::Concrete { value: arr } => {
3283                            assert!(arr.as_array().unwrap().len() >= 2);
3284                        }
3285                        _ => panic!("Expected Concrete AZ arrays, got {:?}", v),
3286                    }
3287                }
3288            }
3289            other => panic!("Expected Enum of AZ arrays, got {:?}", other),
3290        }
3291    }
3292
3293    #[test]
3294    fn cidr_enum_ip_block_produces_enum_of_cidr_arrays() {
3295        let input = r#"{
3296            "Parameters":{"Cidr":{"Type":"String","AllowedValues":["10.0.0.0/16","172.16.0.0/16"]}},
3297            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::Cidr":[{"Ref":"Cidr"},"2","8"]}}}}
3298        }"#;
3299        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3300        match model.resolve("R", "Properties.V") {
3301            Some(ResolvedValue::Enum { variants }) => {
3302                assert_eq!(variants.len(), 2);
3303                for v in variants {
3304                    match v {
3305                        ResolvedValue::Concrete { value: arr } => {
3306                            assert_eq!(arr.as_array().unwrap().len(), 2);
3307                        }
3308                        _ => panic!("Expected Concrete CIDR arrays, got {:?}", v),
3309                    }
3310                }
3311            }
3312            other => panic!("Expected Enum of CIDR arrays, got {:?}", other),
3313        }
3314    }
3315
3316    #[test]
3317    fn to_json_string_enum_produces_enum_of_strings() {
3318        let input = r#"{
3319            "Parameters":{"Val":{"Type":"String","AllowedValues":["a","b"]}},
3320            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Ref":"Val"}}}}}
3321        }"#;
3322        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3323        match model.resolve("R", "Properties.V") {
3324            Some(ResolvedValue::Enum { variants }) => {
3325                assert_eq!(variants.len(), 2);
3326                for v in variants {
3327                    match v {
3328                        ResolvedValue::Concrete { value: j } => {
3329                            assert!(j.is_string());
3330                        }
3331                        _ => panic!("Expected Concrete string, got {:?}", v),
3332                    }
3333                }
3334            }
3335            other => panic!("Expected Enum, got {:?}", other),
3336        }
3337    }
3338
3339    #[test]
3340    fn to_json_string_conditional_produces_conditional() {
3341        let input = r#"{
3342            "Conditions":{"C":{"Fn::Equals":["a","a"]}},
3343            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ToJsonString":{"Fn::If":["C","yes","no"]}}}}}
3344        }"#;
3345        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3346        match model.resolve("R", "Properties.V") {
3347            Some(ResolvedValue::Conditional { .. }) => {}
3348            other => panic!("Expected Conditional, got {:?}", other),
3349        }
3350    }
3351
3352    #[test]
3353    fn length_of_resolved_list_returns_count() {
3354        // A list with mixed items (concrete + reference) becomes ResolvedValue::List
3355        let input = r#"{
3356            "Resources":{
3357                "Other":{"Type":"T2"},
3358                "R":{"Type":"T","Properties":{"V":{"Fn::Length":[1,{"Ref":"Other"},3]}}}
3359            }
3360        }"#;
3361        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3362        match model.resolve("R", "Properties.V") {
3363            Some(ResolvedValue::Concrete { value: v }) => {
3364                assert_eq!(v.as_i64(), Some(3));
3365            }
3366            other => panic!("Expected Concrete(3), got {:?}", other),
3367        }
3368    }
3369
3370    #[test]
3371    fn foreach_concrete_collection_evaluates_body() {
3372        let input = r#"{
3373            "Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",["a","b"],{"Fn::Sub":"prefix-${Item}"}]}}}}
3374        }"#;
3375        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3376        match model.resolve("R", "Properties.V") {
3377            Some(ResolvedValue::Map { entries }) => {
3378                assert_eq!(entries.len(), 2);
3379                let values: Vec<String> = entries
3380                    .iter()
3381                    .filter_map(|e| match &e.value {
3382                        ResolvedValue::Concrete { value: v } => v.as_str().map(|s| s.to_string()),
3383                        _ => None,
3384                    })
3385                    .collect();
3386                assert!(values.contains(&"prefix-a".to_string()));
3387                assert!(values.contains(&"prefix-b".to_string()));
3388            }
3389            other => panic!("Expected Map with evaluated body, got {:?}", other),
3390        }
3391    }
3392
3393    #[test]
3394    fn foreach_dynamic_collection_returns_dynamic() {
3395        let input = r#"{
3396            "Resources":{
3397                "Other":{"Type":"T2"},
3398                "R":{"Type":"T","Properties":{"V":{"Fn::ForEach":["Id","Item",{"Ref":"Other"},"body"]}}}
3399            }
3400        }"#;
3401        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3402        match model.resolve("R", "Properties.V") {
3403            Some(ResolvedValue::Dynamic { .. }) => {}
3404            other => panic!("Expected Dynamic, got {:?}", other),
3405        }
3406    }
3407
3408    #[test]
3409    fn output_getatt_edge_carries_terminal_intrinsic_path() {
3410        // Output edges for explicit GetAtt must end with `.Fn::GetAtt` so
3411        // diagnostics point at the intrinsic, not the parent value.
3412        let input = r#"{
3413            "Resources":{"R":{"Type":"T","Properties":{}}},
3414            "Outputs":{
3415                "DottedString":{"Value":{"Fn::GetAtt":["R","Arn"]}},
3416                "ListForm":{"Value":{"Fn::GetAtt":["R","Arn"]}}
3417            }
3418        }"#;
3419        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3420        let edges: Vec<_> = model.graph.edges.iter().filter(|e| e.source_resource.starts_with("__output__")).collect();
3421        assert_eq!(edges.len(), 2);
3422        for edge in &edges {
3423            assert!(
3424                edge.source_path.ends_with(".Fn::GetAtt"),
3425                "output GetAtt edge path should end with .Fn::GetAtt, got: {}",
3426                edge.source_path,
3427            );
3428        }
3429    }
3430
3431    #[test]
3432    fn output_sub_implicit_getatt_edge_carries_fn_sub_path() {
3433        // Implicit GetAtt inside Fn::Sub in an output must end with `.Fn::Sub`.
3434        let input = r#"{
3435            "Resources":{"R":{"Type":"T","Properties":{}}},
3436            "Outputs":{
3437                "SubOut":{"Value":{"Fn::Sub":"${R.Arn}"}}
3438            }
3439        }"#;
3440        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3441        let edges: Vec<_> = model
3442            .graph
3443            .edges
3444            .iter()
3445            .filter(|e| e.source_resource.starts_with("__output__"))
3446            .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3447            .collect();
3448        assert_eq!(edges.len(), 1);
3449        assert_eq!(edges[0].source_path, "Outputs/SubOut/Value.Fn::Sub");
3450    }
3451
3452    #[test]
3453    fn output_join_nested_getatt_edge_path() {
3454        // GetAtt nested inside Fn::Join in an output carries the full traversal
3455        // path ending with the terminal `.Fn::GetAtt`.
3456        let input = r#"{
3457            "Resources":{"R":{"Type":"T","Properties":{}}},
3458            "Outputs":{
3459                "JoinOut":{"Value":{"Fn::Join":["",["ignored",{"Fn::GetAtt":["R","Arn"]}]]}}
3460            }
3461        }"#;
3462        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3463        let edges: Vec<_> = model
3464            .graph
3465            .edges
3466            .iter()
3467            .filter(|e| e.source_resource == "__output__JoinOut")
3468            .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3469            .collect();
3470        assert_eq!(edges.len(), 1);
3471        assert_eq!(edges[0].source_path, "Outputs/JoinOut/Value.Fn::Join.1.1.Fn::GetAtt");
3472    }
3473
3474    #[test]
3475    fn resource_getatt_edge_path_unchanged() {
3476        // Resource (non-output) GetAtt edges must NOT carry the terminal
3477        // intrinsic segment, preserving existing behavior.
3478        let input = r#"{
3479            "Resources":{
3480                "R":{"Type":"T","Properties":{"V":{"Fn::GetAtt":["Other","Arn"]}}},
3481                "Other":{"Type":"T2","Properties":{}}
3482            }
3483        }"#;
3484        let model = crate::model::SemanticModel::from_bytes(input.as_bytes()).unwrap();
3485        let edges: Vec<_> = model
3486            .graph
3487            .edges
3488            .iter()
3489            .filter(|e| e.source_resource == "R")
3490            .filter(|e| matches!(&e.kind, RefKind::GetAtt { .. }))
3491            .collect();
3492        assert_eq!(edges.len(), 1);
3493        assert_eq!(edges[0].source_path, "Properties.V");
3494        assert!(!edges[0].source_path.contains("Fn::GetAtt"));
3495    }
3496}