Skip to main content

template_model/
model.rs

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