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