Skip to main content

template_model/
model.rs

1use crate::budget::{BudgetKind, BudgetTracker};
2use crate::conditions::{ConditionModel, Satisfiability};
3use crate::consts::*;
4use crate::defect::{DefectPhase, ParseDefect};
5use crate::graph::ReferenceGraph;
6use crate::ir::*;
7use crate::is_custom_resource_type;
8use crate::json_value::JsonValue;
9use crate::regions::*;
10use crate::resolved_value::*;
11use crate::resolver::*;
12use crate::sam;
13use crate::span::SpanProvider;
14use log::{debug, info, warn};
15use serde::Serialize;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
20
21/// A resource property path paired with a string value found at it, such as a substitution variable or literal.
22#[derive(Debug, Clone, Serialize, Default)]
23#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
24#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
25#[serde(rename_all = "camelCase")]
26pub struct PathValuePair {
27    /// Dot-separated property path within the resource (e.g. 'Properties.BucketName').
28    pub path: String,
29    pub value: String,
30}
31
32/// A property that is dropped (resolves to AWS::NoValue) in one branch of an Fn::If condition.
33#[derive(Debug, Clone, Serialize, Default)]
34#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
35#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
36#[serde(rename_all = "camelCase")]
37pub struct ConditionalNullEntry {
38    /// Dot-separated property path that becomes absent in one branch.
39    pub path: String,
40    /// Name of the condition governing the Fn::If that drops this property.
41    pub condition: String,
42    /// True when the property is absent in the condition's true branch; false when absent in the false branch.
43    pub null_in_true_branch: bool,
44}
45
46/// Per-resource observations collected while resolving intrinsics, used to drive lint checks.
47#[derive(Debug, Clone, Serialize, Default)]
48#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
49#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
50#[serde(rename_all = "camelCase")]
51pub struct ResourceDiagnostics {
52    /// Mapping names referenced by Fn::FindInMap within this resource.
53    pub find_in_map_refs: Vec<String>,
54    /// Fn::Sub uses whose template is a single variable that could be a plain Ref; each pairs the property path with the variable name.
55    pub simple_subs: Vec<PathValuePair>,
56    /// Property paths where Fn::Sub wraps a constant string with no variables to substitute.
57    pub redundant_subs: Vec<String>,
58    /// Property paths where Fn::Join uses an empty delimiter, concatenating its elements directly.
59    pub empty_joins: Vec<String>,
60    /// Names of conditions referenced by this resource's property values.
61    pub condition_refs: Vec<String>,
62    /// Property paths building ARNs with a hardcoded 'aws' partition instead of AWS::Partition.
63    pub hardcoded_partition_arns: Vec<String>,
64    pub conditionally_null_props: Vec<ConditionalNullEntry>,
65    pub foreach_expansions: Vec<ForEachExpansion>,
66    /// Occurrences of ${...} placeholders outside an Fn::Sub that will not be substituted; each pairs the property path with the placeholder text.
67    pub unsubstituted_variables: Vec<PathValuePair>,
68    /// Fn::Sub map keys not referenced in the template string; each pairs the property path with the unused key name.
69    pub unused_sub_keys: Vec<PathValuePair>,
70    /// Property values that are a raw pseudo-parameter string (e.g. "AWS::Region") instead of using Ref.
71    pub raw_pseudo_params: Vec<PathValuePair>,
72    /// Property paths containing a {{resolve:secretsmanager:...}} dynamic reference.
73    pub secretsmanager_ref_paths: Vec<String>,
74    /// References whose target is not a defined resource, parameter, or pseudo parameter; each pairs the property path with the missing target name.
75    pub invalid_refs: Vec<PathValuePair>,
76}
77
78/// A template resource with its metadata and properties after intrinsic functions have been resolved.
79#[derive(Debug, Clone, Serialize)]
80#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
81#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
82#[serde(rename_all = "camelCase")]
83pub struct ResolvedResource {
84    pub logical_id: String,
85    pub resource_type: String,
86    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
87    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
88    pub condition: Option<String>,
89    pub depends_on: Vec<String>,
90    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
91    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
92    pub deletion_policy: Option<ResolvedValue>,
93    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
94    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95    pub update_replace_policy: Option<ResolvedValue>,
96    #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
97    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
98    pub update_policy: Option<JsonValue>,
99    #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
100    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
101    pub creation_policy: Option<JsonValue>,
102    #[cfg_attr(feature = "wasm-bindings", tsify(optional, type = "JsonValue"))]
103    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104    pub metadata: Option<JsonValue>,
105    #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, ResolvedValue>"))]
106    pub properties: HashMap<String, ResolvedValue>,
107    /// True when the entire `Properties` block is a non-map intrinsic (e.g.
108    /// `Properties: !Ref AWS::NoValue`) whose effective property set is only known
109    /// at deploy time, as distinct from a resource that simply declares no properties.
110    pub properties_dynamic: bool,
111    pub diagnostics: ResourceDiagnostics,
112}
113
114/// Effective top-level state of a lifecycle attribute after reachable
115/// `Fn::If` branches and `AWS::NoValue` removal are considered.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct LifecycleAttributeStatus {
118    /// At least one reachable branch keeps the attribute on the resource.
119    pub may_be_present: bool,
120    /// The first reachable authored scalar or list, rendered for a type diagnostic.
121    pub invalid_value: Option<String>,
122}
123
124struct PrimaryIdentifierScenario {
125    identity_tuple: Vec<String>,
126    display_tuple: Vec<String>,
127    assumptions: Vec<(String, bool)>,
128}
129
130/// An Fn::ForEach loop within a resource that expands a property over a collection.
131#[derive(Debug, Clone, Serialize)]
132#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
133#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
134#[serde(rename_all = "camelCase")]
135pub struct ForEachExpansion {
136    /// Property path within the resource where the Fn::ForEach appears.
137    pub property_path: String,
138    /// Loop variable name bound to each element during expansion.
139    pub identifier: String,
140    /// Human-readable description of the collection being iterated over.
141    pub collection_source: String,
142}
143
144/// A template output with its value and metadata after intrinsic functions have been resolved.
145#[derive(Debug, Clone, Serialize)]
146#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
147#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
148#[serde(rename_all = "camelCase")]
149pub struct ResolvedOutput {
150    pub value: ResolvedValue,
151    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
152    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
153    pub description: Option<String>,
154    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
155    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
156    pub condition: Option<String>,
157    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
158    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
159    pub export_name: Option<ResolvedValue>,
160}
161
162#[derive(Debug, Clone, Serialize)]
163#[serde(rename_all = "camelCase")]
164pub struct TemplateRule {
165    pub name: String,
166    pub condition: Option<serde_json::Value>,
167    #[serde(skip)]
168    pub condition_node: crate::ir::NodeRef,
169    pub assertions: Vec<RuleAssertion>,
170}
171
172#[derive(Debug, Clone, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct RuleAssertion {
175    pub assert: serde_json::Value,
176    #[serde(skip)]
177    pub assert_node: crate::ir::NodeRef,
178    pub description: Option<String>,
179}
180
181pub struct SemanticModel {
182    pub arena: Arena,
183    pub span_index: SourceSpanIndex,
184    pub format_version: Option<String>,
185    pub description: Option<String>,
186    pub transforms: Vec<String>,
187    pub raw_top_level_keys: Vec<String>,
188    pub template_metadata: Option<serde_json::Value>,
189    pub rules: Option<serde_json::Value>,
190    pub parsed_rules: Vec<TemplateRule>,
191    pub parameters: HashMap<String, ParameterInfo>,
192    pub mappings: MappingData,
193    pub conditions: ConditionModel,
194    pub resources: HashMap<String, ResolvedResource>,
195    pub outputs: HashMap<String, ResolvedOutput>,
196    pub graph: ReferenceGraph,
197    pub resources_by_type: HashMap<String, Vec<String>>,
198    pub diagnostics: Vec<ParseDefect>,
199    pub output_empty_joins: Vec<String>,
200    pub sam_globals: HashMap<String, HashMap<String, serde_json::Value>>,
201    pub sam_implicit_resources: HashSet<String>,
202    pub globals_param_refs: Vec<String>,
203    pub is_cdk: bool,
204    pub fn_if_conditions: Vec<String>,
205    /// Mapping names referenced by an `Fn::FindInMap` with a literal map name,
206    /// collected template-wide (resources, outputs, conditions, ForEach bodies).
207    pub find_in_map_names: HashSet<String>,
208    /// Parameter names referenced from within another parameter's definition
209    /// (e.g. a Default of `!Ref OtherParam`); these still count as usage.
210    pub params_referenced_in_definitions: HashSet<String>,
211    /// True when any `Fn::FindInMap` uses a non-literal map name, which disables
212    /// the unused-mapping check because usage can no longer be attributed to a
213    /// specific mapping.
214    pub has_dynamic_findinmap_name: bool,
215    pub resolution_sources: HashMap<(String, String), String>,
216    /// (resource_id, property_path) → authored expression retained for opaque
217    /// value identity or concrete intrinsic structure inspection.
218    value_nodes: HashMap<(String, String), NodeRef>,
219    /// Authored lifecycle attributes retained for condition-aware presence and
220    /// top-level shape analysis without changing their public raw JSON view.
221    lifecycle_attribute_nodes: HashMap<(String, String), NodeRef>,
222    lifecycle_attribute_status_cache: Mutex<HashMap<(String, String), LifecycleAttributeStatus>>,
223    /// Resource IDs whose authored `Condition` attribute is present but is not a
224    /// condition-name string. Their deployment coexistence cannot be determined.
225    invalid_resource_conditions: HashSet<String>,
226    /// Synthetic condition names created for `Fn::If` first arguments that are
227    /// expressions rather than condition-name strings. The expressions remain
228    /// available for best-effort value resolution but cannot prove reachability.
229    invalid_inline_conditions: HashSet<String>,
230    resolve_memo: Mutex<HashMap<(String, String), Option<ResolvedValue>>>,
231    raw_scenario_memo: Mutex<HashMap<(String, String), Vec<(ResolvedValue, HashMap<String, bool>)>>>,
232    properties_scenario_cache: Mutex<HashMap<String, Vec<(ResolvedValue, HashMap<String, bool>)>>>,
233    scenario_memo: Mutex<HashMap<(String, String), Vec<(serde_json::Value, HashMap<String, bool>)>>>,
234    lifecycle_policy_scenario_cache: Mutex<HashMap<(String, String), Vec<(serde_json::Value, HashMap<String, bool>)>>>,
235    /// Cumulative count of scenarios materialized by `resolve_scenarios` across
236    /// the whole validation, charged against `MAX_TOTAL_SCENARIO_COMBINATIONS`.
237    /// Bounds total scenario-expansion work the way `ConditionModel`'s
238    /// `sat_iterations_used` bounds total satisfiability work.
239    scenario_combinations_used: AtomicU64,
240    /// Deterministic, deduplicating tracker for all validation-budget
241    /// exhaustions across model construction and downstream validation.
242    budget_tracker: Arc<BudgetTracker>,
243    /// Serializes scenario expansion and budget charging. Scenario queries may
244    /// run concurrently in rule-engine workers; holding this lock from budget
245    /// reservation through accounting prevents concurrent queries from jointly
246    /// exceeding the model-wide limit.
247    scenario_expansion_lock: Mutex<()>,
248    /// Set once when scenario expansion omits at least one possible scenario,
249    /// whether because a per-value product or the remaining model-wide budget
250    /// was exhausted. The flag is monotonic and produces one advisory later.
251    scenario_expansion_curtailed: AtomicBool,
252}
253
254/// Values used for AWS pseudo parameters (Ref AWS::Region, AWS::AccountId, ...) when
255/// resolving the template; any field left unset falls back to a sensible default.
256#[derive(Debug, Clone, Default, Serialize, serde::Deserialize)]
257#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
258#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
259#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
260#[serde(rename_all = "camelCase")]
261pub struct PseudoParameterOverrides {
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
264    pub account_id: Option<String>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
267    pub notification_arns: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
270    pub partition: Option<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
273    pub region: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
276    pub stack_id: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
279    pub stack_name: Option<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
282    pub url_suffix: Option<String>,
283}
284
285fn is_account_id_shaped(value: &str) -> bool {
286    value.len() == 12 && value.bytes().all(|b| b.is_ascii_digit())
287}
288
289fn is_partition_shaped(value: &str) -> bool {
290    value.starts_with("aws")
291}
292
293impl PseudoParameterOverrides {
294    pub fn region(&self) -> &str {
295        self.region.as_deref().unwrap_or(DEFAULT_REGION)
296    }
297
298    pub fn get(&self, name: &str) -> Option<String> {
299        match name {
300            PSEUDO_ACCOUNT_ID => Some(self.account_id.clone().unwrap_or_else(|| DEFAULT_ACCOUNT_ID.into())),
301            PSEUDO_NOTIFICATION_ARNS => Some(self.notification_arns.clone().unwrap_or_else(|| {
302                let r = self.region();
303                let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
304                format!("arn:{}:sns:{}:{}:notification", p, r, DEFAULT_ACCOUNT_ID)
305            })),
306            PSEUDO_PARTITION => {
307                Some(self.partition.clone().unwrap_or_else(|| partition_for_region(self.region()).into()))
308            }
309            PSEUDO_REGION => Some(self.region().to_string()),
310            PSEUDO_STACK_ID => Some(self.stack_id.clone().unwrap_or_else(|| {
311                let r = self.region();
312                let p = self.partition.as_deref().unwrap_or_else(|| partition_for_region(r));
313                format!(
314                    "arn:{}:cloudformation:{}:{}:stack/{}/51af3dc0-da77-11e4-872e-1234567db123",
315                    p, r, DEFAULT_ACCOUNT_ID, DEFAULT_STACK_NAME
316                )
317            })),
318            PSEUDO_STACK_NAME => Some(self.stack_name.clone().unwrap_or_else(|| DEFAULT_STACK_NAME.into())),
319            PSEUDO_URL_SUFFIX => {
320                Some(self.url_suffix.clone().unwrap_or_else(|| url_suffix_for_region(self.region()).into()))
321            }
322            _ => None,
323        }
324    }
325
326    #[must_use]
327    pub fn invalid_overrides(&self) -> Vec<String> {
328        let mut problems = Vec::new();
329        if let Some(account_id) = self.account_id.as_deref()
330            && !is_account_id_shaped(account_id)
331        {
332            problems.push(format!("{PSEUDO_ACCOUNT_ID} override '{account_id}' is not a 12-digit AWS account ID"));
333        }
334        if let Some(partition) = self.partition.as_deref()
335            && !is_partition_shaped(partition)
336        {
337            problems.push(format!(
338                "{PSEUDO_PARTITION} override '{partition}' is not a valid partition \
339                 (expected an 'aws'-family value such as aws, aws-cn, or aws-us-gov)"
340            ));
341        }
342        problems
343    }
344
345    /// Returns the user-supplied override for `name` only when the caller
346    /// explicitly set the corresponding field. Auto-derived defaults - e.g. the
347    /// commercial-vs-cn-vs-gov partition implied by `region` - are *not*
348    /// returned here.
349    ///
350    /// The satisfiability solver uses this to decide whether a pseudo-parameter
351    /// is a constant (user pinned its value) or a free variable ranging over
352    /// the literals it is compared against plus a sentinel for "any other
353    /// value". `get` always returns a default and would force the solver to
354    /// treat every pseudo-parameter as a constant, producing false-positive
355    /// "unreachable branch" diagnostics for templates that branch on
356    /// `AWS::Partition`, `AWS::Region`, etc. - see `ConditionModel::eval_value_concrete`.
357    pub fn fixed_value(&self, name: &str) -> Option<String> {
358        match name {
359            PSEUDO_ACCOUNT_ID => self.account_id.clone(),
360            PSEUDO_NOTIFICATION_ARNS => self.notification_arns.clone(),
361            PSEUDO_PARTITION => self.partition.clone(),
362            PSEUDO_REGION => self.region.clone(),
363            PSEUDO_STACK_ID => self.stack_id.clone(),
364            PSEUDO_STACK_NAME => self.stack_name.clone(),
365            PSEUDO_URL_SUFFIX => self.url_suffix.clone(),
366            _ => None,
367        }
368    }
369}
370
371#[derive(Default)]
372pub struct ParseConfig {
373    pub parameters: HashMap<String, String>,
374    pub pseudo_parameters: PseudoParameterOverrides,
375}
376
377#[must_use]
378pub struct ParseResult {
379    pub model: SemanticModel,
380    /// Wall-clock time spent building the model, in milliseconds.
381    pub model_build_ms: f64,
382}
383
384impl SemanticModel {
385    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
386        Self::parse(bytes, Default::default()).map(|r| r.model)
387    }
388
389    pub fn parse(bytes: &[u8], config: ParseConfig) -> Result<ParseResult, ParseError> {
390        if bytes.len() > MAX_TEMPLATE_SIZE_BYTES {
391            return Err(ParseError {
392                message: format!("Template exceeds maximum size of {}MB", MAX_TEMPLATE_SIZE_BYTES / (1024 * 1024)),
393                line: None,
394                column: None,
395            });
396        }
397
398        let total_start = web_time::Instant::now();
399
400        info!("Phase 1: Parsing IR ({} bytes)", bytes.len());
401        let mut ir = crate::parser::parse(bytes)?;
402        let foreach_diagnostics = crate::transform_expansion::expand_language_extensions(&mut ir);
403        let condition_shape_diagnostics =
404            crate::parser::condition_shape::validate_condition_bodies(&ir.arena, ir.conditions, &ir.span_index);
405        let (parameters, parameter_diagnostics) = extract_parameters(&ir);
406        // A parameter's definition can reference another parameter (e.g. a
407        // Default given as `!Ref OtherParam`). Such a reference still counts as
408        // usage, so collect the parameter names referenced from within the
409        // Parameters section to feed the unused-parameter check.
410        let params_referenced_in_definitions = collect_parameter_definition_refs(&ir, &parameters);
411        let (mappings, mapping_diagnostics) = extract_mappings(&ir);
412
413        // Create the shared budget tracker early so it is available before any
414        // condition-model or resolver work can exhaust a budget.
415        let budget_tracker = Arc::new(BudgetTracker::new());
416
417        let mut conditions = ConditionModel::from_ir_with_tracker(
418            &ir,
419            &parameters,
420            &config.pseudo_parameters,
421            &mappings,
422            Arc::clone(&budget_tracker),
423        );
424
425        let resource_ids: Vec<String> = if ir.resources != NULL_REF {
426            ir.arena
427                .as_map(ir.resources)
428                .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
429                .unwrap_or_default()
430        } else {
431            vec![]
432        };
433        let output_count =
434            if ir.outputs != NULL_REF { ir.arena.as_map(ir.outputs).map(|e| e.len()).unwrap_or(0) } else { 0 };
435        info!("Phase 2: Resolving {} resources, {} outputs", resource_ids.len(), output_count);
436
437        let mut resolver = Resolver::new(
438            &ir.arena,
439            &parameters,
440            &mappings,
441            resource_ids.iter().cloned().collect(),
442            &config.parameters,
443            &config.pseudo_parameters,
444        );
445        let mut resources = HashMap::new();
446        let mut lifecycle_attribute_nodes = HashMap::new();
447        let mut invalid_resource_conditions = HashSet::new();
448        if ir.resources != NULL_REF
449            && let Some(entries) = ir.arena.as_map(ir.resources)
450        {
451            // Pre-scan: record each resource's DefinitionSubstitutions keys so
452            // definition placeholders can be checked for membership per variable.
453            for (rname, rnode) in entries {
454                if let Some(props) = ir.arena.as_map(*rnode) {
455                    for (key, val) in props {
456                        if key == KEY_PROPERTIES
457                            && let Some(prop_entries) = ir.arena.as_map(*val)
458                            && let Some((_, subs_ref)) =
459                                prop_entries.iter().find(|(k, _)| k == "DefinitionSubstitutions")
460                            && let Some(subs) = ir.arena.as_map(*subs_ref)
461                        {
462                            resolver
463                                .def_subs_resources
464                                .entry(rname.clone())
465                                .or_default()
466                                .extend(subs.iter().map(|(k, _)| k.clone()));
467                        }
468                    }
469                }
470            }
471            for (name, node_ref) in entries.iter().cloned() {
472                if let Some(resource_entries) = ir.arena.as_map(node_ref) {
473                    for attribute in
474                        [KEY_CREATION_POLICY, KEY_UPDATE_POLICY, KEY_DELETION_POLICY, KEY_UPDATE_REPLACE_POLICY]
475                    {
476                        if let Some((_, value_ref)) = resource_entries.iter().find(|(key, _)| key == attribute) {
477                            lifecycle_attribute_nodes.insert((name.clone(), attribute.to_string()), *value_ref);
478                        }
479                    }
480                }
481                if invalid_resource_condition_ref(&ir.arena, node_ref).is_some() {
482                    invalid_resource_conditions.insert(name.clone());
483                }
484                // Validate resource body shape before resolution. `Fn::ForEach::`
485                // synthetic IDs are expanded by the language-extensions transform
486                // and do not have standard resource shapes.
487                if name != FN_TRANSFORM && !name.starts_with(FN_FOR_EACH_KEY_PREFIX) {
488                    let has_sam_transform = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
489                    let shape_defects =
490                        validate_resource_shape(&ir.arena, &name, node_ref, has_sam_transform, &ir.span_index);
491                    resolver.diagnostics.extend(shape_defects);
492                }
493                resolver.set_current_resource(&name);
494                let resolved = resolve_resource(&ir.arena, &name, node_ref, &mut resolver);
495                resources.insert(name.clone(), resolved);
496            }
497        }
498        for (name, res) in &resources {
499            debug!(
500                "Resolved '{}' ({}): {} properties, {} edges, condition={:?}",
501                name,
502                res.resource_type,
503                res.properties.len(),
504                resolver.edges.iter().filter(|e| e.source_resource == *name).count(),
505                res.condition
506            );
507        }
508
509        let mut outputs = HashMap::new();
510        if ir.outputs != NULL_REF
511            && let Some(entries) = ir.arena.as_map(ir.outputs)
512        {
513            for (name, node_ref) in entries.iter().cloned() {
514                resolver.set_current_resource(&format!("{}{}", OUTPUT_PSEUDO_RESOURCE_PREFIX, name));
515                resolver.set_current_path(&format!("Outputs/{}/Value", name));
516                let resolved = resolve_output(&ir.arena, node_ref, &mut resolver);
517                outputs.insert(name.clone(), resolved);
518            }
519        }
520
521        // Walk the Rules section so that `Ref`/`Fn::Sub`/`Fn::ValueOf` etc.
522        // appearing inside rule conditions and assertions emit reference
523        // edges. Without this pass, parameters used only in Rules-section
524        // assertions would appear unreferenced to downstream rule checks.
525        if ir.rules != NULL_REF
526            && let Some(rule_entries) = ir.arena.as_map(ir.rules)
527        {
528            for (rule_name, rule_node) in rule_entries.iter().cloned() {
529                resolver.set_current_resource(&format!("{}{}", RULE_PSEUDO_RESOURCE_PREFIX, rule_name));
530                let cond_ref = ir.arena.map_get(rule_node, KEY_RULE_CONDITION);
531                if let Some(cond_ref) = cond_ref {
532                    resolver.set_current_path(&format!("Rules/{}/{}", rule_name, KEY_RULE_CONDITION));
533                    resolver.resolve_node(cond_ref);
534                }
535                let assertions_ref = ir.arena.map_get(rule_node, KEY_ASSERTIONS);
536                if let Some(assertions_ref) = assertions_ref
537                    && let Some(assertion_items) = ir.arena.as_list(assertions_ref)
538                {
539                    for (idx, item_ref) in assertion_items.to_vec().iter().enumerate() {
540                        if let Some(assert_ref) = ir.arena.map_get(*item_ref, KEY_ASSERT) {
541                            resolver.set_current_path(&format!(
542                                "Rules/{}/{}/{}/{}",
543                                rule_name, KEY_ASSERTIONS, idx, KEY_ASSERT
544                            ));
545                            resolver.resolve_node(assert_ref);
546                        }
547                    }
548                }
549            }
550        }
551
552        info!("Phase 3: Building reference graph from {} resolver edges", resolver.edges.len());
553
554        // Register inline conditions (from IfExpr) into the condition model in
555        // one batch, so the derived mutex/implication passes run once. Preserve
556        // their invalid authored origin separately from their best-effort model.
557        let invalid_inline_conditions = resolver.inline_conditions.iter().map(|(name, _)| name.clone()).collect();
558        conditions.register_inline_batch(resolver.inline_conditions.drain(..));
559
560        // Collect every mapping name referenced by an Fn::FindInMap anywhere in
561        // the template (resources, outputs, conditions, ForEach bodies). A literal
562        // first argument names a specific mapping; a non-literal one (e.g. a nested
563        // Fn::FindInMap or Ref) means the referenced mapping can't be determined
564        // statically, which disables the unused-mapping check.
565        let mut find_in_map_names: std::collections::HashSet<String> = std::collections::HashSet::new();
566        let mut has_dynamic_findinmap_name = false;
567        for idx in 0..ir.arena.len() {
568            if let Node::Intrinsic(IntrinsicFn::FindInMap(map_name_ref, _, _, _)) = ir.arena.node(idx as NodeRef) {
569                match ir.arena.as_str(*map_name_ref) {
570                    Some(name) => {
571                        find_in_map_names.insert(name.to_string());
572                    }
573                    None => has_dynamic_findinmap_name = true,
574                }
575            }
576        }
577
578        let resolution_sources = resolver.resolution_sources();
579        let value_nodes = resolver.value_nodes();
580        let resolver_depth_exceeded = resolver.depth_exceeded;
581        let resolver_enum_expansion_exceeded = resolver.enum_expansion_exceeded;
582        let mut all_edges = resolver.edges;
583        for (id, res) in &resources {
584            for dep in &res.depends_on {
585                all_edges.push(ResolverEdge {
586                    source_resource: id.clone(),
587                    source_path: KEY_DEPENDS_ON.to_string(),
588                    target: dep.clone(),
589                    kind: RefKind::DependsOn,
590                    span: UNKNOWN_SPAN,
591                    condition_context: None,
592                });
593            }
594        }
595        let graph = ReferenceGraph::build(all_edges, &resource_ids);
596
597        let mut resources_by_type: HashMap<String, Vec<String>> = HashMap::new();
598        for (id, res) in &resources {
599            resources_by_type.entry(res.resource_type.clone()).or_default().push(id.clone());
600        }
601        // Deterministic iteration across engines and runs: resource IDs within each
602        // resource type are returned in template-declaration order (ascending by source
603        // line/column). HashMap iteration is otherwise non-deterministic and was causing
604        // per-pair diagnostic rules (e.g. subnet overlap) to attribute findings to
605        // different resources on different runs.
606        for ids in resources_by_type.values_mut() {
607            ids.sort_by_key(|id| {
608                let key = format!("Resources/{}", id);
609                ir.span_index.get(key.as_str()).map(|s| (s.start_line, s.start_column)).unwrap_or((u32::MAX, u32::MAX))
610            });
611        }
612
613        let mut diagnostics = ir.diagnostics;
614        diagnostics.extend(foreach_diagnostics);
615        diagnostics.extend(condition_shape_diagnostics);
616        diagnostics.extend(mapping_diagnostics);
617        diagnostics.extend(parameter_diagnostics);
618
619        diagnostics.extend(crate::nesting::validate_intrinsic_nesting(&ir.arena));
620        diagnostics.extend(crate::intrinsic_arg_shapes::validate_intrinsic_arg_shapes(&ir.arena, &ir.transforms));
621        diagnostics.extend(crate::lang_ext_shapes::validate_lang_ext_parameter_shapes(&ir.arena, &ir.transforms));
622        diagnostics.extend(crate::language_extensions::validate_language_extensions(&ir.arena, &ir.transforms));
623        let lifecycle_resource_conditions: HashMap<String, String> = resources
624            .iter()
625            .filter_map(|(resource_id, resource)| {
626                resource.condition.as_ref().map(|condition| (resource_id.clone(), condition.clone()))
627            })
628            .collect();
629        diagnostics.extend(crate::language_extensions::validate_lifecycle_intrinsics(
630            &ir.arena,
631            &conditions,
632            &lifecycle_attribute_nodes,
633            &lifecycle_resource_conditions,
634        ));
635        diagnostics.extend(crate::dynamic_ref::validate_dynamic_references(&ir.arena, ir.resources));
636
637        let mut fn_if_conditions: Vec<String> = Vec::new();
638        for idx in 0..ir.arena.len() {
639            match ir.arena.node(idx as NodeRef) {
640                Node::Intrinsic(IntrinsicFn::If(cond_name, _, _)) => {
641                    fn_if_conditions.push(cond_name.clone());
642                    // Inside a Conditions-section body, Fn::If is not a valid
643                    // condition function at all - that is the not-a-boolean
644                    // finding's territory, so the name of a function that is
645                    // itself rejected there is not checked.
646                    let in_conditions_body =
647                        ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
648                    if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
649                        // A single owner for the undefined-Fn::If-condition
650                        // finding. Emitting it here (during the arena scan, which
651                        // sees every Fn::If regardless of nesting) rather than in
652                        // each engine keeps the two engines identical and covers
653                        // the no-Conditions-section case, where a condition-name
654                        // reference is still invalid.
655                        let condition_path = format!("{}/{}/0", ir.arena.get(idx as NodeRef).path, FN_IF);
656                        diagnostics.push(crate::make_parse_defect_at(
657                            "E1028",
658                            format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
659                            ir.span_index
660                                .get(&condition_path)
661                                .copied()
662                                .unwrap_or_else(|| ir.arena.span(idx as NodeRef)),
663                            &condition_path,
664                        ));
665                    }
666                }
667                // A structurally malformed Fn::If (wrong arity, wrong type) is
668                // rejected by the parser and left as a plain `Fn::If` map node
669                // rather than an `IntrinsicFn::If`. Its condition is still
670                // referenced, so collect the name here too - otherwise the
671                // unused-condition check would wrongly flag a condition that the
672                // template does reference.
673                Node::Map(entries) if entries.len() == 1 && entries[0].0 == FN_IF => {
674                    if let Some(first) = ir.arena.as_list(entries[0].1).and_then(|items| items.first())
675                        && let Some(cond_name) = ir.arena.as_str(*first)
676                    {
677                        fn_if_conditions.push(cond_name.to_string());
678                        // The structure error is reported separately; the
679                        // undefined condition name is its own finding - a
680                        // malformed Fn::If gets both. Conditions-section
681                        // bodies are excluded for the same reason as the
682                        // well-formed arm.
683                        let in_conditions_body =
684                            ir.arena.get(idx as NodeRef).path.split('/').next() == Some(SECTION_CONDITIONS);
685                        if !in_conditions_body && !conditions.conditions.contains_key(cond_name) {
686                            diagnostics.push(crate::make_parse_defect_at(
687                                "E1028",
688                                format!("Fn::If condition '{}' does not exist in Conditions section", cond_name),
689                                ir.arena.span(*first),
690                                &ir.arena.get(*first).path,
691                            ));
692                        }
693                    }
694                }
695                _ => {}
696            }
697        }
698        fn_if_conditions.sort();
699        fn_if_conditions.dedup();
700        let mut output_empty_joins: Vec<String> = Vec::new();
701        for (key, joins) in &resolver.empty_joins {
702            if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
703                output_empty_joins.extend(joins.iter().cloned());
704            }
705        }
706
707        // Raw pseudo-parameter strings in the Outputs section. Such findings are
708        // collected against the `__output__`/`__outputs__` pseudo-resources,
709        // which are filtered out of the serialized model the engines scan, so
710        // the engine rule never sees them. Emit them here (both engines share
711        // this parse-time output) so a pseudo-parameter used as a plain string
712        // in an output Value is reported the same as one in a resource.
713        {
714            let mut output_raw_pseudo: Vec<(String, String)> = Vec::new();
715            for (key, entries) in &resolver.raw_pseudo_params {
716                if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
717                    for (path, value) in entries {
718                        output_raw_pseudo.push((path.clone(), value.clone()));
719                    }
720                }
721            }
722            output_raw_pseudo.sort();
723            for (path, value) in output_raw_pseudo {
724                diagnostics.push(crate::make_parse_defect(
725                    "W1054",
726                    format!(
727                        "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
728                        value, value
729                    ),
730                    ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
731                ));
732            }
733        }
734
735        // Raw pseudo-parameter strings in parameter Default values: a Default
736        // that is exactly a pseudo-parameter string (e.g. "AWS::Region") is
737        // almost certainly a mistake. Parameters are not walked by the resolver,
738        // so check the raw default strings directly.
739        {
740            let mut param_names: Vec<&String> = parameters.keys().collect();
741            param_names.sort();
742            for pname in param_names {
743                if let Some(default) = &parameters[pname].default
744                    && PSEUDO_PARAMETERS.contains(&default.as_str())
745                {
746                    diagnostics.push(crate::make_parse_defect(
747                        "W1054",
748                        format!("Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead", default, default),
749                        ir.span_index.get(&format!("Parameters/{}/Default", pname)).copied().unwrap_or(UNKNOWN_SPAN),
750                    ));
751                }
752            }
753        }
754
755        // Raw pseudo-parameter strings in Mappings values and Conditions.
756        // Neither section is walked by the resolver (it only visits resources
757        // and outputs), so scan their string nodes directly. `Ref` targets are
758        // not string nodes in the arena, so `Ref: AWS::Region` is naturally
759        // exempt - only a pseudo-parameter used as plain string data fires.
760        for idx in 0..ir.arena.len() {
761            let spanned = ir.arena.get(idx as NodeRef);
762            let Node::String(s) = &spanned.node else {
763                continue;
764            };
765            let section = spanned.path.split('/').next().unwrap_or("");
766            // A string that is a `Ref` target (path ends in the Ref key, e.g. a
767            // raw `{"Ref": "AWS::Region"}` map the parser could not
768            // canonicalize) is already a reference - only plain string *data*
769            // warrants the use-Ref-instead advice.
770            let is_ref_target = spanned.path.rsplit('/').next() == Some(FN_REF);
771            if (section == SECTION_MAPPINGS || section == SECTION_CONDITIONS)
772                && !is_ref_target
773                && PSEUDO_PARAMETERS.contains(&s.as_str())
774            {
775                diagnostics.push(crate::make_parse_defect(
776                    "W1054",
777                    format!(
778                        "Found a string '{}' that appears to be a pseudo parameter reference; use 'Ref: {}' instead",
779                        s, s
780                    ),
781                    spanned.span,
782                ));
783            }
784        }
785
786        // Unsubstituted `${Variable}` strings in the Outputs section. Like the
787        // raw-pseudo-parameter case above, these are collected against the
788        // `__output__` pseudo-resources that never reach the engines, so the
789        // Sub-needed finding must be emitted here.
790        {
791            let mut output_unsubstituted: Vec<(String, String)> = Vec::new();
792            for (key, entries) in &resolver.unsubstituted_variables {
793                if key.starts_with(OUTPUT_PSEUDO_RESOURCE_PREFIX) || key == OUTPUTS_PSEUDO_RESOURCE {
794                    for (path, variable) in entries {
795                        output_unsubstituted.push((path.clone(), variable.clone()));
796                    }
797                }
798            }
799            output_unsubstituted.sort();
800            for (path, variable) in output_unsubstituted {
801                diagnostics.push(crate::make_parse_defect(
802                    "E1029",
803                    format!("Found an embedded parameter '{}' outside of an 'Fn::Sub' at {}", variable, path),
804                    ir.span_index.get(&path).copied().unwrap_or(UNKNOWN_SPAN),
805                ));
806            }
807        }
808
809        diagnostics.extend(resolver.diagnostics);
810        // SAM handling keys on the exact transform id, matching how the engines
811        // detect the transform. A substring match would misclassify a non-SAM
812        // transform whose name merely contains "Serverless" (e.g. a typo'd date
813        // or a custom macro) as SAM, running the transform-error validators and
814        // suppressing the correct "serverless type without transform" finding.
815        let is_sam = ir.transforms.iter().any(|t| t == TRANSFORM_SERVERLESS);
816        for d in graph.cycle_diagnostics(&ir.span_index) {
817            if is_sam && sam::cycle_involves_sam_diagnostic(&d, &resources) {
818                continue;
819            }
820            diagnostics.push(d);
821        }
822        for (cond_name, always_val) in conditions.tautological_equals() {
823            let result_str = if always_val { "True" } else { "False" };
824            diagnostics.push(crate::make_parse_defect_at(
825                "W8003",
826                format!("Fn::Equals in condition '{}' will always return {}", cond_name, result_str),
827                ir.span_index.get(&format!("Conditions/{}", cond_name)).copied().unwrap_or(UNKNOWN_SPAN),
828                &format!("Conditions/{}", cond_name),
829            ));
830        }
831        // A `Condition:` key that names a condition absent from the Conditions
832        // section is reported here - a distinct rule for the resource case and
833        // the output case, since they are separate concerns. Emitting both
834        // during model build anchors each at its own source location and keeps
835        // the two engines identical. Names are sorted for deterministic
836        // ordering.
837        {
838            let mut resource_ids_sorted: Vec<&String> = resources.keys().collect();
839            resource_ids_sorted.sort();
840            for rid in resource_ids_sorted {
841                if let Some(cond) = &resources[rid].condition
842                    && !conditions.conditions.contains_key(cond)
843                {
844                    diagnostics.push(crate::make_parse_defect_for_resource(
845                        "E8002",
846                        format!("Condition '{}' referenced by resource '{}' is not defined", cond, rid),
847                        ir.span_index.get(&format!("Resources/{}", rid)).copied().unwrap_or(UNKNOWN_SPAN),
848                        rid,
849                    ));
850                }
851            }
852            let mut output_names_sorted: Vec<&String> = outputs.keys().collect();
853            output_names_sorted.sort();
854            for oname in output_names_sorted {
855                if let Some(cond) = &outputs[oname].condition
856                    && !conditions.conditions.contains_key(cond)
857                {
858                    diagnostics.push(crate::make_parse_defect_for_resource(
859                        "E6005",
860                        format!("Condition '{}' referenced by output '{}' is not defined", cond, oname),
861                        ir.span_index.get(&format!("Outputs/{}", oname)).copied().unwrap_or(UNKNOWN_SPAN),
862                        oname,
863                    ));
864                }
865            }
866        }
867        for (owner, undefined_ref) in conditions.undefined_condition_refs() {
868            // Synthetic conditions (`__`-prefixed, inserted for inline Fn::If and
869            // Rules-section assertions) are internal; never surface their names.
870            if owner.starts_with("__") || undefined_ref.starts_with("__") {
871                continue;
872            }
873            diagnostics.push(crate::make_parse_defect(
874                "E8007",
875                format!("Condition '{}' references undefined condition '{}'", owner, undefined_ref),
876                ir.span_index.get(&format!("Conditions/{}", owner)).copied().unwrap_or(UNKNOWN_SPAN),
877            ));
878        }
879        for cycle in conditions.detect_cycles() {
880            let cycle_desc = cycle.join(" -> ");
881            let first = &cycle[0];
882            diagnostics.push(crate::make_parse_defect(
883                "E9106",
884                format!("Circular dependency in conditions: {}", cycle_desc),
885                ir.span_index.get(&format!("Conditions/{}", first)).copied().unwrap_or(UNKNOWN_SPAN),
886            ));
887        }
888        for (first, other) in conditions.detect_equivalent_conditions() {
889            diagnostics.push(crate::make_parse_defect(
890                "W9053",
891                format!("Condition '{}' is equivalent to condition '{}' - consider consolidating", other, first),
892                ir.span_index.get(&format!("Conditions/{}", other)).copied().unwrap_or(UNKNOWN_SPAN),
893            ));
894        }
895        // The satisfiability-budget-exhaustion advisory is deliberately NOT
896        // emitted here: almost every satisfiability query runs later, during
897        // engine rule evaluation, so the budget-exhausted set is still empty at
898        // model-build time. The validation engine emits it after rule
899        // evaluation, once those queries have run.
900        let model_build_ms = total_start.elapsed().as_secs_f64() * 1000.0;
901
902        if !diagnostics.is_empty() {
903            warn!("{} parse-time diagnostics", diagnostics.len());
904        }
905        let total_props: usize = resources.values().map(|r| r.properties.len()).sum();
906        info!(
907            "Semantic model: {} resources ({} types, {} total properties), {} outputs, {} edges, {} conditions, {} diagnostics",
908            resources.len(),
909            resources_by_type.len(),
910            total_props,
911            outputs.len(),
912            graph.edges.len(),
913            conditions.conditions.len(),
914            diagnostics.len()
915        );
916
917        let template_metadata =
918            if ir.template_metadata != NULL_REF { Some(node_to_json(&ir.arena, ir.template_metadata)) } else { None };
919        let rules = if ir.rules != NULL_REF { Some(node_to_json(&ir.arena, ir.rules)) } else { None };
920        let parsed_rules = parse_rules(&rules, &ir.arena, ir.rules);
921        let rule_implications: Vec<(String, crate::ir::NodeRef, Vec<crate::ir::NodeRef>)> = parsed_rules
922            .iter()
923            .map(|r| {
924                let assertion_nodes: Vec<crate::ir::NodeRef> = r.assertions.iter().map(|a| a.assert_node).collect();
925                (r.name.clone(), r.condition_node, assertion_nodes)
926            })
927            .collect();
928        if !rule_implications.is_empty() {
929            conditions.register_rule_implications(&ir.arena, &rule_implications);
930        }
931        let rule_diagnostics = crate::rules::validate_rules(&rules, &ir.arena, ir.rules);
932        diagnostics.extend(rule_diagnostics);
933        let sam_globals = sam::extract_sam_globals(&ir.arena, ir.globals);
934        if !sam_globals.is_empty() {
935            sam::apply_sam_globals(&mut resources, &sam_globals, &mut ir.span_index);
936        }
937        let sam_implicit_resources =
938            if is_sam { sam::collect_sam_implicit_resources(&resources) } else { HashSet::new() };
939        let globals_param_refs =
940            if is_sam { sam::collect_globals_param_refs(&ir.arena, ir.globals) } else { Vec::new() };
941        if is_sam {
942            let parameter_names: HashSet<String> = parameters.keys().cloned().collect();
943            diagnostics.extend(sam::collect_transform_errors(
944                &ir.arena,
945                ir.resources,
946                ir.globals,
947                &resources,
948                &parameter_names,
949                &ir.span_index,
950            ));
951        }
952        let is_cdk = resources_by_type.contains_key(CDK_METADATA_TYPE);
953
954        if resolver_depth_exceeded {
955            budget_tracker.record(BudgetKind::ResolverDepth);
956        }
957        if resolver_enum_expansion_exceeded {
958            budget_tracker.record(BudgetKind::EnumExpansion);
959        }
960        // Invalid Ref targets in Outputs. The resolver records these under the
961        // `__output__<Name>` pseudo-resource key, but the engines only see
962        // top-level edges (which are not created for unknown Ref targets). Emit
963        // them here as parse-time diagnostics so both engines surface them.
964        // Skip when MODULE resources or unexpanded ForEach are present, since
965        // both can synthesize resource names unknown at parse time.
966        {
967            let has_module_or_foreach = resources.values().any(|r| r.resource_type.ends_with("::MODULE"))
968                || resources.keys().any(|k| k.contains("Fn::ForEach"));
969            if !has_module_or_foreach {
970                let mut output_invalid_refs: Vec<(String, String, String)> = Vec::new();
971                for (key, entries) in &resolver.invalid_refs {
972                    if let Some(output_name) = key.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX) {
973                        for (path, target) in entries {
974                            if sam_implicit_resources.contains(target) {
975                                continue;
976                            }
977                            output_invalid_refs.push((output_name.to_string(), path.clone(), target.clone()));
978                        }
979                    }
980                }
981                output_invalid_refs.sort();
982                for (output_name, path, target) in &output_invalid_refs {
983                    let mut defect =
984                        ParseDefect::new(
985                            "F6101",
986                            format!(
987                                "Ref '{}' does not reference a valid resource, parameter, or pseudo-parameter",
988                                target,
989                            ),
990                        )
991                        .location(ir.span_index.get(path).copied().unwrap_or(UNKNOWN_SPAN))
992                        .phase(DefectPhase::Parse);
993                    defect = defect.property_path(format!("Outputs.{}.Value.Ref", output_name));
994                    diagnostics.push(defect);
995                }
996            }
997        }
998
999        Ok(ParseResult {
1000            model: SemanticModel {
1001                arena: ir.arena,
1002                span_index: ir.span_index,
1003                format_version: ir.format_version,
1004                description: ir.description,
1005                transforms: ir.transforms,
1006                raw_top_level_keys: ir.raw_top_level_keys,
1007                template_metadata,
1008                rules,
1009                parsed_rules,
1010                parameters,
1011                mappings,
1012                conditions,
1013                resources,
1014                outputs,
1015                graph,
1016                resources_by_type,
1017                diagnostics,
1018                output_empty_joins,
1019                sam_globals,
1020                sam_implicit_resources,
1021                globals_param_refs,
1022                is_cdk,
1023                fn_if_conditions,
1024                find_in_map_names,
1025                params_referenced_in_definitions,
1026                has_dynamic_findinmap_name,
1027                resolution_sources,
1028                value_nodes,
1029                lifecycle_attribute_nodes,
1030                lifecycle_attribute_status_cache: Mutex::new(HashMap::new()),
1031                invalid_resource_conditions,
1032                invalid_inline_conditions,
1033                resolve_memo: Mutex::new(HashMap::new()),
1034                raw_scenario_memo: Mutex::new(HashMap::new()),
1035                properties_scenario_cache: Mutex::new(HashMap::new()),
1036                scenario_memo: Mutex::new(HashMap::new()),
1037                lifecycle_policy_scenario_cache: Mutex::new(HashMap::new()),
1038                scenario_combinations_used: AtomicU64::new(0),
1039                budget_tracker,
1040                scenario_expansion_lock: Mutex::new(()),
1041                scenario_expansion_curtailed: AtomicBool::new(false),
1042            },
1043            model_build_ms,
1044        })
1045    }
1046
1047    pub fn resource(&self, id: &str) -> Option<&ResolvedResource> {
1048        self.resources.get(id)
1049    }
1050
1051    /// Reports whether a lifecycle attribute can legally survive condition
1052    /// evaluation, and whether any reachable authored value has a non-object
1053    /// shape. Only `UpdatePolicy` permits `Fn::If` to remove the whole attribute
1054    /// with `AWS::NoValue`; illegal values on the other attributes remain present
1055    /// for downstream diagnostics.
1056    #[must_use]
1057    pub fn lifecycle_attribute_status(&self, resource_id: &str, attribute: &str) -> LifecycleAttributeStatus {
1058        if !matches!(
1059            attribute,
1060            KEY_CREATION_POLICY | KEY_UPDATE_POLICY | KEY_DELETION_POLICY | KEY_UPDATE_REPLACE_POLICY
1061        ) {
1062            return LifecycleAttributeStatus::default();
1063        }
1064        let cache_key = (resource_id.to_string(), attribute.to_string());
1065        let mut cache = self.lifecycle_attribute_status_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1066        if let Some(status) = cache.get(&cache_key) {
1067            return status.clone();
1068        }
1069
1070        let mut status = LifecycleAttributeStatus::default();
1071        if let Some(node) = self.lifecycle_attribute_nodes.get(&cache_key) {
1072            let mut assumptions = self
1073                .resources
1074                .get(resource_id)
1075                .and_then(|resource| resource.condition.as_ref())
1076                .map(|condition| vec![(condition.clone(), true)])
1077                .unwrap_or_default();
1078            collect_lifecycle_attribute_status(
1079                &self.arena,
1080                *node,
1081                &self.conditions,
1082                attribute == KEY_UPDATE_POLICY,
1083                &mut assumptions,
1084                &mut status,
1085            );
1086        }
1087        cache.insert(cache_key, status.clone());
1088        status
1089    }
1090
1091    /// Expands a lifecycle policy into reachable values while preserving the
1092    /// assumptions for each branch. Whole dynamic leaves are deferred, while a
1093    /// list or object remains invalid regardless of dynamic values nested inside it.
1094    #[must_use]
1095    pub fn lifecycle_policy_scenarios(
1096        &self,
1097        resource_id: &str,
1098        attribute: &str,
1099    ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
1100        let cache_key = (resource_id.to_string(), attribute.to_string());
1101        let mut cache = self.lifecycle_policy_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1102        if let Some(scenarios) = cache.get(&cache_key) {
1103            return scenarios.clone();
1104        }
1105
1106        let scenarios = if !self.resource_condition_is_valid(resource_id) {
1107            Vec::new()
1108        } else {
1109            let resolved = match attribute {
1110                KEY_DELETION_POLICY => self.resources.get(resource_id).and_then(|r| r.deletion_policy.as_ref()),
1111                KEY_UPDATE_REPLACE_POLICY => {
1112                    self.resources.get(resource_id).and_then(|r| r.update_replace_policy.as_ref())
1113                }
1114                _ => None,
1115            };
1116            match resolved {
1117                None => Vec::new(),
1118                Some(value) => {
1119                    let initial_assumptions: HashMap<String, bool> = self
1120                        .resources
1121                        .get(resource_id)
1122                        .and_then(|resource| resource.condition.as_ref())
1123                        .map(|condition| [(condition.clone(), true)].into_iter().collect())
1124                        .unwrap_or_default();
1125                    let assumptions: Vec<_> =
1126                        initial_assumptions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1127                    if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1128                        Vec::new()
1129                    } else {
1130                        self.collect_scenarios_with_budget_from(
1131                            value,
1132                            &initial_assumptions,
1133                            MAX_SCENARIO_COMBINATIONS,
1134                            MAX_TOTAL_SCENARIO_COMBINATIONS,
1135                        )
1136                        .0
1137                        .into_iter()
1138                        .filter_map(|(value, conditions)| {
1139                            let assumptions: Vec<_> =
1140                                conditions.iter().map(|(name, value)| (name.clone(), *value)).collect();
1141                            if !assumptions.is_empty() && !self.conditions.is_satisfiable(&assumptions) {
1142                                return None;
1143                            }
1144                            let value = match value {
1145                                ResolvedValue::Reference { .. }
1146                                | ResolvedValue::Dynamic { .. }
1147                                | ResolvedValue::TypedDynamic { .. } => return None,
1148                                other => crate::serialization::resolved_value_to_json(&other),
1149                            };
1150                            Some((value, conditions))
1151                        })
1152                        .collect()
1153                    }
1154                }
1155            }
1156        };
1157        cache.insert(cache_key, scenarios.clone());
1158        scenarios
1159    }
1160
1161    pub fn resources_of_type(&self, type_name: &str) -> &[String] {
1162        self.resources_by_type.get(type_name).map(|v| v.as_slice()).unwrap_or(&[])
1163    }
1164
1165    #[must_use]
1166    pub fn resource_condition_is_valid(&self, resource_id: &str) -> bool {
1167        !self.invalid_resource_conditions.contains(resource_id)
1168    }
1169
1170    /// Groups resources that can simultaneously resolve to the same primary-identifier tuple.
1171    /// Each displayed tuple is paired with the ordered set of resources participating in at
1172    /// least one satisfiable collision for the corresponding comparison identity.
1173    #[must_use]
1174    pub fn primary_identifier_conflicts(
1175        &self,
1176        resource_type: &str,
1177        identifier_properties: &[String],
1178    ) -> Vec<(Vec<String>, BTreeSet<String>)> {
1179        let mut per_resource = Vec::new();
1180        for resource_id in self.resources_of_type(resource_type) {
1181            let scenarios = self.primary_identifier_scenarios(resource_id, identifier_properties);
1182            if !scenarios.is_empty() {
1183                per_resource.push((resource_id, scenarios));
1184            }
1185        }
1186
1187        let mut conflicts: BTreeMap<Vec<String>, (Vec<String>, BTreeSet<String>)> = BTreeMap::new();
1188        for left_index in 0..per_resource.len() {
1189            for right_index in (left_index + 1)..per_resource.len() {
1190                let (left_resource, left_scenarios) = &per_resource[left_index];
1191                let (right_resource, right_scenarios) = &per_resource[right_index];
1192                for left in left_scenarios {
1193                    for right in right_scenarios {
1194                        if left.identity_tuple != right.identity_tuple {
1195                            continue;
1196                        }
1197                        let mut assumptions = left.assumptions.clone();
1198                        assumptions.extend(right.assumptions.iter().cloned());
1199                        if assumptions.is_empty() || self.conditions.is_satisfiable(&assumptions) {
1200                            let (_, resources) = conflicts
1201                                .entry(left.identity_tuple.clone())
1202                                .or_insert_with(|| (left.display_tuple.clone(), BTreeSet::new()));
1203                            resources.insert((*left_resource).clone());
1204                            resources.insert((*right_resource).clone());
1205                        }
1206                    }
1207                }
1208            }
1209        }
1210        conflicts.into_values().collect()
1211    }
1212
1213    fn primary_identifier_scenarios(
1214        &self,
1215        resource_id: &str,
1216        identifier_properties: &[String],
1217    ) -> Vec<PrimaryIdentifierScenario> {
1218        if !self.resource_condition_is_valid(resource_id) {
1219            return Vec::new();
1220        }
1221        let base_assumptions = self
1222            .resources
1223            .get(resource_id)
1224            .and_then(|resource| resource.condition.as_deref())
1225            .map(|condition| vec![(condition.to_string(), true)])
1226            .unwrap_or_default();
1227        let mut scenarios = vec![PrimaryIdentifierScenario {
1228            identity_tuple: Vec::with_capacity(identifier_properties.len()),
1229            display_tuple: Vec::with_capacity(identifier_properties.len()),
1230            assumptions: base_assumptions,
1231        }];
1232
1233        for property in identifier_properties {
1234            let path = format!("Properties.{property}");
1235            let property_scenarios = self.resolve_scenarios_json(resource_id, &path);
1236            let expression_identity =
1237                self.value_identity(resource_id, &path).filter(|identity| identity.starts_with("expr:"));
1238            let effective_scenarios: Vec<(String, String, HashMap<String, bool>)> = if property_scenarios.is_empty()
1239                || (expression_identity.is_some()
1240                    && property_scenarios.iter().all(|(_, conditions)| conditions.is_empty()))
1241            {
1242                expression_identity
1243                    .map(|identity| {
1244                        let display = identity.strip_prefix("expr:").unwrap_or(&identity).to_string();
1245                        vec![(identity, display, HashMap::new())]
1246                    })
1247                    .unwrap_or_default()
1248            } else {
1249                property_scenarios
1250                    .into_iter()
1251                    .filter_map(|(value, conditions)| {
1252                        if value.is_null() {
1253                            return None;
1254                        }
1255                        let display = value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| value.to_string());
1256                        let identity = format!("value:{}", crate::value_identity::concrete_value_fingerprint(&value));
1257                        Some((identity, display, conditions))
1258                    })
1259                    .collect()
1260            };
1261            let mut next = Vec::new();
1262            for existing in &scenarios {
1263                for (identity, display, conditions) in &effective_scenarios {
1264                    let mut assumptions = existing.assumptions.clone();
1265                    let mut consistent = true;
1266                    for (condition, truth) in conditions {
1267                        if let Some((_, prior)) = assumptions.iter().find(|(name, _)| name == condition) {
1268                            if prior != truth {
1269                                consistent = false;
1270                                break;
1271                            }
1272                        } else {
1273                            assumptions.push((condition.clone(), *truth));
1274                        }
1275                    }
1276                    if consistent {
1277                        let mut identity_tuple = existing.identity_tuple.clone();
1278                        identity_tuple.push(identity.clone());
1279                        let mut display_tuple = existing.display_tuple.clone();
1280                        display_tuple.push(display.clone());
1281                        next.push(PrimaryIdentifierScenario { identity_tuple, display_tuple, assumptions });
1282                    }
1283                }
1284            }
1285            scenarios = next;
1286            if scenarios.is_empty() {
1287                break;
1288            }
1289        }
1290
1291        scenarios.retain(|scenario| {
1292            scenario.identity_tuple.len() == identifier_properties.len()
1293                && (scenario.assumptions.is_empty() || self.conditions.is_satisfiable(&scenario.assumptions))
1294        });
1295        scenarios
1296    }
1297
1298    #[must_use]
1299    pub fn condition_is_valid_for_reachability(&self, condition: &str) -> bool {
1300        !self.invalid_inline_conditions.contains(condition)
1301    }
1302
1303    #[must_use]
1304    pub fn resolve(&self, resource_id: &str, path: &str) -> Option<&ResolvedValue> {
1305        let resource = self.resources.get(resource_id)?;
1306        resource.properties.get(path.strip_prefix("Properties.").unwrap_or(path))
1307    }
1308
1309    #[must_use]
1310    pub fn resolve_deep(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1311        let memo_key = (resource_id.to_string(), path.to_string());
1312        if let Some(memoized) = self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&memo_key)
1313        {
1314            return memoized.clone();
1315        }
1316        let resource = self.resources.get(resource_id)?;
1317        let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
1318        let mut segments = prop_path.splitn(2, '.');
1319        let top_key = segments.next()?;
1320        let resolved = resource.properties.get(top_key)?;
1321        let result = match segments.next() {
1322            Some(r) if !r.is_empty() => resolved_value_at_path(resolved, r),
1323            _ => Some(resolved.clone()),
1324        };
1325        self.resolve_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).insert(memo_key, result.clone());
1326        result
1327    }
1328
1329    #[must_use]
1330    pub fn is_from_parameter(&self, resource_id: &str, path: &str) -> bool {
1331        self.parameter_name_at(resource_id, path).is_some()
1332    }
1333
1334    pub(crate) fn authored_sub_template_at(&self, resource_id: &str, path: &str) -> Option<&str> {
1335        let resource_id = resource_id.to_string();
1336        let mut current = path;
1337        loop {
1338            if let Some(node) = self.value_nodes.get(&(resource_id.clone(), current.to_string()))
1339                && let Node::Intrinsic(IntrinsicFn::Sub(template, _)) = self.arena.node(*node)
1340            {
1341                return Some(template.as_str());
1342            }
1343            let (parent, _) = current.rsplit_once('.')?;
1344            current = parent;
1345        }
1346    }
1347
1348    /// The parameter whose declaration stood in for the value at `path`, or `None`
1349    /// when the value came from somewhere else. A value that is only known at
1350    /// deployment is not necessarily a parameter - a cross-stack import and a
1351    /// dynamic reference are equally unknown - so callers that describe a value to
1352    /// a reader must ask rather than assume.
1353    #[must_use]
1354    pub fn parameter_name_at(&self, resource_id: &str, path: &str) -> Option<&str> {
1355        let source = self.resolution_sources.get(&(resource_id.to_string(), path.to_string()))?;
1356        parameter_name_from_source(source)
1357    }
1358
1359    /// A key that two values share only when they are provably the same value, or
1360    /// `None` when nothing about the value settles the question.
1361    ///
1362    /// A value that resolves before deployment is keyed by its contents, so the
1363    /// authored form does not matter. A value that stays opaque is keyed by the
1364    /// expression that produces it, because that is the only thing that can show
1365    /// two such values are one value. Callers that compare keys must treat `None`
1366    /// and two differing keys alike: neither proves the values differ, so neither
1367    /// permits a claim that they are the same.
1368    #[must_use]
1369    pub fn value_identity(&self, resource_id: &str, path: &str) -> Option<String> {
1370        let resolved = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
1371        self.resolved_value_identity(resource_id, path, &resolved)
1372    }
1373
1374    /// Builds an identity for an already-resolved value at its authored path.
1375    /// A concrete result that still depends on a reference uses the complete
1376    /// expression, because a parameter default may be overridden at deployment.
1377    pub(crate) fn resolved_value_identity(
1378        &self,
1379        resource_id: &str,
1380        path: &str,
1381        resolved: &ResolvedValue,
1382    ) -> Option<String> {
1383        let reference_prefix = format!("{path}.");
1384        let depends_on_reference = self
1385            .graph
1386            .outgoing(resource_id)
1387            .iter()
1388            .any(|edge| edge.source_path == path || edge.source_path.starts_with(&reference_prefix));
1389        let as_json = crate::serialization::resolved_value_to_json(resolved);
1390        if !depends_on_reference && !json_contains_markers(&as_json) {
1391            let fingerprint = crate::value_identity::concrete_value_fingerprint(&as_json);
1392            return Some(format!("value:{fingerprint}"));
1393        }
1394        let node = *self.value_nodes.get(&(resource_id.to_string(), path.to_string()))?;
1395        let (result, depth_exhausted) = crate::value_identity::expression_fingerprint_signaled(&self.arena, node);
1396        if depth_exhausted {
1397            self.budget_tracker.record(BudgetKind::ExpressionFingerprintDepth);
1398        }
1399        result.map(|fingerprint| format!("expr:{fingerprint}"))
1400    }
1401
1402    /// True when the value at `path` (or any ancestor up to the resource root) was
1403    /// produced by an intrinsic function. Used by rules that skip hardcoded-literal
1404    /// checks when the property value comes from `Fn::GetAZs`, `Ref`, etc.
1405    #[must_use]
1406    pub fn is_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
1407        if self.path_from_intrinsic(resource_id, path) {
1408            return true;
1409        }
1410        // Properties wrapped in Fn::If store per-branch intrinsic sources
1411        // under branch-qualified paths. When a caller queries the bare
1412        // `Properties.<prop>` path, also consult the branch-qualified
1413        // variants so intrinsic-sourced values inside either branch are
1414        // still recognised.
1415        let properties_prefix = format!("{}.", KEY_PROPERTIES);
1416        if let Some(rest) = path.strip_prefix(&properties_prefix) {
1417            for branch in ["1", "2"] {
1418                let branch_path = format!("{}.{}.{}.{}", KEY_PROPERTIES, FN_IF, branch, rest);
1419                if self.path_from_intrinsic(resource_id, &branch_path) {
1420                    return true;
1421                }
1422            }
1423        }
1424        false
1425    }
1426
1427    #[must_use]
1428    pub fn substituted_paths_under(&self, resource_id: &str, base_path: &str) -> HashSet<String> {
1429        let prefix = format!("{}.", base_path);
1430        let relative_path = |path: &str| {
1431            if path == base_path { Some(String::new()) } else { path.strip_prefix(&prefix).map(String::from) }
1432        };
1433        let mut paths: HashSet<String> =
1434            self.graph.outgoing(resource_id).into_iter().filter_map(|edge| relative_path(&edge.source_path)).collect();
1435        paths.extend(self.resolution_sources.iter().filter_map(|((source_resource, source_path), _)| {
1436            (source_resource == resource_id).then(|| relative_path(source_path)).flatten()
1437        }));
1438        paths
1439    }
1440
1441    fn path_from_intrinsic(&self, resource_id: &str, path: &str) -> bool {
1442        let edges = self.graph.outgoing(resource_id);
1443        let mut p = path.to_string();
1444        loop {
1445            if let Some(src) = self.resolution_sources.get(&(resource_id.to_string(), p.clone()))
1446                && src.starts_with("Intrinsic/")
1447            {
1448                return true;
1449            }
1450            // A reference edge (Ref, GetAtt, Sub) anchored at this path means the
1451            // value is produced by an intrinsic rather than written as a literal.
1452            if edges.iter().any(|e| e.source_path == p) {
1453                return true;
1454            }
1455            match p.rfind('.') {
1456                Some(i) => p.truncate(i),
1457                None => return false,
1458            }
1459        }
1460    }
1461
1462    /// Whether this model has spent its cumulative scenario-expansion budget.
1463    /// Mirrors `ConditionModel::satisfiability_budget_exhausted` for the
1464    /// scenario path: callers that drive many `resolve_scenarios` queries stop
1465    /// producing new scenarios once further expansion would only be truncated.
1466    #[must_use]
1467    pub fn scenario_budget_exhausted(&self) -> bool {
1468        self.scenario_combinations_used.load(Ordering::Relaxed) >= MAX_TOTAL_SCENARIO_COMBINATIONS
1469    }
1470
1471    /// Record that a budget kind was exhausted. Delegates to the shared tracker.
1472    pub fn record_budget_exhaustion(&self, kind: BudgetKind) {
1473        self.budget_tracker.record(kind);
1474    }
1475
1476    /// Returns the set of exhausted budget kinds in deterministic order.
1477    pub fn exhausted_budget_kinds(&self) -> BTreeSet<BudgetKind> {
1478        self.budget_tracker.exhausted_kinds()
1479    }
1480
1481    /// Whether any exhausted budget makes the analysis incomplete.
1482    pub fn budget_analysis_incomplete(&self) -> bool {
1483        self.budget_tracker.analysis_incomplete()
1484    }
1485
1486    /// Cumulative scenarios materialized by this model so far.
1487    #[must_use]
1488    pub fn scenario_combinations_used(&self) -> u64 {
1489        self.scenario_combinations_used.load(Ordering::Relaxed)
1490    }
1491
1492    /// Whether any scenario expansion for this model omitted possible results
1493    /// because a per-value or model-wide analysis limit was reached. The flag is
1494    /// monotonic so callers can distinguish complete expansion from conservative
1495    /// fallback behavior.
1496    #[must_use]
1497    pub fn scenario_expansion_curtailed(&self) -> bool {
1498        self.scenario_expansion_curtailed.load(Ordering::Relaxed)
1499    }
1500
1501    fn collect_scenarios_with_budget_status(
1502        &self,
1503        value: &ResolvedValue,
1504        per_value_limit: usize,
1505        total_limit: u64,
1506    ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1507        self.collect_scenarios_with_budget_from(value, &HashMap::new(), per_value_limit, total_limit)
1508    }
1509
1510    fn collect_scenarios_with_budget_from(
1511        &self,
1512        value: &ResolvedValue,
1513        assumptions: &HashMap<String, bool>,
1514        per_value_limit: usize,
1515        total_limit: u64,
1516    ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1517        let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1518        let used = self.scenario_combinations_used.load(Ordering::Relaxed);
1519        if used >= total_limit {
1520            self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1521            if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS {
1522                self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
1523            }
1524            return (Vec::new(), true);
1525        }
1526
1527        let remaining = total_limit - used;
1528        let remaining_limit = usize::try_from(remaining).unwrap_or(usize::MAX);
1529        let effective_limit = per_value_limit.min(remaining_limit);
1530        let mut scenarios = Vec::new();
1531        let was_curtailed = collect_scenarios(value, assumptions, effective_limit, &mut scenarios);
1532        if was_curtailed {
1533            self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1534            if per_value_limit == MAX_SCENARIO_COMBINATIONS && per_value_limit <= remaining_limit {
1535                self.budget_tracker.record(BudgetKind::ScenarioCombinationsPerValue);
1536            }
1537            if total_limit == MAX_TOTAL_SCENARIO_COMBINATIONS && remaining_limit <= per_value_limit {
1538                self.budget_tracker.record(BudgetKind::ScenarioCombinationsTotal);
1539            }
1540        }
1541        self.scenario_combinations_used.store(used + scenarios.len() as u64, Ordering::Relaxed);
1542        (scenarios, was_curtailed)
1543    }
1544
1545    fn collect_scenarios_with_budget(
1546        &self,
1547        value: &ResolvedValue,
1548        per_value_limit: usize,
1549        total_limit: u64,
1550    ) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1551        self.collect_scenarios_with_budget_status(value, per_value_limit, total_limit).0
1552    }
1553
1554    /// Test-only: advance the cumulative scenario counter directly, so the
1555    /// budget threshold and short-circuit behavior can be exercised without
1556    /// materializing `MAX_TOTAL_SCENARIO_COMBINATIONS` real scenarios.
1557    #[cfg(test)]
1558    pub(crate) fn add_scenario_combinations_for_test(&self, count: u64) {
1559        let _expansion_guard = self.scenario_expansion_lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1560        self.scenario_combinations_used.fetch_add(count, Ordering::Relaxed);
1561    }
1562
1563    /// Test-only: set the curtailment flag directly without needing to
1564    /// materialize a pathological number of real scenarios.
1565    #[cfg(test)]
1566    fn set_scenario_expansion_curtailed_for_test(&self) {
1567        self.scenario_expansion_curtailed.store(true, Ordering::Relaxed);
1568    }
1569
1570    fn resolved_properties_value(&self, resource_id: &str) -> Option<ResolvedValue> {
1571        let resource = self.resources.get(resource_id)?;
1572        if resource.properties_dynamic {
1573            return None;
1574        }
1575        if resource.properties.len() == 1
1576            && let Some(conditional) = resource.properties.get(FN_IF)
1577        {
1578            return Some(conditional.clone());
1579        }
1580        let mut entries: Vec<MapEntry> = resource
1581            .properties
1582            .iter()
1583            .map(|(key, value)| MapEntry { key: key.clone(), value: value.clone() })
1584            .collect();
1585        entries.sort_by(|left, right| left.key.cmp(&right.key));
1586        Some(ResolvedValue::Map { entries })
1587    }
1588
1589    pub fn resolve_properties_scenarios(&self, resource_id: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1590        let mut cache = self.properties_scenario_cache.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1591        if let Some(scenarios) = cache.get(resource_id) {
1592            return scenarios.clone();
1593        }
1594        let Some(properties) = self.resolved_properties_value(resource_id) else {
1595            return vec![];
1596        };
1597        let scenarios =
1598            self.collect_scenarios_with_budget(&properties, MAX_SCENARIO_COMBINATIONS, MAX_TOTAL_SCENARIO_COMBINATIONS);
1599        cache.insert(resource_id.to_string(), scenarios.clone());
1600        scenarios
1601    }
1602
1603    /// Returns the authored, branch-qualified source path for an effective path in
1604    /// one resolved scenario. The returned path uses the same dotted form as
1605    /// resolver source paths and can be passed to [`Self::resource_span`].
1606    pub fn scenario_source_path(
1607        &self,
1608        resource_id: &str,
1609        effective_path: &str,
1610        conditions: &HashMap<String, bool>,
1611    ) -> Option<String> {
1612        let properties = self.resolved_properties_value(resource_id)?;
1613        let relative_path = effective_path
1614            .strip_prefix("Properties.")
1615            .or_else(|| (effective_path == KEY_PROPERTIES).then_some(""))
1616            .unwrap_or(effective_path);
1617        scenario_source_path_at(&properties, relative_path, conditions, KEY_PROPERTIES)
1618    }
1619
1620    pub fn resolve_scenarios(&self, resource_id: &str, path: &str) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
1621        let key = (resource_id.to_string(), path.to_string());
1622        let mut memo = self.raw_scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1623        if let Some(scenarios) = memo.get(&key) {
1624            return scenarios.clone();
1625        }
1626        let scenarios = self.resolve_scenarios_with_limit(resource_id, path, MAX_SCENARIO_COMBINATIONS).0;
1627        memo.insert(key, scenarios.clone());
1628        scenarios
1629    }
1630
1631    pub fn resolve_scenarios_with_limit(
1632        &self,
1633        resource_id: &str,
1634        path: &str,
1635        per_value_limit: usize,
1636    ) -> (Vec<(ResolvedValue, HashMap<String, bool>)>, bool) {
1637        let value = match self.resolve_deep(resource_id, path) {
1638            Some(value) => value,
1639            None => match self.resolve(resource_id, path) {
1640                Some(value) => value.clone(),
1641                None => match self.resolve_via_properties_if(resource_id, path) {
1642                    Some(value) => value,
1643                    None => return (Vec::new(), false),
1644                },
1645            },
1646        };
1647        self.collect_scenarios_with_budget_status(&value, per_value_limit, MAX_TOTAL_SCENARIO_COMBINATIONS)
1648    }
1649
1650    /// Fallback lookup when `Properties` is wrapped in an `Fn::If`: walks
1651    /// the caller's path inside the synthetic `Fn::If` branch so scenario
1652    /// resolution still reaches properties that only exist conditionally.
1653    fn resolve_via_properties_if(&self, resource_id: &str, path: &str) -> Option<ResolvedValue> {
1654        let resource = self.resources.get(resource_id)?;
1655        let conditional = resource.properties.get(FN_IF)?;
1656        let properties_prefix = format!("{}.", KEY_PROPERTIES);
1657        let prop_path = path.strip_prefix(&properties_prefix).unwrap_or(path);
1658        if prop_path.is_empty() {
1659            return Some(conditional.clone());
1660        }
1661        resolved_value_at_path(conditional, prop_path)
1662    }
1663
1664    pub fn resolve_scenarios_json(
1665        &self,
1666        resource_id: &str,
1667        path: &str,
1668    ) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
1669        let memo_key = (resource_id.to_string(), path.to_string());
1670        {
1671            let memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1672            if let Some(memoized) = memo.get(&memo_key) {
1673                return memoized.clone();
1674            }
1675        }
1676        let scenarios = self.resolve_scenarios(resource_id, path);
1677        let json_scenarios: Vec<_> = scenarios
1678            .into_iter()
1679            .filter_map(|(val, conds)| {
1680                if contains_dynamic_resolved(&val) {
1681                    return None;
1682                }
1683                if !conds.is_empty() {
1684                    let assumptions: Vec<(String, bool)> = conds.iter().map(|(k, v)| (k.clone(), *v)).collect();
1685                    if !self.conditions.is_satisfiable(&assumptions) {
1686                        return None;
1687                    }
1688                }
1689                let json = crate::serialization::resolved_value_to_json_clean(&val)?;
1690                if json_contains_markers(&json) {
1691                    return None;
1692                }
1693                Some((json, conds))
1694            })
1695            .collect();
1696        let mut memo = self.scenario_memo.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
1697        memo.entry(memo_key).or_insert_with(|| json_scenarios).clone()
1698    }
1699
1700    pub fn follow_ref(&self, resource_id: &str, path: &str) -> Option<&str> {
1701        if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve(resource_id, path) {
1702            return Some(target.as_str());
1703        }
1704        if let Some(ResolvedValue::Reference { target, kind: _ }) = self.resolve_deep(resource_id, path).as_ref() {
1705            for edge in self.graph.outgoing(resource_id) {
1706                if edge.target == *target {
1707                    return Some(&edge.target);
1708                }
1709            }
1710        }
1711        None
1712    }
1713
1714    pub fn source_location(&self, path: &str) -> Option<&SourceSpan> {
1715        self.span_index.get(path)
1716    }
1717
1718    /// Returns the source span for a resource property path, falling back to
1719    /// the resource-level span if the specific path is not found.
1720    ///
1721    /// The span index is keyed with slash-separated paths, so a dotted,
1722    /// resource-relative path (`Properties.BucketName`) is normalized to slash
1723    /// form before lookup - the same normalization [`Self::diagnostic_span`]
1724    /// applies. Callers pass paths in either form, so accepting only slash form
1725    /// here would silently mislocate every dotted-path diagnostic onto the
1726    /// resource declaration line.
1727    pub fn resource_span(&self, resource_id: &str, prop_path: &str) -> SourceSpan {
1728        // An empty resource id means the path is already a section-absolute span-index
1729        // key (e.g. an output's `Outputs/X/Value.Fn::Join`); prefixing it with
1730        // `Resources/` would mislocate the finding onto the Resources block. Resolve it
1731        // as-is (no dot-to-slash conversion, since dots in segment names like `Fn::Join`
1732        // are literal, not path separators). Returns UNKNOWN when nothing resolves so
1733        // callers fall back to section-level or backfill-based location.
1734        if resource_id.is_empty() {
1735            return self.walk_up_span(prop_path).unwrap_or(UNKNOWN_SPAN);
1736        }
1737        // Resolver paths preserve literal dots and slashes inside map keys, so converting
1738        // them into the slash-keyed index can be ambiguous. A terminal intrinsic can be
1739        // resolved through its exact authored parent path without lossy conversion.
1740        if let Some((authored_path, function_name)) = prop_path.rsplit_once('.')
1741            && let Some(node_ref) = self.value_nodes.get(&(resource_id.to_string(), authored_path.to_string()))
1742            && let Node::Intrinsic(intrinsic) = self.arena.node(*node_ref)
1743            && cfn_function_name(intrinsic) == function_name
1744        {
1745            let intrinsic_path = format!("{}/{}", self.arena.get(*node_ref).path, function_name);
1746            if let Some(span) = self.walk_up_span(&intrinsic_path) {
1747                return span;
1748            }
1749        }
1750        let specific = if prop_path.is_empty() {
1751            format!("Resources/{}", resource_id)
1752        } else {
1753            format!("Resources/{}/{}", resource_id, prop_path.replace('.', "/"))
1754        };
1755        // Walk up from the exact path to the nearest indexed ancestor, so a leaf that
1756        // carries no span of its own - a synthetic intrinsic key (`…/Topic/Fn::Sub`),
1757        // an `Fn::If` branch index - anchors at its closest real parent rather than
1758        // collapsing straight to the resource declaration. The resource path itself is
1759        // the final ancestor, preserving the previous resource-level fallback.
1760        self.walk_up_span(&specific).unwrap_or(UNKNOWN_SPAN)
1761    }
1762
1763    /// Walks up `key` (a `/`-separated span-index path), trimming one trailing
1764    /// segment at a time, and returns the first ancestor with a known span. This
1765    /// anchors a diagnostic as close to the offending node as the index allows -
1766    /// an unindexed leaf (e.g. an `Fn::If` branch index) falls back to its parent
1767    /// property, then the resource, then the section.
1768    fn walk_up_span(&self, key: &str) -> Option<SourceSpan> {
1769        let mut current = key;
1770        loop {
1771            // A key can be present in the index but mapped to UNKNOWN_SPAN (an
1772            // interior node whose byte span was never assigned). Treat that as
1773            // "not found" and keep walking up to the nearest ancestor that does
1774            // have a real span, rather than returning the unusable UNKNOWN.
1775            match self.span_index.get(current) {
1776                Some(&span) if span != UNKNOWN_SPAN => return Some(span),
1777                _ => match current.rfind('/') {
1778                    Some(cut) => current = &current[..cut],
1779                    None => return None,
1780                },
1781            }
1782        }
1783    }
1784
1785    /// Best-effort span for a diagnostic identified by its optional resource and
1786    /// property path, walking up to the nearest indexed ancestor.
1787    ///
1788    /// The path form disambiguates how to root it, which matters because a segment
1789    /// like `Metadata` names both a top-level section and a resource property:
1790    /// * A **slash** form (`Outputs/X/Value`, `Conditions/C/Fn::And`) is already an
1791    ///   absolute, section-rooted span-index key and is resolved as written.
1792    /// * A **bare path with no resource** (`BogusSection`) is a top-level key and is
1793    ///   also resolved directly.
1794    /// * A **dotted** or bare form with a resource (`Properties.Foo`, `Metadata`) is
1795    ///   relative to that resource, so it is rooted at `Resources/<rid>` before
1796    ///   lookup - never matched against a same-named top-level section.
1797    ///
1798    /// Returns `None` when nothing along the chosen candidate is indexed, so callers
1799    /// can fall back to a section span.
1800    pub fn diagnostic_span(&self, resource_id: Option<&str>, property_path: &str) -> Option<SourceSpan> {
1801        let rid = resource_id.filter(|r| !r.is_empty());
1802
1803        if property_path.contains('/') || (rid.is_none() && !property_path.is_empty()) {
1804            // Absolute, section-rooted path: resolve directly.
1805            if let Some(span) = self.walk_up_span(property_path) {
1806                return Some(span);
1807            }
1808        } else if let Some(rid) = rid {
1809            // Resource-relative path (dotted or bare): root at the resource so a
1810            // property named after a top-level section cannot mislocate onto it.
1811            let key = if property_path.is_empty() {
1812                format!("Resources/{}", rid)
1813            } else {
1814                format!("Resources/{}/{}", rid, property_path.replace('.', "/"))
1815            };
1816            if let Some(span) = self.walk_up_span(&key) {
1817                return Some(span);
1818            }
1819        }
1820
1821        // Last resort: the bare resource span, when a resource id is known.
1822        rid.and_then(|rid| self.span_index.get(&format!("Resources/{}", rid)))
1823            .filter(|span| **span != UNKNOWN_SPAN)
1824            .copied()
1825    }
1826
1827    /// Bounds on the length of a string value the template does not state
1828    /// literally - one built by an intrinsic, or chosen from a parameter's
1829    /// AllowedValues. `None` when the length cannot be pinned for every
1830    /// possibility, and also when the template does state the value literally,
1831    /// because schema validation checks a literal against the constraint directly.
1832    ///
1833    /// Both engines decide what to report from this one answer, so neither can
1834    /// reach a different conclusion about the same property.
1835    pub fn estimated_string_length_bounds(&self, resource_id: &str, path: &str) -> Option<(usize, usize)> {
1836        let val = self.resolve_deep(resource_id, path).or_else(|| self.resolve(resource_id, path).cloned())?;
1837        if matches!(&val, ResolvedValue::Concrete { value } if value.is_string()) {
1838            return None;
1839        }
1840        estimate_resolved_string_length_bounds(&val)
1841    }
1842}
1843
1844impl SpanProvider for SemanticModel {
1845    fn source_location(&self, path: &str) -> Option<SourceSpan> {
1846        self.span_index.get(path).copied()
1847    }
1848}
1849
1850fn parse_rules(rules_json: &Option<serde_json::Value>, arena: &Arena, rules_node: NodeRef) -> Vec<TemplateRule> {
1851    let Some(rules) = rules_json else {
1852        return Vec::new();
1853    };
1854    let Some(obj) = rules.as_object() else {
1855        return Vec::new();
1856    };
1857
1858    // Build a lookup from rule name → NodeRef for the rule's IR subtree
1859    let rule_nodes: HashMap<&str, NodeRef> =
1860        arena.as_map(rules_node).unwrap_or(&[]).iter().map(|(k, v)| (k.as_str(), *v)).collect();
1861
1862    obj.iter()
1863        .filter_map(|(name, rule)| {
1864            let rule_obj = rule.as_object()?;
1865            let condition = rule_obj.get(KEY_RULE_CONDITION).cloned();
1866
1867            // Look up the IR node for this rule to find condition/assertion NodeRefs
1868            let rule_ir = rule_nodes.get(name.as_str()).copied().unwrap_or(NULL_REF);
1869            let condition_node = arena.map_get(rule_ir, KEY_RULE_CONDITION).unwrap_or(NULL_REF);
1870
1871            let assertions_node = arena.map_get(rule_ir, KEY_ASSERTIONS).unwrap_or(NULL_REF);
1872            let assertion_items = arena.as_list(assertions_node).unwrap_or(&[]);
1873
1874            let assertions = rule_obj
1875                .get(KEY_ASSERTIONS)
1876                .and_then(|a| a.as_array())
1877                .map(|arr| {
1878                    arr.iter()
1879                        .enumerate()
1880                        .filter_map(|(idx, a)| {
1881                            let a_obj = a.as_object()?;
1882                            let assert_node = assertion_items
1883                                .get(idx)
1884                                .and_then(|item_ref| arena.map_get(*item_ref, KEY_ASSERT))
1885                                .unwrap_or(NULL_REF);
1886                            Some(RuleAssertion {
1887                                assert: a_obj.get(KEY_ASSERT).cloned().unwrap_or(serde_json::Value::Null),
1888                                assert_node,
1889                                description: a_obj
1890                                    .get(KEY_ASSERT_DESCRIPTION)
1891                                    .and_then(|d| d.as_str())
1892                                    .map(String::from),
1893                            })
1894                        })
1895                        .collect()
1896                })
1897                .unwrap_or_default();
1898            Some(TemplateRule { name: name.clone(), condition, condition_node, assertions })
1899        })
1900        .collect()
1901}
1902
1903/// Collect parameter names that are referenced (via `Ref`/`Fn::Sub`) from within
1904/// another parameter's definition. These references still count as usage for the
1905/// unused-parameter check even though they originate in the Parameters section.
1906fn collect_parameter_definition_refs(ir: &TemplateIR, parameters: &HashMap<String, ParameterInfo>) -> HashSet<String> {
1907    let mut referenced = HashSet::new();
1908    if ir.parameters == NULL_REF {
1909        return referenced;
1910    }
1911    let Some(param_entries) = ir.arena.as_map(ir.parameters) else {
1912        return referenced;
1913    };
1914    for (_, param_ref) in param_entries {
1915        collect_refs_in_subtree(&ir.arena, *param_ref, parameters, &mut referenced);
1916    }
1917    referenced
1918}
1919
1920fn collect_refs_in_subtree(
1921    arena: &Arena,
1922    node_ref: NodeRef,
1923    parameters: &HashMap<String, ParameterInfo>,
1924    referenced: &mut HashSet<String>,
1925) {
1926    match arena.node(node_ref) {
1927        Node::Intrinsic(IntrinsicFn::Ref(target)) => {
1928            if parameters.contains_key(target) {
1929                referenced.insert(target.clone());
1930            }
1931        }
1932        Node::Intrinsic(IntrinsicFn::Sub(_, Some(bindings))) => {
1933            for (_, v) in bindings {
1934                collect_refs_in_subtree(arena, *v, parameters, referenced);
1935            }
1936        }
1937        Node::List(items) => {
1938            for item in items {
1939                collect_refs_in_subtree(arena, *item, parameters, referenced);
1940            }
1941        }
1942        Node::Map(entries) => {
1943            for (_, v) in entries {
1944                collect_refs_in_subtree(arena, *v, parameters, referenced);
1945            }
1946        }
1947        _ => {}
1948    }
1949}
1950
1951fn parameter_name_from_source(source: &str) -> Option<&str> {
1952    let rest = source.strip_prefix(SECTION_PARAMETERS)?.strip_prefix('/')?;
1953    let name = rest.split('/').next()?;
1954    if name.is_empty() { None } else { Some(name) }
1955}
1956
1957/// Some intrinsic nodes stand in for a whole object - most notably
1958/// `Properties: {Fn::If: [...]}` which the parser folds into an
1959/// `IntrinsicFn::If` node. Return the CloudFormation function-name key
1960/// (`Fn::If`, `Fn::ForEach::*`, etc.) when the node is one of these
1961/// object-wrapping intrinsics, so downstream resolution can address the
1962/// conditional by a synthetic path.
1963fn intrinsic_synthetic_key(arena: &Arena, node_ref: NodeRef) -> Option<String> {
1964    let Node::Intrinsic(func) = arena.node(node_ref) else {
1965        return None;
1966    };
1967    match func {
1968        IntrinsicFn::If(_, _, _) | IntrinsicFn::IfExpr(_, _, _) => Some(FN_IF.to_string()),
1969        IntrinsicFn::ForEach(uid, _, _, _) => Some(format!("{}::{}", FN_FOR_EACH, uid)),
1970        _ => None,
1971    }
1972}
1973
1974/// Valid resource-level attributes per the CloudFormation template anatomy.
1975/// These are the keys CloudFormation accepts directly under a resource's logical
1976/// ID in the `Resources` section.
1977const VALID_RESOURCE_ATTRIBUTES: &[&str] = &[
1978    KEY_TYPE,
1979    KEY_PROPERTIES,
1980    KEY_DEPENDS_ON,
1981    KEY_CONDITION,
1982    SECTION_METADATA,
1983    KEY_DELETION_POLICY,
1984    KEY_UPDATE_REPLACE_POLICY,
1985    KEY_UPDATE_POLICY,
1986    KEY_CREATION_POLICY,
1987    FN_TRANSFORM,
1988    "Version",
1989];
1990
1991/// Additional resource-level attributes valid only when the SAM transform is
1992/// declared. These are SAM-specific extensions that the Serverless transform
1993/// processes before CloudFormation sees the resource.
1994const SAM_RESOURCE_ATTRIBUTES: &[&str] = &["Connectors", "IgnoreGlobals"];
1995
1996fn invalid_resource_condition_ref(arena: &Arena, node_ref: NodeRef) -> Option<NodeRef> {
1997    let entries = arena.as_map(node_ref)?;
1998    let condition_ref = entries.iter().find(|(key, _)| key == KEY_CONDITION).map(|(_, value)| *value)?;
1999    (!matches!(arena.node(condition_ref), Node::String(_))).then_some(condition_ref)
2000}
2001
2002/// Validates the structural shape of a resource body, producing diagnostics for
2003/// bodies that CloudFormation would reject: a non-object resource, a missing
2004/// `Type`, or unknown resource-level attribute keys.
2005fn validate_resource_shape(
2006    arena: &Arena,
2007    name: &str,
2008    node_ref: NodeRef,
2009    is_sam: bool,
2010    span_index: &SourceSpanIndex,
2011) -> Vec<ParseDefect> {
2012    let mut out = Vec::new();
2013    let resource_span = span_index.get(&format!("Resources/{}", name)).copied().unwrap_or(UNKNOWN_SPAN);
2014
2015    // The resource body must be an object.
2016    let entries = match arena.as_map(node_ref) {
2017        Some(entries) => entries,
2018        None => {
2019            let shape = match arena.node(node_ref) {
2020                Node::Null => "null",
2021                Node::Bool(_) => "a boolean",
2022                Node::Int(_) | Node::Float(_) => "a number",
2023                Node::String(_) => "a string",
2024                Node::List(_) => "a list",
2025                Node::Intrinsic(_) => "an intrinsic function",
2026                Node::Map(_) => return out,
2027            };
2028            out.push(crate::make_parse_defect_for_resource(
2029                "E3001",
2030                format!("Resource '{}' body must be an object, got {}", name, shape),
2031                resource_span,
2032                name,
2033            ));
2034            return out;
2035        }
2036    };
2037
2038    // `Type` is required and must be a string.
2039    match entries.iter().find(|(k, _)| k == KEY_TYPE) {
2040        None => {
2041            out.push(crate::make_parse_defect_for_resource(
2042                "E3001",
2043                format!("Resource '{}' is missing required property 'Type'", name),
2044                resource_span,
2045                name,
2046            ));
2047        }
2048        Some((_, type_ref)) if !matches!(arena.node(*type_ref), Node::String(_)) => {
2049            let type_span = span_index.get(&format!("Resources/{}/Type", name)).copied().unwrap_or(resource_span);
2050            out.push(crate::make_parse_defect_for_resource(
2051                "E3001",
2052                format!("Resource '{}' property 'Type' must be a string", name),
2053                type_span,
2054                name,
2055            ));
2056        }
2057        _ => {}
2058    }
2059
2060    // `Condition` must be a string when present.
2061    if invalid_resource_condition_ref(arena, node_ref).is_some() {
2062        let cond_span = span_index.get(&format!("Resources/{}/Condition", name)).copied().unwrap_or(resource_span);
2063        out.push(crate::make_parse_defect_for_resource(
2064            "E3001",
2065            format!("Resource '{}' property 'Condition' must be a string", name),
2066            cond_span,
2067            name,
2068        ));
2069    }
2070
2071    // `DependsOn` must be a string or a list of strings when present.
2072    if let Some((_, dep_ref)) = entries.iter().find(|(k, _)| k == KEY_DEPENDS_ON) {
2073        match arena.node(*dep_ref) {
2074            Node::String(_) => {}
2075            Node::List(items) => {
2076                for item_ref in items {
2077                    if !matches!(arena.node(*item_ref), Node::String(_)) {
2078                        let dep_span =
2079                            span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
2080                        out.push(crate::make_parse_defect_for_resource(
2081                            "E3001",
2082                            format!("Resource '{}' property 'DependsOn' list elements must be strings", name),
2083                            dep_span,
2084                            name,
2085                        ));
2086                        break;
2087                    }
2088                }
2089            }
2090            _ => {
2091                let dep_span =
2092                    span_index.get(&format!("Resources/{}/DependsOn", name)).copied().unwrap_or(resource_span);
2093                out.push(crate::make_parse_defect_for_resource(
2094                    "E3001",
2095                    format!("Resource '{}' property 'DependsOn' must be a string or list of strings", name),
2096                    dep_span,
2097                    name,
2098                ));
2099            }
2100        }
2101    }
2102
2103    let resource_type = entries.iter().find(|(key, _)| key == KEY_TYPE).and_then(|(_, value)| arena.as_str(*value));
2104    if let Some(resource_type) = resource_type {
2105        let is_custom_resource = is_custom_resource_type(resource_type);
2106        if let Some((_, version_ref)) = entries.iter().find(|(key, _)| key == "Version") {
2107            let version_span = span_index.get(&format!("Resources/{}/Version", name)).copied().unwrap_or(resource_span);
2108            if !is_custom_resource {
2109                out.push(crate::make_parse_defect_for_resource(
2110                    "E3001",
2111                    format!("Resource '{}' property 'Version' is only valid for custom resources", name),
2112                    version_span,
2113                    name,
2114                ));
2115            } else if !matches!(arena.node(*version_ref), Node::String(_) | Node::Int(_)) {
2116                out.push(crate::make_parse_defect_for_resource(
2117                    "E3001",
2118                    format!("Resource '{}' property 'Version' must be a string or integer", name),
2119                    version_span,
2120                    name,
2121                ));
2122            }
2123        }
2124
2125        for policy_name in [KEY_CREATION_POLICY, KEY_UPDATE_POLICY] {
2126            let Some((_, policy_ref)) = entries.iter().find(|(key, _)| key == policy_name) else {
2127                continue;
2128            };
2129            let policy_span =
2130                span_index.get(&format!("Resources/{}/{}", name, policy_name)).copied().unwrap_or(resource_span);
2131            if is_custom_resource {
2132                out.push(crate::make_parse_defect_for_resource(
2133                    "E3001",
2134                    format!("Resource '{}' property '{}' is not valid for custom resources", name, policy_name),
2135                    policy_span,
2136                    name,
2137                ));
2138            } else if !matches!(arena.node(*policy_ref), Node::Map(_) | Node::Intrinsic(_)) {
2139                out.push(crate::make_parse_defect_for_resource(
2140                    "E3001",
2141                    format!("Resource '{}' property '{}' must be an object", name, policy_name),
2142                    policy_span,
2143                    name,
2144                ));
2145            }
2146        }
2147    }
2148
2149    for (key, _) in entries {
2150        if VALID_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
2151            continue;
2152        }
2153        if is_sam && SAM_RESOURCE_ATTRIBUTES.contains(&key.as_str()) {
2154            continue;
2155        }
2156        let key_span = span_index.get(&format!("Resources/{}/{}", name, key)).copied().unwrap_or(resource_span);
2157        out.push(crate::make_parse_defect_for_resource(
2158            "E3001",
2159            format!(
2160                "Resource '{}' has invalid property '{}'. Valid resource attributes: {}",
2161                name,
2162                key,
2163                valid_attributes_display(is_sam),
2164            ),
2165            key_span,
2166            name,
2167        ));
2168    }
2169
2170    out
2171}
2172
2173/// Builds the human-readable list of valid resource attributes for error messages.
2174fn valid_attributes_display(is_sam: bool) -> String {
2175    let mut attrs: Vec<&str> = VALID_RESOURCE_ATTRIBUTES.to_vec();
2176    if is_sam {
2177        attrs.extend_from_slice(SAM_RESOURCE_ATTRIBUTES);
2178    }
2179    attrs.join(", ")
2180}
2181
2182fn collect_lifecycle_attribute_status(
2183    arena: &Arena,
2184    node: NodeRef,
2185    conditions: &ConditionModel,
2186    allows_no_value_omission: bool,
2187    assumptions: &mut Vec<(String, bool)>,
2188    status: &mut LifecycleAttributeStatus,
2189) {
2190    if matches!(conditions.satisfiability(assumptions), Satisfiability::Unsatisfiable) {
2191        return;
2192    }
2193
2194    if !allows_no_value_omission {
2195        status.may_be_present = true;
2196        if matches!(
2197            arena.node(node),
2198            Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_)
2199        ) && status.invalid_value.is_none()
2200        {
2201            status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
2202        }
2203        return;
2204    }
2205
2206    match arena.node(node) {
2207        Node::Intrinsic(IntrinsicFn::Ref(target)) if target == PSEUDO_NO_VALUE => {}
2208        Node::Intrinsic(IntrinsicFn::If(condition, when_true, when_false)) => {
2209            assumptions.push((condition.clone(), true));
2210            collect_lifecycle_attribute_status(
2211                arena,
2212                *when_true,
2213                conditions,
2214                allows_no_value_omission,
2215                assumptions,
2216                status,
2217            );
2218            assumptions.pop();
2219            assumptions.push((condition.clone(), false));
2220            collect_lifecycle_attribute_status(
2221                arena,
2222                *when_false,
2223                conditions,
2224                allows_no_value_omission,
2225                assumptions,
2226                status,
2227            );
2228            assumptions.pop();
2229        }
2230        Node::Intrinsic(_) | Node::Map(_) => status.may_be_present = true,
2231        Node::Null | Node::Bool(_) | Node::Int(_) | Node::Float(_) | Node::String(_) | Node::List(_) => {
2232            status.may_be_present = true;
2233            if status.invalid_value.is_none() {
2234                status.invalid_value = Some(crate::message::render_value(&node_to_json(arena, node)));
2235            }
2236        }
2237    }
2238}
2239
2240fn resolve_resource(arena: &Arena, name: &str, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedResource {
2241    let entries = arena.as_map(node_ref).unwrap_or(&[]);
2242    let resource_type =
2243        entries.iter().find(|(k, _)| k == KEY_TYPE).and_then(|(_, v)| arena.as_str(*v)).unwrap_or("").to_string();
2244    let condition =
2245        entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
2246    let depends_on = entries
2247        .iter()
2248        .find(|(k, _)| k == KEY_DEPENDS_ON)
2249        .map(|(_, v)| match arena.node(*v) {
2250            Node::String(s) => vec![s.clone()],
2251            Node::List(items) => items.iter().filter_map(|r| arena.as_str(*r).map(|s| s.to_string())).collect(),
2252            _ => vec![],
2253        })
2254        .unwrap_or_default();
2255    let deletion_policy =
2256        entries.iter().find(|(k, _)| k == KEY_DELETION_POLICY).map(|(_, v)| resolver.resolve_node(*v));
2257    let update_replace_policy =
2258        entries.iter().find(|(k, _)| k == KEY_UPDATE_REPLACE_POLICY).map(|(_, v)| resolver.resolve_node(*v));
2259    let metadata_ref = entries.iter().find(|(k, _)| k == SECTION_METADATA).map(|(_, v)| *v);
2260    let metadata = metadata_ref.map(|v| node_to_json(arena, v));
2261    let update_policy = entries.iter().find(|(k, _)| k == KEY_UPDATE_POLICY).map(|(_, v)| node_to_json(arena, *v));
2262    let creation_policy = entries.iter().find(|(k, _)| k == KEY_CREATION_POLICY).map(|(_, v)| node_to_json(arena, *v));
2263
2264    let resolved_metadata = if let Some(meta_ref) = metadata_ref {
2265        resolver.set_current_path(SECTION_METADATA);
2266        Some(resolver.resolve_node(meta_ref))
2267    } else {
2268        None
2269    };
2270
2271    let mut properties = HashMap::new();
2272    let mut properties_dynamic = false;
2273    if let Some((_, props_ref)) = entries.iter().find(|(k, _)| k == KEY_PROPERTIES) {
2274        if let Some(prop_entries) = arena.as_map(*props_ref) {
2275            for (key, val_ref) in prop_entries {
2276                resolver.set_current_path(&format!("Properties.{}", key));
2277                properties.insert(key.clone(), resolver.resolve_node(*val_ref));
2278            }
2279        } else if let Some(synthetic_key) = intrinsic_synthetic_key(arena, *props_ref) {
2280            // `Properties: {Fn::If: [...]}` collapses in the IR into an intrinsic
2281            // node rather than a map. Preserve the intrinsic as a single entry so
2282            // downstream validators can enumerate each branch via scenario
2283            // resolution without losing the original conditional structure.
2284            // Use bare `Properties` as the base path so the Fn::If handler in
2285            // the resolver records branch sources at the single-prefix path
2286            // rather than doubling the `Fn::If` segment.
2287            resolver.set_current_path(KEY_PROPERTIES);
2288            properties.insert(synthetic_key, resolver.resolve_node(*props_ref));
2289        } else if matches!(arena.node(*props_ref), Node::Intrinsic(_)) {
2290            // The whole Properties block is an intrinsic (e.g. `!Ref AWS::NoValue`)
2291            // that did not collapse into a per-branch synthetic key. Its effective
2292            // properties are only known at deploy time.
2293            properties_dynamic = true;
2294        }
2295    }
2296
2297    let mut conditionally_null_props = Vec::new();
2298    for (key, val) in &properties {
2299        collect_conditional_nulls(val, key, &mut conditionally_null_props);
2300    }
2301
2302    let mut condition_refs = Vec::new();
2303    for v in properties.values() {
2304        collect_condition_refs_from_resolved(v, &mut condition_refs);
2305    }
2306    if let Some(ref meta_resolved) = resolved_metadata {
2307        collect_condition_refs_from_resolved(meta_resolved, &mut condition_refs);
2308    }
2309    if let Some(ref dp) = deletion_policy {
2310        collect_condition_refs_from_resolved(dp, &mut condition_refs);
2311    }
2312    if let Some(ref urp) = update_replace_policy {
2313        collect_condition_refs_from_resolved(urp, &mut condition_refs);
2314    }
2315    if let Some(mut extra) = resolver.extra_condition_refs.remove(name) {
2316        condition_refs.append(&mut extra);
2317    }
2318    condition_refs.sort();
2319    condition_refs.dedup();
2320
2321    ResolvedResource {
2322        logical_id: name.to_string(),
2323        resource_type,
2324        condition,
2325        depends_on,
2326        deletion_policy,
2327        update_replace_policy,
2328        update_policy: update_policy.map(JsonValue),
2329        creation_policy: creation_policy.map(JsonValue),
2330        metadata: metadata.map(JsonValue),
2331        properties,
2332        properties_dynamic,
2333        diagnostics: ResourceDiagnostics {
2334            find_in_map_refs: resolver.find_in_map_refs.remove(name).unwrap_or_default(),
2335            simple_subs: resolver
2336                .simple_subs
2337                .remove(name)
2338                .unwrap_or_default()
2339                .into_iter()
2340                .map(|(a, b)| PathValuePair { path: a, value: b })
2341                .collect(),
2342            redundant_subs: resolver.redundant_subs.remove(name).unwrap_or_default(),
2343            empty_joins: resolver.empty_joins.remove(name).unwrap_or_default(),
2344            hardcoded_partition_arns: collapse_list_sibling_arn_paths(
2345                resolver.hardcoded_partition_arns.remove(name).unwrap_or_default(),
2346            ),
2347            foreach_expansions: resolver
2348                .foreach_expansions
2349                .remove(name)
2350                .unwrap_or_default()
2351                .into_iter()
2352                .map(|(path, ident, coll)| ForEachExpansion {
2353                    property_path: path,
2354                    identifier: ident,
2355                    collection_source: coll,
2356                })
2357                .collect(),
2358            unsubstituted_variables: resolver
2359                .unsubstituted_variables
2360                .remove(name)
2361                .unwrap_or_default()
2362                .into_iter()
2363                .map(|(a, b)| PathValuePair { path: a, value: b })
2364                .collect(),
2365            unused_sub_keys: resolver
2366                .unused_sub_keys
2367                .remove(name)
2368                .unwrap_or_default()
2369                .into_iter()
2370                .map(|(a, b)| PathValuePair { path: a, value: b })
2371                .collect(),
2372            raw_pseudo_params: resolver
2373                .raw_pseudo_params
2374                .remove(name)
2375                .unwrap_or_default()
2376                .into_iter()
2377                .map(|(a, b)| PathValuePair { path: a, value: b })
2378                .collect(),
2379            secretsmanager_ref_paths: resolver.secretsmanager_ref_paths.remove(name).unwrap_or_default(),
2380            invalid_refs: resolver
2381                .invalid_refs
2382                .remove(name)
2383                .unwrap_or_default()
2384                .into_iter()
2385                .map(|(a, b)| PathValuePair { path: a, value: b })
2386                .collect(),
2387            conditionally_null_props: conditionally_null_props
2388                .into_iter()
2389                .map(|(a, b, c)| ConditionalNullEntry { path: a, condition: b, null_in_true_branch: c })
2390                .collect(),
2391            condition_refs,
2392        },
2393    }
2394}
2395
2396/// Collapses hardcoded-partition ARN paths that are list siblings sharing one
2397/// source location into a single path at the lowest index.
2398///
2399/// When several `Fn::Sub` ARNs are list elements of the same property (e.g.
2400/// `Principal.AWS.0.Fn::Sub`, `Principal.AWS.1.Fn::Sub`), they map to the same
2401/// source span - the list's location - so they are one observable finding, not
2402/// several. Paths whose only difference is the list index immediately before the
2403/// trailing `.Fn::Sub` segment are therefore folded to the smallest index.
2404fn collapse_list_sibling_arn_paths(paths: Vec<String>) -> Vec<String> {
2405    // Group key: the path with the final list index (the segment just before
2406    // `.Fn::Sub`) blanked out. Tracks the minimum index seen per group so the
2407    // surviving path reports the first sibling.
2408    let mut min_index: HashMap<String, usize> = HashMap::new();
2409    let mut ungrouped: Vec<String> = Vec::new();
2410    for path in &paths {
2411        match split_trailing_list_index(path) {
2412            Some((prefix, idx, suffix)) => {
2413                let key = format!("{}\u{0}{}", prefix, suffix);
2414                min_index.entry(key).and_modify(|m| *m = (*m).min(idx)).or_insert(idx);
2415            }
2416            None => ungrouped.push(path.clone()),
2417        }
2418    }
2419    let mut out = ungrouped;
2420    for (key, idx) in min_index {
2421        let (prefix, suffix) = key.split_once('\u{0}').unwrap_or((key.as_str(), ""));
2422        out.push(format!("{}{}.{}", prefix, idx, suffix));
2423    }
2424    out.sort();
2425    out
2426}
2427
2428/// Splits a path like `a.b.2.Fn::Sub` into (`"a.b."`, `2`, `"Fn::Sub"`) when a
2429/// numeric list index directly precedes the trailing `.Fn::Sub` segment.
2430fn split_trailing_list_index(path: &str) -> Option<(String, usize, String)> {
2431    let suffix = "Fn::Sub";
2432    let stem = path.strip_suffix(suffix)?.strip_suffix('.')?;
2433    let (head, last) = stem.rsplit_once('.')?;
2434    let idx: usize = last.parse().ok()?;
2435    Some((format!("{}.", head), idx, suffix.to_string()))
2436}
2437
2438/// An output `Value` must resolve to a string. A literal non-empty list or map,
2439/// or a function whose result is a list (`Fn::GetAZs`, `Fn::Split`, `Fn::Cidr`),
2440/// can never be a string and is a guaranteed template error. `Fn::If` is
2441/// transparent: each branch is checked at its own location, so a conditional
2442/// that picks a string in one branch and a list in the other is flagged only on
2443/// the offending branch. Empty containers are accepted (they carry no members to
2444/// stringify) and every other function (`Ref`, `Fn::Sub`, `Fn::Join`,
2445/// `Fn::Select`, `Fn::FindInMap`, `Fn::ImportValue`, …) is treated as
2446/// string-producing here - a `Fn::GetAtt` returning a non-string is caught later
2447/// against the resource schema, which this parse-time shape check cannot see.
2448fn check_output_value_is_string(arena: &Arena, value_ref: NodeRef, output_name: &str, resolver: &mut Resolver) {
2449    let build_path = format!("Outputs/{}/Value", output_name);
2450    check_output_value_node(arena, value_ref, output_name, &build_path, resolver);
2451}
2452
2453/// Recursive worker for [`check_output_value_is_string`]. `build_path` is the
2454/// slash path of the node under inspection; each `Fn::If` branch is visited with
2455/// its own branch path so that a conditional with a bad value in both branches
2456/// yields a separate, correctly-located diagnostic per branch rather than
2457/// collapsing to one.
2458fn check_output_value_node(
2459    arena: &Arena,
2460    value_ref: NodeRef,
2461    output_name: &str,
2462    build_path: &str,
2463    resolver: &mut Resolver,
2464) {
2465    let span = arena.span(value_ref);
2466    match arena.node(value_ref) {
2467        Node::List(items) if !items.is_empty() => {
2468            resolver.diagnostics.push(crate::make_parse_defect_at(
2469                "F6101",
2470                format!("Output '{}' value must be a string, not a list", output_name),
2471                span,
2472                build_path,
2473            ));
2474        }
2475        Node::Map(entries) if !entries.is_empty() => {
2476            resolver.diagnostics.push(crate::make_parse_defect_at(
2477                "F6101",
2478                format!("Output '{}' value must be a string, not an object", output_name),
2479                span,
2480                build_path,
2481            ));
2482        }
2483        Node::Intrinsic(IntrinsicFn::If(_, if_true, if_false) | IntrinsicFn::IfExpr(_, if_true, if_false)) => {
2484            let (if_true, if_false) = (*if_true, *if_false);
2485            check_output_value_node(arena, if_true, output_name, &format!("{}/{}/1", build_path, FN_IF), resolver);
2486            check_output_value_node(arena, if_false, output_name, &format!("{}/{}/2", build_path, FN_IF), resolver);
2487        }
2488        Node::Intrinsic(intrinsic) if returns_list(intrinsic) => {
2489            resolver.diagnostics.push(crate::make_parse_defect_at(
2490                "F6101",
2491                format!("Output '{}' value must be a string, not a list", output_name),
2492                span,
2493                build_path,
2494            ));
2495        }
2496        _ => {}
2497    }
2498}
2499
2500/// Whether an intrinsic's result is a list value (never a string). These are the
2501/// only list-returning functions that can stand as a whole output `Value`;
2502/// `Fn::If` is handled separately (its branches are checked), and every other
2503/// function yields a string or an opaque deploy-time value.
2504fn returns_list(intrinsic: &IntrinsicFn) -> bool {
2505    matches!(intrinsic, IntrinsicFn::GetAZs(_) | IntrinsicFn::Split(_, _) | IntrinsicFn::Cidr(_, _, _))
2506}
2507
2508fn resolve_output(arena: &Arena, node_ref: NodeRef, resolver: &mut Resolver) -> ResolvedOutput {
2509    let entries = arena.as_map(node_ref).unwrap_or(&[]);
2510
2511    // Validate output property keys
2512    const VALID_OUTPUT_KEYS: &[&str] = &[KEY_VALUE, SECTION_DESCRIPTION, KEY_CONDITION, KEY_EXPORT];
2513    let output_name = resolver.current_resource.as_deref().unwrap_or("");
2514    let display_name = output_name.strip_prefix(OUTPUT_PSEUDO_RESOURCE_PREFIX).unwrap_or(output_name);
2515    for (key, _) in entries {
2516        if !VALID_OUTPUT_KEYS.contains(&key.as_str()) {
2517            resolver.diagnostics.push(crate::make_parse_defect(
2518                "E6001",
2519                format!(
2520                    "Output '{}' has invalid property '{}'. Valid properties: Value, Description, Condition, Export",
2521                    display_name, key
2522                ),
2523                crate::ir::UNKNOWN_SPAN,
2524            ));
2525        }
2526    }
2527
2528    if let Some((_, value_ref)) = entries.iter().find(|(k, _)| k == KEY_VALUE) {
2529        let value_ref = *value_ref;
2530        let name = display_name.to_string();
2531        check_output_value_is_string(arena, value_ref, &name, resolver);
2532    }
2533
2534    let value = entries
2535        .iter()
2536        .find(|(k, _)| k == KEY_VALUE)
2537        .map(|(_, v)| resolver.resolve_node(*v))
2538        .unwrap_or(ResolvedValue::Dynamic { reason: "missing output value".into() });
2539    let description = entries
2540        .iter()
2541        .find(|(k, _)| k == SECTION_DESCRIPTION)
2542        .and_then(|(_, v)| arena.as_str(*v))
2543        .map(|s| s.to_string());
2544    let condition =
2545        entries.iter().find(|(k, _)| k == KEY_CONDITION).and_then(|(_, v)| arena.as_str(*v)).map(|s| s.to_string());
2546    let export_name = entries
2547        .iter()
2548        .find(|(k, _)| k == KEY_EXPORT)
2549        .and_then(|(_, v)| arena.as_map(*v))
2550        .and_then(|m| m.iter().find(|(k, _)| k == KEY_NAME))
2551        .map(|(_, v)| resolver.resolve_node(*v));
2552    ResolvedOutput { value, description, condition, export_name }
2553}
2554
2555#[cfg(test)]
2556mod tests {
2557    use super::*;
2558
2559    #[test]
2560    fn invalid_overrides_reports_nothing_when_unset_or_valid() {
2561        assert!(PseudoParameterOverrides::default().invalid_overrides().is_empty(), "unset overrides are valid");
2562        let valid = PseudoParameterOverrides {
2563            account_id: Some("123456789012".to_string()),
2564            partition: Some("aws-cn".to_string()),
2565            ..Default::default()
2566        };
2567        assert!(valid.invalid_overrides().is_empty(), "well-formed overrides produce no warnings");
2568    }
2569
2570    #[test]
2571    fn invalid_overrides_reports_each_malformed_provided_value() {
2572        let bad = PseudoParameterOverrides {
2573            account_id: Some("unknown-account".to_string()),
2574            partition: Some("gcp".to_string()),
2575            ..Default::default()
2576        };
2577        let problems = bad.invalid_overrides();
2578        assert_eq!(problems.len(), 2, "one entry per invalid provided field");
2579        assert!(problems.iter().any(|p| p.contains("AccountId") && p.contains("unknown-account")));
2580        assert!(problems.iter().any(|p| p.contains("Partition") && p.contains("gcp")));
2581    }
2582
2583    #[test]
2584    fn invalid_overrides_only_checks_account_id_and_partition() {
2585        let only_region = PseudoParameterOverrides { region: Some("not a region".to_string()), ..Default::default() };
2586        assert!(only_region.invalid_overrides().is_empty());
2587    }
2588
2589    #[test]
2590    fn account_id_shape_requires_exactly_twelve_digits() {
2591        assert!(is_account_id_shaped("123456789012"));
2592        assert!(!is_account_id_shaped("12345678901"));
2593        assert!(!is_account_id_shaped("1234567890123"));
2594        assert!(!is_account_id_shaped("12345678901a"));
2595        assert!(!is_account_id_shaped(""));
2596    }
2597
2598    #[test]
2599    fn partition_shape_accepts_aws_family_and_rejects_others() {
2600        for ok in ["aws", "aws-cn", "aws-us-gov", "aws-iso", "aws-iso-b"] {
2601            assert!(is_partition_shaped(ok), "{ok} should be accepted");
2602        }
2603        for bad in ["gcp", "AWS", "azure", ""] {
2604            assert!(!is_partition_shaped(bad), "{bad} should be rejected");
2605        }
2606    }
2607
2608    #[test]
2609    fn model_from_simple_template() {
2610        let input = r#"
2611AWSTemplateFormatVersion: "2010-09-09"
2612Resources:
2613  MyBucket:
2614    Type: AWS::S3::Bucket
2615    Properties:
2616      BucketName: my-bucket
2617  MyBucket2:
2618    Type: AWS::S3::Bucket
2619    Properties:
2620      BucketName: my-bucket-2
2621"#;
2622        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2623        assert_eq!(model.resources.len(), 2);
2624        assert_eq!(model.resources_of_type("AWS::S3::Bucket").len(), 2);
2625        assert_eq!(model.resources_of_type("AWS::Fake::Thing").len(), 0);
2626    }
2627
2628    #[test]
2629    fn diagnostic_span_resolves_dotted_resource_property_path() {
2630        let input = r#"
2631Resources:
2632  MyBucket:
2633    Type: AWS::S3::Bucket
2634    Properties:
2635      BucketName: my-bucket
2636"#;
2637        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2638        // A dotted, resource-relative path is normalized to the slash-keyed index
2639        // entry and resolves to the specific property span (the BucketName value).
2640        let via_diag = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName").expect("should resolve");
2641        let expected = model.source_location("Resources/MyBucket/Properties/BucketName").copied().expect("indexed");
2642        assert_eq!(via_diag, expected, "dotted path should resolve to the exact property span");
2643    }
2644
2645    #[test]
2646    fn diagnostic_span_resolves_bare_top_level_path_without_resource() {
2647        let input = r#"
2648BogusSection:
2649  Value: invalid
2650Resources:
2651  MyBucket:
2652    Type: AWS::S3::Bucket
2653"#;
2654        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2655        let via_diag = model.diagnostic_span(None, "BogusSection").expect("should resolve");
2656        let expected = model.source_location("BogusSection").copied().expect("indexed");
2657        assert_eq!(via_diag, expected, "bare top-level path should resolve to its authored key");
2658    }
2659
2660    #[test]
2661    fn diagnostic_span_resource_relative_path_never_matches_top_level_section() {
2662        // A resource property named after a top-level section (Metadata) must anchor
2663        // at the resource's own Metadata, not the unrelated top-level Metadata block.
2664        let input = r#"
2665Metadata:
2666  AWS::CloudFormation::Interface:
2667    ParameterGroups: []
2668Resources:
2669  MyBucket:
2670    Type: AWS::S3::Bucket
2671    Metadata:
2672      cfn_nag: skip
2673"#;
2674        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2675        let via_diag = model.diagnostic_span(Some("MyBucket"), "Metadata").expect("should resolve");
2676        let resource_meta = model.source_location("Resources/MyBucket/Metadata").copied().expect("indexed");
2677        let top_level_meta = model.source_location("Metadata").copied().expect("indexed");
2678        assert_eq!(via_diag, resource_meta, "resource-relative Metadata must anchor at the resource");
2679        assert_ne!(via_diag, top_level_meta, "must not mislocate onto the top-level Metadata section");
2680    }
2681
2682    #[test]
2683    fn diagnostic_span_resolves_absolute_section_rooted_path() {
2684        let input = r#"
2685Resources:
2686  MyBucket:
2687    Type: AWS::S3::Bucket
2688Outputs:
2689  BucketRef:
2690    Value: !Ref MyBucket
2691"#;
2692        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2693        // An output-anchored path cannot be reached via resource_span (it only roots
2694        // at Resources/), but diagnostic_span resolves it directly from the index.
2695        let span = model.diagnostic_span(Some("BucketRef"), "Outputs/BucketRef/Value");
2696        assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "output path should resolve to a real span");
2697    }
2698
2699    #[test]
2700    fn diagnostic_span_walks_up_when_leaf_node_is_unindexed() {
2701        let input = r#"
2702Resources:
2703  MyBucket:
2704    Type: AWS::S3::Bucket
2705    Properties:
2706      BucketName: my-bucket
2707"#;
2708        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2709        // A deeper path that was never indexed falls back to the nearest indexed
2710        // ancestor (the property, then the resource) rather than UNKNOWN.
2711        let span = model.diagnostic_span(Some("MyBucket"), "Properties.BucketName.DoesNotExist.Deeper");
2712        assert!(span.is_some() && span != Some(UNKNOWN_SPAN), "unindexed leaf should walk up to a real span");
2713    }
2714
2715    #[test]
2716    fn diagnostic_span_returns_none_when_nothing_resolvable() {
2717        let input = r#"
2718Resources:
2719  MyBucket:
2720    Type: AWS::S3::Bucket
2721"#;
2722        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2723        // No resource and no property path: there is no candidate key to resolve.
2724        assert_eq!(model.diagnostic_span(None, ""), None, "no resource and no path yields no span");
2725        // A property path whose every ancestor is unindexed (no matching section)
2726        // resolves to nothing.
2727        assert_eq!(
2728            model.diagnostic_span(None, "NoSuchSection/Deep/Path"),
2729            None,
2730            "an unindexed absolute path yields no span"
2731        );
2732    }
2733
2734    #[test]
2735    fn model_resolve_property() {
2736        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2737        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2738        match model.resolve("R", "Properties.Name").unwrap() {
2739            ResolvedValue::Concrete { value: v } => assert_eq!(v.as_str().unwrap(), "hello"),
2740            other => panic!("Expected Concrete, got {:?}", other),
2741        }
2742    }
2743
2744    #[test]
2745    fn model_follow_ref() {
2746        let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
2747        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2748        assert_eq!(model.follow_ref("Svc", "Properties.TaskDef"), Some("TD"));
2749    }
2750
2751    #[test]
2752    fn model_to_diagnostic_json() {
2753        let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"Name":"test"}}}}"#;
2754        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2755        let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
2756        assert_ne!(json.get("resources"), None, "expected 'resources' key in diagnostic JSON");
2757        assert_ne!(json["resources"].get("R"), None, "expected resource 'R' in diagnostic JSON");
2758    }
2759
2760    #[test]
2761    fn model_send_sync() {
2762        fn assert_send_sync<T: Send + Sync>() {}
2763        assert_send_sync::<SemanticModel>();
2764    }
2765
2766    #[test]
2767    fn model_with_conditions() {
2768        let input = r#"
2769Parameters:
2770  Env:
2771    Type: String
2772    AllowedValues: [Prod, Dev]
2773Conditions:
2774  IsProd:
2775    Fn::Equals: [!Ref Env, Prod]
2776Resources:
2777  R:
2778    Type: T
2779    Condition: IsProd
2780    Properties:
2781      Size:
2782        Fn::If: [IsProd, 100, 20]
2783"#;
2784        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2785        assert_eq!(model.conditions.conditions.len(), 1);
2786        let r = model.resource("R").unwrap();
2787        assert_eq!(r.condition.as_deref(), Some("IsProd"));
2788        assert!(matches!(
2789            r.properties.get("Size"),
2790            Some(ResolvedValue::Conditional { condition: _, if_true: _, if_false: _ })
2791        ));
2792    }
2793
2794    #[test]
2795    fn model_with_outputs() {
2796        let input =
2797            "Resources:\n  R:\n    Type: T\nOutputs:\n  Out1:\n    Value: !Ref R\n    Description: test output\n";
2798        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2799        assert_eq!(model.outputs.len(), 1);
2800        assert!(model.outputs.contains_key("Out1"));
2801    }
2802
2803    #[test]
2804    fn resolve_deep_nested_object() {
2805        let input =
2806            r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"SubKey":"value","Nested":{"Deep":"found"}}}}}}"#;
2807        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2808        match model.resolve_deep("R", "Properties.Config.SubKey") {
2809            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "value"),
2810            other => panic!("Expected Concrete, got {:?}", other),
2811        }
2812        match model.resolve_deep("R", "Properties.Config.Nested.Deep") {
2813            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "found"),
2814            other => panic!("Expected Concrete, got {:?}", other),
2815        }
2816    }
2817
2818    #[test]
2819    fn resolve_deep_array_index() {
2820        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"Env","Value":"prod"},{"Key":"App","Value":"web"}]}}}}"#;
2821        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2822        match model.resolve_deep("R", "Properties.Tags.0.Key") {
2823            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "Env"),
2824            other => panic!("Expected Concrete, got {:?}", other),
2825        }
2826    }
2827
2828    #[test]
2829    fn resolve_deep_out_of_bounds() {
2830        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Tags":[{"Key":"A"}]}}}}"#;
2831        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2832        assert!(model.resolve_deep("R", "Properties.Tags.5.Key").is_none(), "out-of-bounds index should return None");
2833    }
2834
2835    #[test]
2836    fn resolve_deep_missing_intermediate() {
2837        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2838        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2839        assert!(model.resolve_deep("R", "Properties.NonExistent.Sub").is_none());
2840    }
2841
2842    #[test]
2843    fn resolve_deep_top_level_still_works() {
2844        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Name":"hello"}}}}"#;
2845        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2846        match model.resolve_deep("R", "Properties.Name") {
2847            Some(ResolvedValue::Concrete { value: v }) => assert_eq!(v.as_str().unwrap(), "hello"),
2848            other => panic!("Expected Concrete, got {:?}", other),
2849        }
2850    }
2851
2852    #[test]
2853    fn model_findinmap_refs_tracked() {
2854        let input = br#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::FindInMap":["MyMap","k1","k2"]}}}}}"#;
2855        let model = SemanticModel::from_bytes(input).unwrap();
2856        assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
2857    }
2858
2859    #[test]
2860    fn model_findinmap_refs_tracked_yaml() {
2861        let input = b"Resources:\n  R:\n    Type: T\n    Properties:\n      V: !FindInMap [MyMap, k1, k2]\n";
2862        let model = SemanticModel::from_bytes(input).unwrap();
2863        assert!(model.resource("R").unwrap().diagnostics.find_in_map_refs.contains(&"MyMap".to_string()));
2864    }
2865
2866    #[test]
2867    fn model_to_diagnostic_json_has_outputs_and_mappings() {
2868        let input = b"AWSTemplateFormatVersion: '2010-09-09'\nMappings:\n  M:\n    k1:\n      k2: val\nResources:\n  R:\n    Type: AWS::S3::Bucket\nOutputs:\n  Out:\n    Value: !Ref R\n";
2869        let model = SemanticModel::from_bytes(input).unwrap();
2870        let json = serde_json::to_value(model.to_diagnostic_json()).unwrap();
2871        assert_ne!(json.get("outputs"), None, "expected 'outputs' key in diagnostic JSON");
2872        assert_ne!(json.get("mappings"), None, "expected 'mappings' key in diagnostic JSON");
2873        assert_eq!(json["mappings"]["M"]["k1"]["k2"], "val");
2874    }
2875
2876    #[test]
2877    fn resolve_deep_into_reference() {
2878        let input = r#"{"Resources":{"Svc":{"Type":"T","Properties":{"TaskDef":{"Ref":"TD"}}},"TD":{"Type":"T2"}}}"#;
2879        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2880        assert!(
2881            model.resolve_deep("Svc", "Properties.TaskDef.Something").is_none(),
2882            "path through Reference should return None"
2883        );
2884        match model.resolve_deep("Svc", "Properties.TaskDef") {
2885            Some(ResolvedValue::Reference { target, kind: _ }) => assert_eq!(target, "TD"),
2886            other => panic!("Expected Reference, got {:?}", other),
2887        }
2888    }
2889
2890    #[test]
2891    fn scenario_source_path_maps_effective_indexes_to_authored_branches() {
2892        let input = br#"{
2893            "Parameters": {"Mode": {"Type": "String"}},
2894            "Conditions": {"ChooseFirst": {"Fn::Equals": [{"Ref": "Mode"}, "first"]}},
2895            "Resources": {"R": {"Type": "T", "Properties": {
2896                "Values": {"Fn::If": ["ChooseFirst", ["bad"], ["good"]]}
2897            }}}
2898        }"#;
2899        let model = SemanticModel::from_bytes(input).unwrap();
2900        let true_conditions = HashMap::from([("ChooseFirst".to_string(), true)]);
2901        let false_conditions = HashMap::from([("ChooseFirst".to_string(), false)]);
2902        assert_eq!(
2903            model.scenario_source_path("R", "Properties.Values.0", &true_conditions),
2904            Some("Properties.Values.Fn::If.1.0".to_string())
2905        );
2906        assert_eq!(
2907            model.scenario_source_path("R", "Properties.Values.0", &false_conditions),
2908            Some("Properties.Values.Fn::If.2.0".to_string())
2909        );
2910    }
2911
2912    #[test]
2913    fn resolve_scenarios_json_filters_dynamic() {
2914        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Fn::ImportValue":"Stack"}}}}}"#;
2915        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2916        let scenarios = model.resolve_scenarios_json("R", "Properties.V");
2917        assert!(scenarios.is_empty(), "ImportValue should be filtered as dynamic");
2918    }
2919
2920    #[test]
2921    fn resolve_scenarios_json_returns_concrete() {
2922        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
2923        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2924        let scenarios = model.resolve_scenarios_json("R", "Properties.V");
2925        assert_eq!(scenarios.len(), 1);
2926        assert_eq!(scenarios[0].0, serde_json::json!("hello"));
2927    }
2928
2929    #[test]
2930    fn resolve_scenarios_json_memoized() {
2931        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
2932        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
2933        let s1 = model.resolve_scenarios_json("R", "Properties.V");
2934        let s2 = model.resolve_scenarios_json("R", "Properties.V");
2935        assert_eq!(s1, s2);
2936    }
2937
2938    #[test]
2939    fn default_scenario_queries_are_memoized_and_global_budget_halts_uncached_expansion() {
2940        let input = br#"{
2941            "Parameters": {"Env": {"Type": "String"}},
2942            "Conditions": {"IsProd": {"Fn::Equals": [{"Ref": "Env"}, "prod"]}},
2943            "Resources": {"R": {"Type": "T", "Properties": {
2944                "V": {"Fn::If": ["IsProd", "a", "b"]},
2945                "W": {"Fn::If": ["IsProd", "c", "d"]},
2946                "X": {"Fn::If": ["IsProd", "e", "f"]}
2947            }}}
2948        }"#;
2949        let model = SemanticModel::from_bytes(input).unwrap();
2950
2951        assert_eq!(model.scenario_combinations_used(), 0, "a freshly built model has materialized no scenarios");
2952        assert!(!model.scenario_budget_exhausted());
2953
2954        let first = model.resolve_scenarios("R", "Properties.V");
2955        assert!(first.len() > 1, "an Fn::If value must expand into multiple scenarios; got {}", first.len());
2956        let after_first = model.scenario_combinations_used();
2957        assert_eq!(after_first, first.len() as u64, "the first query charges exactly the scenarios it produced");
2958
2959        let repeated = model.resolve_scenarios("R", "Properties.V");
2960        assert_eq!(repeated.len(), first.len());
2961        assert_eq!(
2962            model.scenario_combinations_used(),
2963            after_first,
2964            "a repeated default-limit query must reuse its raw scenarios without consuming budget"
2965        );
2966
2967        let distinct = model.resolve_scenarios("R", "Properties.W");
2968        assert!(!distinct.is_empty());
2969        assert!(
2970            model.scenario_combinations_used() > after_first,
2971            "a distinct scenario path must still charge the model-wide budget"
2972        );
2973
2974        let to_threshold = MAX_TOTAL_SCENARIO_COMBINATIONS - model.scenario_combinations_used();
2975        model.add_scenario_combinations_for_test(to_threshold);
2976        assert!(model.scenario_budget_exhausted());
2977
2978        let cached = model.resolve_scenarios("R", "Properties.V");
2979        assert_eq!(
2980            cached.len(),
2981            first.len(),
2982            "cached scenarios remain usable after unrelated queries consume the budget"
2983        );
2984        assert!(!model.scenario_expansion_curtailed(), "reading a cached result does not curtail expansion");
2985
2986        let before_short_circuit = model.scenario_combinations_used();
2987        let curtailed = model.resolve_scenarios("R", "Properties.X");
2988        assert!(curtailed.is_empty(), "an uncached query must stop when the model-wide budget is exhausted");
2989        assert_eq!(model.scenario_combinations_used(), before_short_circuit);
2990        assert!(model.scenario_expansion_curtailed());
2991    }
2992
2993    fn two_scenario_value() -> ResolvedValue {
2994        ResolvedValue::Conditional {
2995            condition: "C".to_string(),
2996            if_true: Box::new(ResolvedValue::Concrete { value: serde_json::json!("yes").into() }),
2997            if_false: Box::new(ResolvedValue::Concrete { value: serde_json::json!("no").into() }),
2998        }
2999    }
3000
3001    #[test]
3002    fn exact_remaining_scenario_budget_does_not_mark_curtailment() {
3003        let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
3004        let scenarios = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 2);
3005        assert_eq!(scenarios.len(), 2);
3006        assert_eq!(model.scenario_combinations_used(), 2);
3007        assert!(
3008            !model.scenario_expansion_curtailed(),
3009            "materializing exactly every possible scenario at the budget boundary is not curtailment"
3010        );
3011    }
3012
3013    #[test]
3014    fn remaining_scenario_budget_truncates_and_marks_curtailment() {
3015        let model = SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap();
3016        let first = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3017        assert_eq!(first.len(), 2);
3018        assert!(!model.scenario_expansion_curtailed());
3019
3020        let second = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3021        assert_eq!(second.len(), 1, "only the one remaining global slot may be used");
3022        assert_eq!(model.scenario_combinations_used(), 3);
3023        assert!(model.scenario_expansion_curtailed());
3024
3025        let exhausted = model.collect_scenarios_with_budget(&two_scenario_value(), 8, 3);
3026        assert!(exhausted.is_empty());
3027        assert_eq!(model.scenario_combinations_used(), 3, "the global limit must never be exceeded");
3028    }
3029
3030    #[test]
3031    fn concurrent_scenario_queries_cannot_exceed_global_budget() {
3032        let model = std::sync::Arc::new(SemanticModel::from_bytes(br#"{"Resources":{"R":{"Type":"T"}}}"#).unwrap());
3033        let handles: Vec<_> = (0..8)
3034            .map(|_| {
3035                let model = std::sync::Arc::clone(&model);
3036                std::thread::spawn(move || {
3037                    let value = ResolvedValue::Concrete { value: serde_json::json!("value").into() };
3038                    model.collect_scenarios_with_budget(&value, 8, 4).len()
3039                })
3040            })
3041            .collect();
3042        let produced: usize =
3043            handles.into_iter().map(|handle| handle.join().expect("scenario worker must not panic")).sum();
3044
3045        assert_eq!(produced, 4, "exactly the global budget may be materialized across all workers");
3046        assert_eq!(model.scenario_combinations_used(), 4, "concurrent accounting must not overshoot the limit");
3047        assert!(model.scenario_expansion_curtailed(), "workers denied by exhaustion must mark curtailment");
3048    }
3049
3050    #[test]
3051    fn scenario_expansion_curtailment_flag_starts_false() {
3052        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
3053        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3054        assert!(
3055            !model.scenario_expansion_curtailed(),
3056            "a freshly built model must not have scenario expansion curtailed"
3057        );
3058    }
3059
3060    #[test]
3061    fn scenario_expansion_curtailment_flag_set_by_test_helper() {
3062        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"literal"}}}}"#;
3063        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3064        model.set_scenario_expansion_curtailed_for_test();
3065        assert!(model.scenario_expansion_curtailed(), "the test helper must set the curtailment flag");
3066    }
3067
3068    #[test]
3069    fn scenario_expansion_curtailment_flag_is_monotonic() {
3070        // Once set, the flag never resets — regardless of subsequent queries that
3071        // stay within budget.
3072        let input = r#"{
3073            "Resources": {"R": {"Type": "T", "Properties": {"V": {"Fn::If": ["C", "a", "b"]}}}}
3074        }"#;
3075        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3076        model.set_scenario_expansion_curtailed_for_test();
3077        // A normal query should not reset the flag.
3078        let _ = model.resolve_scenarios("R", "Properties.V");
3079        assert!(model.scenario_expansion_curtailed(), "the curtailment flag must be monotonic — once set it stays set");
3080    }
3081
3082    #[test]
3083    fn estimated_bounds_are_absent_for_a_literal_string() {
3084        // Schema validation checks a literal against its constraint directly, so
3085        // the estimating rule is deliberately given nothing for one.
3086        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3087        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3088        assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
3089    }
3090
3091    #[test]
3092    fn estimated_bounds_span_every_allowed_value() {
3093        let input = r#"{"Parameters":{"Size":{"Type":"String","AllowedValues":["xs","large"]}},
3094            "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Size"}}}}}"#;
3095        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3096        assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), Some((2, 5)));
3097    }
3098
3099    #[test]
3100    fn estimated_bounds_are_absent_when_a_possibility_is_unknown() {
3101        let input = r#"{"Parameters":{"Free":{"Type":"String"}},
3102            "Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Free"}}}}}"#;
3103        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3104        assert_eq!(model.estimated_string_length_bounds("R", "Properties.V"), None);
3105    }
3106
3107    #[test]
3108    fn estimate_string_length_missing_property() {
3109        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3110        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3111        assert!(model.estimated_string_length_bounds("R", "Properties.Missing").is_none());
3112    }
3113
3114    #[test]
3115    fn resource_span_specific_path() {
3116        let input = "Resources:\n  R:\n    Type: T\n    Properties:\n      Name: hello\n";
3117        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3118        let span = model.resource_span("R", "Properties/Name");
3119        // Should find a span (not UNKNOWN_SPAN) for the specific path
3120        assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span for Properties/Name");
3121    }
3122
3123    #[test]
3124    fn resource_span_specific_path_dotted_form() {
3125        let input = "Resources:\n  R:\n    Type: T\n    Properties:\n      Name: hello\n";
3126        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3127        // A dotted, resource-relative path must resolve to the SAME specific span
3128        // as its slash-form equivalent - not fall back to the resource span.
3129        let dotted = model.resource_span("R", "Properties.Name");
3130        let slashed = model.resource_span("R", "Properties/Name");
3131        let resource = model.resource_span("R", "");
3132        assert_eq!(dotted, slashed, "dotted and slash forms must resolve to the same span");
3133        assert_ne!(dotted, resource, "specific property span must differ from the resource span");
3134    }
3135
3136    #[test]
3137    fn resource_span_falls_back_to_resource() {
3138        let input = "Resources:\n  R:\n    Type: T\n    Properties:\n      Name: hello\n";
3139        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3140        let span = model.resource_span("R", "Properties/NonExistent/Deep");
3141        // Should fall back to the resource-level span
3142        assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
3143    }
3144
3145    #[test]
3146    fn resource_span_nested_array_property_anchors_at_element() {
3147        // A property inside an array element must anchor at that element, not at the
3148        // array or the resource. The span index is keyed with the array index.
3149        let input =
3150            "Resources:\n  R:\n    Type: T\n    Properties:\n      Ingress:\n      - Port: 80\n      - Port: 443\n";
3151        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3152        let first = model.resource_span("R", "Properties.Ingress.0.Port");
3153        let second = model.resource_span("R", "Properties.Ingress.1.Port");
3154        assert_eq!(first.start_line, 6, "Ingress[0].Port is on line 6, got {:?}", first);
3155        assert_eq!(second.start_line, 7, "Ingress[1].Port is on line 7, got {:?}", second);
3156    }
3157
3158    #[test]
3159    fn resource_span_empty_id_resolves_section_absolute_path_precisely() {
3160        // A finding with no resource id (e.g. an Outputs-level diagnostic) carries a
3161        // section-absolute span-index path. It must resolve against that path directly,
3162        // NOT be prefixed with `Resources/` (which would mislocate onto the Resources
3163        // block). A path whose exact node is indexed resolves to that node.
3164        let input = concat!(
3165            "Resources:\n  R:\n    Type: T\n",                                  // lines 1-3
3166            "Outputs:\n  Combined:\n    Value: !Join [\"\", [\"a\", \"b\"]]\n", // lines 4-6
3167        );
3168        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3169        // The `Value` node is indexed (line 6) - resolution lands on it exactly.
3170        let value = model.resource_span("", "Outputs/Combined/Value");
3171        assert_eq!(value.start_line, 6, "Outputs/Combined/Value is on line 6, got {:?}", value);
3172        // The output key itself resolves to its own line (line 5).
3173        let combined = model.resource_span("", "Outputs/Combined");
3174        assert_eq!(combined.start_line, 5, "Outputs/Combined is on line 5, got {:?}", combined);
3175    }
3176
3177    #[test]
3178    fn resource_span_empty_id_fused_intrinsic_suffix_anchors_at_nearest_slash_ancestor() {
3179        // A synthetic intrinsic suffix (`.Fn::Join`) is joined to its parent by a DOT,
3180        // which is deliberately not treated as a path separator here: real span-index
3181        // keys contain literal dots inside a single segment (e.g. API Gateway's
3182        // `method.request.path.proxy`), so splitting on dots would shred those paths and
3183        // mis-anchor. The walk-up therefore trims the whole `Value.Fn::Join` segment on
3184        // the nearest `/`, landing on the enclosing output - still within Outputs, never
3185        // on the Resources block. Both engines resolve this identically, which is what
3186        // keeps them at parity.
3187        let input = concat!(
3188            "Resources:\n  R:\n    Type: T\n",                                  // lines 1-3
3189            "Outputs:\n  Combined:\n    Value: !Join [\"\", [\"a\", \"b\"]]\n", // lines 4-6
3190        );
3191        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3192        let span = model.resource_span("", "Outputs/Combined/Value.Fn::Join");
3193        assert_eq!(
3194            span.start_line, 5,
3195            "fused-suffix path must anchor at the enclosing output (line 5), got {:?}",
3196            span
3197        );
3198    }
3199
3200    #[test]
3201    fn resource_span_empty_id_does_not_leak_onto_resources_block() {
3202        // Regression guard: an unresolvable empty-id path must return UNKNOWN rather
3203        // than walking up a spuriously `Resources/`-prefixed key onto the Resources
3204        // section. This is the divergence that made the two engines disagree.
3205        let input = "Resources:\n  R:\n    Type: T\n";
3206        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3207        let span = model.resource_span("", "Outputs/DoesNotExist/Value");
3208        assert_eq!(span, UNKNOWN_SPAN, "unresolvable empty-id path must be UNKNOWN, got {:?}", span);
3209    }
3210
3211    #[test]
3212    fn resource_span_empty_path_returns_resource_span() {
3213        let input = "Resources:\n  R:\n    Type: T\n";
3214        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3215        let span = model.resource_span("R", "");
3216        assert!(span.start_line > 0 || span.end_line > 0, "expected non-zero span, got {:?}", span);
3217    }
3218
3219    #[test]
3220    fn resolve_nonexistent_resource_returns_none() {
3221        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"x"}}}}"#;
3222        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3223        assert!(model.resolve("NoSuchResource", "Properties.V").is_none(), "nonexistent resource should return None");
3224        assert!(model.resolve_deep("NoSuchResource", "Properties.V").is_none());
3225    }
3226
3227    #[test]
3228    fn parse_config_with_parameter_overrides() {
3229        let input = r#"{"Parameters":{"Env":{"Type":"String","AllowedValues":["dev","prod"]}},"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"Env"}}}}}"#;
3230        let config = ParseConfig {
3231            parameters: [("Env".to_string(), "staging".to_string())].into_iter().collect(),
3232            pseudo_parameters: PseudoParameterOverrides::default(),
3233        };
3234        let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
3235        match result.model.resolve("R", "Properties.V") {
3236            Some(ResolvedValue::Concrete { value: v }) => {
3237                assert_eq!(v.as_str().unwrap(), "staging")
3238            }
3239            other => panic!("Expected Concrete(\"staging\"), got {:?}", other),
3240        }
3241    }
3242
3243    #[test]
3244    fn parse_config_with_pseudo_parameter_overrides() {
3245        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"AWS::Region"}}}}}"#;
3246        let config = ParseConfig {
3247            parameters: HashMap::new(),
3248            pseudo_parameters: PseudoParameterOverrides { region: Some("eu-west-1".to_string()), ..Default::default() },
3249        };
3250        let result = SemanticModel::parse(input.as_bytes(), config).unwrap();
3251        match result.model.resolve("R", "Properties.V") {
3252            Some(ResolvedValue::Concrete { value: v }) => {
3253                assert_eq!(v.as_str().unwrap(), "eu-west-1")
3254            }
3255            other => panic!("Expected Concrete(\"eu-west-1\"), got {:?}", other),
3256        }
3257    }
3258
3259    #[test]
3260    fn pseudo_parameter_overrides_defaults() {
3261        let overrides = PseudoParameterOverrides::default();
3262        assert_eq!(overrides.region(), DEFAULT_REGION);
3263        assert_eq!(overrides.get("AWS::AccountId").unwrap(), DEFAULT_ACCOUNT_ID);
3264        assert_eq!(overrides.get("AWS::Partition").unwrap(), DEFAULT_PARTITION);
3265        assert_eq!(overrides.get("AWS::StackName").unwrap(), DEFAULT_STACK_NAME);
3266        assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), DEFAULT_URL_SUFFIX);
3267        assert!(overrides.get("AWS::StackId").unwrap().contains("arn:"));
3268        assert!(overrides.get("AWS::NotificationARNs").unwrap().contains("sns"));
3269        assert_eq!(overrides.get("Unknown"), None, "unknown pseudo-param should return None");
3270    }
3271
3272    #[test]
3273    fn pseudo_parameter_overrides_china_region() {
3274        let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
3275        assert_eq!(overrides.get("AWS::Partition").unwrap(), "aws-cn");
3276        assert_eq!(overrides.get("AWS::URLSuffix").unwrap(), "amazonaws.com.cn");
3277    }
3278
3279    /// `fixed_value` underpins the SAT-solver decision "treat this pseudo-param
3280    /// as a constant or a free variable". It must return `Some` only when the
3281    /// caller pinned the corresponding field - never for region-derived
3282    /// defaults - otherwise `Fn::Equals[Ref AWS::Partition, "aws"]` would be
3283    /// falsely deterministic and `find_unreachable_branches` would emit
3284    /// false-positive unreachable-branch diagnostics.
3285    #[test]
3286    fn fixed_value_returns_none_for_unset_pseudo_parameters() {
3287        let overrides = PseudoParameterOverrides::default();
3288
3289        for name in [
3290            "AWS::AccountId",
3291            "AWS::NotificationARNs",
3292            "AWS::Partition",
3293            "AWS::Region",
3294            "AWS::StackId",
3295            "AWS::StackName",
3296            "AWS::URLSuffix",
3297        ] {
3298            assert_eq!(
3299                overrides.fixed_value(name),
3300                None,
3301                "{name} must be a free variable when the user has not explicitly pinned it"
3302            );
3303        }
3304    }
3305
3306    #[test]
3307    fn parameter_name_at_names_the_parameter_behind_a_value() {
3308        let input = r#"{"Parameters":{"BucketName":{"Type":"String"}},
3309            "Resources":{"R":{"Type":"AWS::S3::Bucket","Properties":{"BucketName":{"Ref":"BucketName"}}}}}"#;
3310        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3311        assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), Some("BucketName"));
3312        assert!(model.is_from_parameter("R", "Properties.BucketName"));
3313    }
3314
3315    #[test]
3316    fn parameter_name_at_is_absent_for_a_value_no_parameter_produced() {
3317        // A cross-stack import is unknown until deployment without being a
3318        // parameter, so nothing may name one.
3319        let input = r#"{"Resources":{"R":{"Type":"AWS::S3::Bucket",
3320            "Properties":{"BucketName":{"Fn::ImportValue":"SharedName"}}}}}"#;
3321        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3322        assert_eq!(model.parameter_name_at("R", "Properties.BucketName"), None);
3323        assert!(!model.is_from_parameter("R", "Properties.BucketName"));
3324    }
3325
3326    #[test]
3327    fn fixed_value_returns_user_supplied_overrides() {
3328        let overrides = PseudoParameterOverrides {
3329            account_id: Some("999999999999".to_string()),
3330            partition: Some("aws-cn".to_string()),
3331            region: Some("cn-north-1".to_string()),
3332            stack_name: Some("MyStack".to_string()),
3333            ..Default::default()
3334        };
3335
3336        assert_eq!(overrides.fixed_value("AWS::AccountId"), Some("999999999999".to_string()));
3337        assert_eq!(overrides.fixed_value("AWS::Partition"), Some("aws-cn".to_string()));
3338        assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
3339        assert_eq!(overrides.fixed_value("AWS::StackName"), Some("MyStack".to_string()));
3340        assert_eq!(overrides.fixed_value("AWS::URLSuffix"), None, "URLSuffix not set; must remain a free variable");
3341        assert_eq!(overrides.fixed_value("Unknown"), None);
3342    }
3343
3344    #[test]
3345    fn parameter_name_from_source_reads_only_a_parameter_entry() {
3346        assert_eq!(parameter_name_from_source("Parameters/InstanceType/Default"), Some("InstanceType"));
3347        assert_eq!(parameter_name_from_source("Parameters/InstanceType/AllowedValues"), Some("InstanceType"));
3348        assert_eq!(parameter_name_from_source("Parameters/InstanceType"), Some("InstanceType"));
3349        assert_eq!(parameter_name_from_source("Intrinsic/Ref"), None);
3350        assert_eq!(parameter_name_from_source("Parameters/"), None);
3351        assert_eq!(parameter_name_from_source("ParametersLookAlike/Name"), None);
3352    }
3353
3354    /// Setting `region` alone must NOT cause `fixed_value("AWS::Partition")`
3355    /// to return a value. The convenience defaulting that `get` performs (so
3356    /// resource-property substitution sees a sensible partition string) is
3357    /// deliberately separate from the SAT-solver pinning, because constraining
3358    /// the region constrains where the stack deploys, not the partition the
3359    /// template was written against.
3360    #[test]
3361    fn fixed_value_does_not_propagate_region_to_partition() {
3362        let overrides = PseudoParameterOverrides { region: Some("cn-north-1".to_string()), ..Default::default() };
3363
3364        assert_eq!(overrides.fixed_value("AWS::Region"), Some("cn-north-1".to_string()));
3365        assert_eq!(
3366            overrides.fixed_value("AWS::Partition"),
3367            None,
3368            "region override must not propagate to partition - partition stays free unless the caller pins it"
3369        );
3370    }
3371
3372    #[test]
3373    fn model_rejects_oversized_template() {
3374        let huge = vec![b' '; 11 * 1024 * 1024];
3375        let result = SemanticModel::from_bytes(&huge);
3376        match result {
3377            Err(e) => assert!(e.message.contains("maximum size")),
3378            Ok(_) => panic!("Expected error for oversized template"),
3379        }
3380    }
3381
3382    #[test]
3383    fn follow_ref_returns_none_for_non_reference() {
3384        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":"hello"}}}}"#;
3385        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3386        assert_eq!(model.follow_ref("R", "Properties.V"), None, "non-reference value should return None");
3387    }
3388
3389    #[test]
3390    fn resolve_deep_memoization_consistency() {
3391        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"Config":{"A":"1","B":"2"}}}}}"#;
3392        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3393        let r1 = model.resolve_deep("R", "Properties.Config.A");
3394        let r2 = model.resolve_deep("R", "Properties.Config.A");
3395        match (&r1, &r2) {
3396            (Some(ResolvedValue::Concrete { value: a }), Some(ResolvedValue::Concrete { value: b })) => {
3397                assert_eq!(a, b);
3398            }
3399            _ => panic!("Expected matching Concrete values"),
3400        }
3401    }
3402
3403    #[test]
3404    fn model_with_mappings_and_findinmap() {
3405        let input = r#"
3406Mappings:
3407  RegionMap:
3408    us-east-1:
3409      AMI: ami-12345
3410    us-west-2:
3411      AMI: ami-67890
3412Resources:
3413  R:
3414    Type: T
3415    Properties:
3416      ImageId: !FindInMap [RegionMap, us-east-1, AMI]
3417"#;
3418        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3419        match model.resolve("R", "Properties.ImageId") {
3420            Some(ResolvedValue::Concrete { value: v }) => {
3421                assert_eq!(v.as_str().unwrap(), "ami-12345")
3422            }
3423            other => panic!("Expected Concrete, got {:?}", other),
3424        }
3425    }
3426
3427    #[test]
3428    fn model_invalid_ref_tracked() {
3429        let input = r#"{"Resources":{"R":{"Type":"T","Properties":{"V":{"Ref":"NonExistent"}}}}}"#;
3430        let model = SemanticModel::from_bytes(input.as_bytes()).unwrap();
3431        let r = model.resource("R").unwrap();
3432        assert!(!r.diagnostics.invalid_refs.is_empty());
3433        assert!(r.diagnostics.invalid_refs.iter().any(|s| s.value == "NonExistent"));
3434    }
3435
3436    #[test]
3437    fn collapse_arn_paths_folds_list_siblings_to_lowest_index() {
3438        let input = vec![
3439            "Properties.KeyPolicy.Statement.2.Principal.AWS.1.Fn::Sub".to_string(),
3440            "Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string(),
3441        ];
3442        let out = collapse_list_sibling_arn_paths(input);
3443        assert_eq!(out, vec!["Properties.KeyPolicy.Statement.2.Principal.AWS.0.Fn::Sub".to_string()]);
3444    }
3445
3446    #[test]
3447    fn collapse_arn_paths_keeps_distinct_parent_lists() {
3448        // Differing index is the Statement index, not the one before Fn::Sub, so
3449        // these are separate source locations and must both survive.
3450        let input = vec![
3451            "Properties.PolicyDocument.Statement.0.Resource.Fn::Sub".to_string(),
3452            "Properties.PolicyDocument.Statement.1.Resource.Fn::Sub".to_string(),
3453        ];
3454        let mut out = collapse_list_sibling_arn_paths(input.clone());
3455        out.sort();
3456        assert_eq!(out, input);
3457    }
3458
3459    #[test]
3460    fn collapse_arn_paths_leaves_scalar_paths_untouched() {
3461        let input = vec![
3462            "Properties.KeyPolicy.Statement.0.Principal.AWS.Fn::Sub".to_string(),
3463            "Properties.KeyPolicy.Statement.1.Principal.AWS.Fn::Sub".to_string(),
3464        ];
3465        let mut out = collapse_list_sibling_arn_paths(input.clone());
3466        out.sort();
3467        assert_eq!(out, input);
3468    }
3469
3470    /// Number of output value-type diagnostics (F6101) the parse phase produced.
3471    fn output_string_type_diagnostics(template: &str) -> usize {
3472        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3473        model.diagnostics.iter().filter(|d| d.rule_id == "F6101").count()
3474    }
3475
3476    #[test]
3477    fn output_literal_list_value_flagged() {
3478        let template = "Resources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value:\n      - a\n      - b\n";
3479        assert_eq!(output_string_type_diagnostics(template), 1);
3480    }
3481
3482    #[test]
3483    fn output_literal_object_value_flagged() {
3484        let template = "Resources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value:\n      Key: v\n";
3485        assert_eq!(output_string_type_diagnostics(template), 1);
3486    }
3487
3488    #[test]
3489    fn output_list_returning_functions_flagged() {
3490        for value in ["!GetAZs \"\"", "!Split [\",\", \"a,b\"]", "!Cidr [\"10.0.0.0/16\", 1, 8]"] {
3491            let template = format!("Resources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value: {}\n", value);
3492            assert_eq!(output_string_type_diagnostics(&template), 1, "expected F6101 for output value {}", value);
3493        }
3494    }
3495
3496    #[test]
3497    fn output_empty_container_value_not_flagged() {
3498        // An empty list/object has no members to stringify; CloudFormation does
3499        // not reject it, so neither does the parse-time check.
3500        for value in ["[]", "{}"] {
3501            let template = format!("Resources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value: {}\n", value);
3502            assert_eq!(output_string_type_diagnostics(&template), 0, "empty container {} must not be flagged", value);
3503        }
3504    }
3505
3506    #[test]
3507    fn output_string_producing_values_not_flagged() {
3508        // Scalars coerce to strings; Ref/Fn::Sub/Fn::Join/Fn::Select/Fn::ImportValue
3509        // produce (or are treated as) strings. None are string-type violations.
3510        for value in ["hello", "42", "true", "!Ref R", "!Sub \"${R}\"", "!Join [\",\", [\"a\"]]", "!ImportValue X"] {
3511            let template = format!("Resources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value: {}\n", value);
3512            assert_eq!(output_string_type_diagnostics(&template), 0, "string-valued output {} must not fire", value);
3513        }
3514    }
3515
3516    #[test]
3517    fn output_find_in_map_list_value_not_flagged() {
3518        // Fn::FindInMap can resolve to a list, but it is not flagged here (the
3519        // mapping's shape is validated elsewhere): only literal lists and
3520        // list-returning functions are string-type violations. This is exactly
3521        // the case a resolved-value check would get wrong, since the resolved
3522        // value is indistinguishable from a literal list.
3523        let template = "Mappings:\n  M:\n    k:\n      l:\n        - a\n        - b\nResources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value: !FindInMap [M, k, l]\n";
3524        assert_eq!(output_string_type_diagnostics(template), 0);
3525    }
3526
3527    #[test]
3528    fn output_fn_if_flags_each_list_branch() {
3529        // Fn::If is transparent: each branch is checked. Both branches are lists,
3530        // so both are flagged.
3531        let template = "Conditions:\n  C:\n    Fn::Equals: [\"a\", \"a\"]\nResources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value:\n      Fn::If: [C, [\"a\"], [\"b\"]]\n";
3532        assert_eq!(output_string_type_diagnostics(template), 2);
3533    }
3534
3535    #[test]
3536    fn output_fn_if_string_branches_not_flagged() {
3537        let template = "Conditions:\n  C:\n    Fn::Equals: [\"a\", \"a\"]\nResources:\n  R:\n    Type: T\nOutputs:\n  O:\n    Value:\n      Fn::If: [C, \"yes\", \"no\"]\n";
3538        assert_eq!(output_string_type_diagnostics(template), 0);
3539    }
3540
3541    #[test]
3542    fn join_with_enum_list_exact_4096_not_curtailed() {
3543        use crate::consts::MAX_ENUM_EXPANSION;
3544        use crate::resolver::join_with_enum_list;
3545        // Build items that produce exactly MAX_ENUM_EXPANSION combinations:
3546        // 64 * 64 = 4096 (no truncation needed)
3547        let variants_a: Vec<ResolvedValue> = (0..64)
3548            .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
3549            .collect();
3550        let variants_b: Vec<ResolvedValue> = (0..64)
3551            .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
3552            .collect();
3553        let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
3554        let (result, curtailed) = join_with_enum_list(",", &items);
3555        assert!(!curtailed, "exactly {MAX_ENUM_EXPANSION} combinations must not signal curtailment");
3556        if let ResolvedValue::Enum { variants } = &result {
3557            assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
3558        } else {
3559            panic!("expected Enum result");
3560        }
3561    }
3562
3563    #[test]
3564    fn join_with_enum_list_one_over_curtailed() {
3565        use crate::consts::MAX_ENUM_EXPANSION;
3566        use crate::resolver::join_with_enum_list;
3567        // 65 * 64 = 4160 > 4096 — must curtail
3568        let variants_a: Vec<ResolvedValue> = (0..65)
3569            .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("a{i}")).into() })
3570            .collect();
3571        let variants_b: Vec<ResolvedValue> = (0..64)
3572            .map(|i| ResolvedValue::Concrete { value: serde_json::Value::String(format!("b{i}")).into() })
3573            .collect();
3574        let items = vec![ResolvedValue::Enum { variants: variants_a }, ResolvedValue::Enum { variants: variants_b }];
3575        let (result, curtailed) = join_with_enum_list(",", &items);
3576        assert!(curtailed, "exceeding {MAX_ENUM_EXPANSION} combinations must signal curtailment");
3577        if let ResolvedValue::Enum { variants } = &result {
3578            assert_eq!(variants.len(), MAX_ENUM_EXPANSION);
3579        } else {
3580            panic!("expected Enum result");
3581        }
3582    }
3583
3584    #[test]
3585    fn primary_id_same_parameter_across_ref_sub_join_collides() {
3586        let template = r#"
3587Parameters:
3588  Name: {Type: String}
3589Resources:
3590  A:
3591    Type: AWS::S3::Bucket
3592    Properties: {BucketName: !Ref Name}
3593  B:
3594    Type: AWS::S3::Bucket
3595    Properties: {BucketName: !Sub '${Name}'}
3596  C:
3597    Type: AWS::S3::Bucket
3598    Properties: {BucketName: !Sub ['${V}', {V: !Ref Name}]}
3599  D:
3600    Type: AWS::S3::Bucket
3601    Properties: {BucketName: !Join ['', [!Ref Name]]}
3602"#;
3603        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3604        let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3605        assert_eq!(conflicts.len(), 1, "all four forms must collide into one group");
3606        let (_, resources) = conflicts.into_iter().next().unwrap();
3607        assert_eq!(resources.len(), 4, "all four resources must be in the group");
3608    }
3609
3610    #[test]
3611    fn primary_id_different_parameters_with_equal_defaults_stay_distinct() {
3612        let template = r#"
3613Parameters:
3614  A: {Type: String, Default: shared}
3615  B: {Type: String, Default: shared}
3616Resources:
3617  One:
3618    Type: AWS::S3::Bucket
3619    Properties: {BucketName: !Ref A}
3620  Two:
3621    Type: AWS::S3::Bucket
3622    Properties: {BucketName: !Ref B}
3623"#;
3624        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3625        let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3626        assert!(conflicts.is_empty(), "different overridable parameters must not collide: {:?}", conflicts);
3627    }
3628
3629    #[test]
3630    fn primary_id_comparison_identity_is_separate_from_display_value() {
3631        let template = r#"
3632Parameters:
3633  Name: {Type: String}
3634Resources:
3635  DynamicOne:
3636    Type: AWS::S3::Bucket
3637    Properties: {BucketName: !Ref Name}
3638  DynamicTwo:
3639    Type: AWS::S3::Bucket
3640    Properties: {BucketName: !Ref Name}
3641  LiteralOne:
3642    Type: AWS::S3::Bucket
3643    Properties: {BucketName: 'Ref("Name")'}
3644  LiteralTwo:
3645    Type: AWS::S3::Bucket
3646    Properties: {BucketName: 'Ref("Name")'}
3647"#;
3648        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3649        let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3650        assert_eq!(conflicts.len(), 2, "equal display text must not merge distinct identities");
3651        assert!(
3652            conflicts
3653                .iter()
3654                .all(|(tuple, resources)| { tuple == &["Ref(\"Name\")".to_string()] && resources.len() == 2 })
3655        );
3656    }
3657
3658    #[test]
3659    fn primary_id_mutually_exclusive_conditions_do_not_collide() {
3660        let template = r#"
3661Parameters:
3662  Env: {Type: String}
3663  Name: {Type: String}
3664Conditions:
3665  IsProd: !Equals [!Ref Env, prod]
3666  IsNotProd: !Not [!Condition IsProd]
3667Resources:
3668  ProdBucket:
3669    Type: AWS::S3::Bucket
3670    Condition: IsProd
3671    Properties: {BucketName: !Ref Name}
3672  DevBucket:
3673    Type: AWS::S3::Bucket
3674    Condition: IsNotProd
3675    Properties: {BucketName: !Ref Name}
3676"#;
3677        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3678        let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3679        assert!(conflicts.is_empty(), "mutually exclusive conditions must not collide");
3680    }
3681
3682    #[test]
3683    fn primary_id_no_internal_marker_leakage_in_tuple_values() {
3684        let template = r#"
3685Parameters:
3686  Name: {Type: String}
3687Resources:
3688  A:
3689    Type: AWS::S3::Bucket
3690    Properties: {BucketName: !Ref Name}
3691  B:
3692    Type: AWS::S3::Bucket
3693    Properties: {BucketName: !Ref Name}
3694"#;
3695        let model = SemanticModel::from_bytes(template.as_bytes()).unwrap();
3696        let conflicts = model.primary_identifier_conflicts("AWS::S3::Bucket", &["BucketName".to_string()]);
3697        assert_eq!(conflicts.len(), 1);
3698        let (tuple, _) = conflicts.into_iter().next().unwrap();
3699        let value = &tuple[0];
3700        assert!(!value.contains("$dyn:"), "tuple value must not contain $dyn: marker");
3701        assert!(!value.contains("expr:"), "tuple value must not contain expr: prefix");
3702    }
3703
3704    #[test]
3705    fn output_invalid_ref_has_canonical_path() {
3706        let template = "Resources:\n  R:\n    Type: T\nOutputs:\n  Bad:\n    Value: !Ref Missing\n";
3707        let result = SemanticModel::parse(template.as_bytes(), ParseConfig::default()).unwrap();
3708        let ref_defect = result
3709            .model
3710            .diagnostics
3711            .iter()
3712            .find(|d| d.rule_id == "F6101" && d.message.contains("Missing"))
3713            .expect("F6101 for missing ref");
3714        assert_eq!(
3715            ref_defect.property_path.as_deref(),
3716            Some("Outputs.Bad.Value.Ref"),
3717            "parse-time output ref diagnostic must have dot-separated path"
3718        );
3719        assert_ne!(ref_defect.span, crate::UNKNOWN_SPAN, "span must be resolved");
3720    }
3721}