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