Skip to main content

schema_validator/
validate.rs

1use crate::compiled::{CompiledSchema, ConditionSchema, PropSchema, PropType, SubSchema};
2use crate::store::CompiledSchemaStore;
3use diagnostics::{Diagnostic, Phase, RegisteredDiagnostic, ViolationContext, resolve_section_span};
4use rules::format_rule_for_format;
5use std::borrow::Cow;
6use std::collections::{BTreeSet, HashMap, HashSet};
7use std::sync::{Arc, LazyLock};
8use template_model::coercion::{CoerceResult, coerce_to_number, coerce_to_string, coerce_value, scalar_eq};
9use template_model::conditions::Satisfiability;
10use template_model::consts::{
11    FN_CONDITION, FN_FOR_EACH_KEY_PREFIX, FN_IF, FN_REF, INTRINSIC_FN_PATH_SEGMENTS, KEY_PROPERTIES, KEY_TYPE,
12    MAX_REQUIRED_PROPERTY_COMBINATIONS, MAX_SCHEMA_MATCH_DEPTH as MAX_MATCH_DEPTH,
13    MAX_SCHEMA_SCENARIO_ASSIGNMENTS as MAX_GROUP_SCENARIO_ASSIGNMENTS,
14    MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS as MAX_GROUP_SCENARIO_MERGE_ATTEMPTS, PARAM_TYPE_COMMA_DELIMITED_LIST,
15    PARAM_TYPE_NUMBER, PARAM_TYPE_STRING, SAM_FUNCTION_TYPE, SAM_SERVERLESS_TYPE_PREFIX,
16};
17use template_model::message::{render_str_list, render_value, render_value_list};
18use template_model::model::ResolvedResource;
19use template_model::region_enums;
20use template_model::resolver::{RefKind, ResolvedValue};
21use template_model::{
22    BudgetKind, CompiledPattern, IAM_ROLE_ARN_PATTERN, SECURITY_GROUP_NAME_PATTERN, SemanticModel, compile_pattern,
23    is_custom_resource_type, resolved_value_to_json,
24};
25
26/// Properties that accept a string value when used with `aws cloudformation package`.
27/// Type checks are skipped for these paths when the value is a string.
28const PACKAGING_PROPERTY_PATHS: &[(&str, &str)] = &[
29    ("AWS::Lambda::Function", "Properties.Code"),
30    ("AWS::Lambda::LayerVersion", "Properties.Content"),
31    ("AWS::ApiGateway::RestApi", "Properties.BodyS3Location"),
32    ("AWS::ElasticBeanstalk::ApplicationVersion", "Properties.SourceBundle"),
33    ("AWS::StepFunctions::StateMachine", "Properties.DefinitionS3Location"),
34    ("AWS::AppSync::GraphQLSchema", "Properties.DefinitionS3Location"),
35    ("AWS::AppSync::Resolver", "Properties.RequestMappingTemplateS3Location"),
36    ("AWS::AppSync::Resolver", "Properties.ResponseMappingTemplateS3Location"),
37    ("AWS::AppSync::FunctionConfiguration", "Properties.RequestMappingTemplateS3Location"),
38    ("AWS::AppSync::FunctionConfiguration", "Properties.ResponseMappingTemplateS3Location"),
39    ("AWS::CloudFormation::Stack", "Properties.TemplateURL"),
40    ("AWS::CodeCommit::Repository", "Properties.Code.S3"),
41];
42
43/// Property paths where type validation is skipped entirely because
44/// the property accepts free-form user-defined content.
45const TYPE_CHECK_EXEMPT_PATHS: &[(&str, &str)] = &[
46    ("AWS::Lambda::Function", "Properties.Environment.Variables"),
47    ("AWS::Lambda::Function", "Properties.Environment"),
48];
49
50/// Returns true if the value is an unresolved or malformed intrinsic function:
51/// a JSON object with a single *known* function key (`Fn::<name>`, `Ref`,
52/// `Condition`, or an `Fn::ForEach::` loop key). Only known names are skipped -
53/// a single-key object whose key merely starts with `Fn::` (e.g. a map entry
54/// literally named `Fn::Custom`) is plain data and must be schema-validated
55/// like any other object.
56fn is_intrinsic_key(key: &str) -> bool {
57    INTRINSIC_FN_PATH_SEGMENTS.contains(&key)
58        || key == FN_REF
59        || key == FN_CONDITION
60        || key.starts_with(FN_FOR_EACH_KEY_PREFIX)
61}
62
63fn is_unresolved_intrinsic(val: &serde_json::Value) -> bool {
64    let Some(obj) = val.as_object() else { return false };
65    if obj.len() != 1 {
66        return false;
67    }
68    is_intrinsic_key(obj.keys().next().unwrap())
69}
70
71fn condition_context_matches_scenario(
72    m: &Arc<SemanticModel>,
73    context: Option<&str>,
74    scenario: &HashMap<String, bool>,
75) -> bool {
76    let Some(context) = context else {
77        return true;
78    };
79    let mut combined = scenario.clone();
80    for assumption in context.split(',') {
81        let (condition, expected) = assumption.strip_prefix('!').map_or((assumption, true), |name| (name, false));
82        if let Some(actual) = combined.insert(condition.to_string(), expected)
83            && actual != expected
84        {
85            return false;
86        }
87    }
88    is_satisfiable(m, &combined)
89}
90
91/// Whether the authored value at `prop_path` reads a caller-overridable template
92/// parameter in the current condition scenario. Reference edges retain nested
93/// intrinsic paths and branch assumptions, so a parameter in one `Fn::If`
94/// branch does not hide a deterministic violation in the other branch.
95fn value_depends_on_parameter_in_scenario(
96    m: &Arc<SemanticModel>,
97    rid: &str,
98    prop_path: &str,
99    scenario: &HashMap<String, bool>,
100) -> bool {
101    let descendant_prefix = format!("{prop_path}.");
102    m.graph.outgoing_edges(rid).any(|edge| {
103        (edge.source_path == prop_path || edge.source_path.starts_with(&descendant_prefix))
104            && m.parameters.contains_key(&edge.target)
105            && condition_context_matches_scenario(m, edge.condition_context.as_deref(), scenario)
106    })
107}
108
109fn defer_value_constraints(
110    m: &Arc<SemanticModel>,
111    rid: &str,
112    prop_path: &str,
113    val: &serde_json::Value,
114    scenario: &HashMap<String, bool>,
115) -> bool {
116    if value_depends_on_parameter_in_scenario(m, rid, prop_path, scenario) {
117        return true;
118    }
119
120    let mut pending = vec![(prop_path.to_string(), val)];
121    while let Some((path, candidate)) = pending.pop() {
122        if is_unresolved_intrinsic(candidate)
123            || (m.is_from_intrinsic(rid, &path)
124                && coerce_to_string(candidate).is_some_and(|value| value.contains("${")))
125        {
126            return true;
127        }
128
129        match candidate {
130            serde_json::Value::Array(items) => {
131                pending.extend(
132                    items
133                        .iter()
134                        .enumerate()
135                        .map(|(index, item)| (append_property_path(&path, &index.to_string()), item)),
136                );
137            }
138            serde_json::Value::Object(properties) => {
139                pending
140                    .extend(properties.iter().map(|(property, value)| (append_property_path(&path, property), value)));
141            }
142            _ => {}
143        }
144    }
145    false
146}
147
148pub fn validate_all_resources(
149    store: &CompiledSchemaStore,
150    model: &Arc<SemanticModel>,
151    region: Option<&str>,
152) -> Vec<Diagnostic> {
153    reset_scenario_filter();
154    reset_schema_budget_exhaustions();
155    let mut out = Vec::new();
156    let relevant: HashSet<&str> = model.resources_by_type.keys().map(String::as_str).collect();
157
158    validate_lifecycle(&mut out, store, model);
159
160    for rtype in &relevant {
161        // Custom resources, modules, and SAM resources (rewritten by the SAM
162        // transform before deployment) are not region-scoped provider types, so
163        // the region check skips them - CloudFormation validates the
164        // post-transform template.
165        if rtype.ends_with("::MODULE") || rtype.starts_with("Custom::") || rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX)
166        {
167            continue;
168        }
169
170        // Only flag a genuine regional provider type that is absent in the target
171        // region. A type that appears in no region's map at all (e.g. an empty or
172        // transform-generated placeholder type) is not a region-availability
173        // problem and must not be reported here, or good templates regress. With
174        // no region configured the type is validated against the union of all
175        // regions: available if it exists in any region, so a type known in some
176        // region is never flagged - the availability check only runs for a
177        // configured region.
178        if let Some(region) = region
179            && store.is_known_in_any_region(rtype)
180            && !store.is_available_in_region(rtype, region)
181        {
182            for rid in model.resources_of_type(rtype) {
183                // A resource guarded by a Condition that cannot hold in the target
184                // region is never created there, so its type's absence in that
185                // region is not an error. The satisfiability check pins
186                // AWS::Region to the target region and asks whether the condition
187                // can hold there, so a condition like
188                // `!Equals [AWS::Region, us-east-1]` is unsatisfiable at any other
189                // region and the finding is skipped - even at the DEFAULT region,
190                // where AWS::Region is otherwise a free SAT variable.
191                if let Some(res) = model.resources.get(rid.as_str())
192                    && let Some(cond) = res.condition.as_deref()
193                    && !model.conditions.is_satisfiable_in_region(&[(cond.to_string(), true)], region)
194                {
195                    continue;
196                }
197                // Report the region-availability message at the Type node
198                // (a Fatal F3006 here).
199                out.push(build_diagnostic(
200                    "F3006",
201                    &format!("Resource type '{}' does not exist in '{}'", rtype, region),
202                    model,
203                    rid,
204                    KEY_TYPE,
205                    None,
206                ));
207            }
208            continue;
209        }
210
211        let Some(schema) = store.get(rtype) else {
212            continue;
213        };
214        for rid in model.resources_of_type(rtype) {
215            let Some(res) = model.resources.get(rid.as_str()) else {
216                continue;
217            };
218            // A structurally invalid logical ID (non-alphanumeric) is itself a
219            // template error; CloudFormation rejects the resource outright before
220            // its properties matter, so skip all property-level validation here to
221            // avoid surfacing property diagnostics (e.g. format checks) for a
222            // resource that cannot be created.
223            if !is_valid_logical_id(rid) {
224                continue;
225            }
226            // AWS::Serverless::* resources are rewritten by the SAM transform
227            // before deployment, so their authored form does not have to satisfy
228            // the raw resource schema (required properties, etc. are supplied or
229            // relaxed during expansion). Validating the pre-transform shape would
230            // flag requirements the transform fills in.
231            if rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX) {
232                continue;
233            }
234            validate_resource(&mut out, store, model, rid, res, schema, region);
235            validate_extensions(&mut out, store, model, rid, res);
236        }
237    }
238    drain_schema_budget_exhaustions(model);
239    out
240}
241
242/// CloudFormation logical IDs must be alphanumeric (`[A-Za-z0-9]+`). A resource
243/// whose ID violates this is rejected outright, so property-level schema checks
244/// against it would be noise.
245fn is_valid_logical_id(rid: &str) -> bool {
246    !rid.is_empty() && rid.bytes().all(|b| b.is_ascii_alphanumeric())
247}
248
249pub fn enrich_schema_context(diagnostics: &mut [Diagnostic], store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
250    reset_scenario_filter();
251    for d in diagnostics.iter_mut() {
252        if d.phase != Some(Phase::Schema) {
253            continue;
254        }
255        let Some(rid) = d.resource_logical_id().map(String::from) else {
256            continue;
257        };
258        let Some(res) = model.resources.get(rid.as_str()) else {
259            continue;
260        };
261        let Some(schema) = store.get(&res.resource_type) else {
262            continue;
263        };
264
265        if d.documentation_url.is_none() {
266            if let Some(ref url) = schema.documentation_url {
267                d.documentation_url = Some(url.clone());
268            } else if let Some(ref url) = schema.source_url {
269                d.documentation_url = Some(url.clone());
270            }
271        }
272
273        if let Some(source) = describe_resolution(model, &rid, d.property_path.as_deref().unwrap_or("")) {
274            let ctx = d.context.get_or_insert_with(|| ViolationContext {
275                actual_value: None,
276                expected_constraint: None,
277                property: None,
278                lifecycle: None,
279                resolution_source: None,
280                extra: None,
281            });
282            if ctx.resolution_source.is_none() {
283                ctx.resolution_source = Some(source);
284            }
285        }
286
287        let pp = d.property_path.as_deref().unwrap_or("");
288        let prop_path = pp.strip_prefix("Properties.").unwrap_or(pp);
289        let prop_schema = find_prop_schema_deep(prop_path, schema);
290
291        macro_rules! ensure_ctx {
292            ($d:expr) => {
293                $d.context.get_or_insert_with(|| ViolationContext {
294                    actual_value: None,
295                    expected_constraint: None,
296                    property: None,
297                    lifecycle: None,
298                    resolution_source: None,
299                    extra: None,
300                })
301            };
302        }
303
304        match d.rule_id.as_str() {
305            "F3012" => {
306                if let Some(ps) = prop_schema
307                    && let Some(ref pt) = ps.prop_type
308                {
309                    ensure_ctx!(d).expected_constraint = Some(pt.primary().unwrap_or("unknown").to_string());
310                }
311            }
312            "F3030" | "W3030" => {
313                if let Some(ps) = prop_schema {
314                    let allowed = if !ps.enum_values.is_empty() { &ps.enum_values } else { &ps.enum_case_insensitive };
315                    if !allowed.is_empty() {
316                        ensure_ctx!(d)
317                            .extra
318                            .get_or_insert_with(HashMap::new)
319                            .insert("allowed_values".into(), serde_json::json!(allowed).into());
320                    }
321                }
322            }
323            "F3031" => {
324                if let Some(ps) = prop_schema
325                    && let Some(ref pat) = ps.pattern
326                {
327                    ensure_ctx!(d).expected_constraint = Some(pat.clone());
328                }
329            }
330            "F3034" => {
331                if let Some(ps) = prop_schema {
332                    let ctx = ensure_ctx!(d);
333                    if let Some(v) = ps.minimum {
334                        ctx.extra
335                            .get_or_insert_with(HashMap::new)
336                            .insert("minimum".into(), serde_json::json!(v).into());
337                    }
338                    if let Some(v) = ps.maximum {
339                        ctx.extra
340                            .get_or_insert_with(HashMap::new)
341                            .insert("maximum".into(), serde_json::json!(v).into());
342                    }
343                    if let Some(v) = ps.exclusive_minimum {
344                        ctx.extra
345                            .get_or_insert_with(HashMap::new)
346                            .insert("exclusive_minimum".into(), serde_json::json!(v).into());
347                    }
348                    if let Some(v) = ps.exclusive_maximum {
349                        ctx.extra
350                            .get_or_insert_with(HashMap::new)
351                            .insert("exclusive_maximum".into(), serde_json::json!(v).into());
352                    }
353                }
354            }
355            "F3033" => {
356                if let Some(ps) = prop_schema {
357                    let ctx = ensure_ctx!(d);
358                    if let Some(v) = ps.min_length {
359                        ctx.extra
360                            .get_or_insert_with(HashMap::new)
361                            .insert("min_length".into(), serde_json::json!(v).into());
362                    }
363                    if let Some(v) = ps.max_length {
364                        ctx.extra
365                            .get_or_insert_with(HashMap::new)
366                            .insert("max_length".into(), serde_json::json!(v).into());
367                    }
368                }
369            }
370            "F3032" => {
371                if let Some(ps) = prop_schema {
372                    let ctx = ensure_ctx!(d);
373                    if let Some(v) = ps.min_items {
374                        ctx.extra
375                            .get_or_insert_with(HashMap::new)
376                            .insert("min_items".into(), serde_json::json!(v).into());
377                    }
378                    if let Some(v) = ps.max_items {
379                        ctx.extra
380                            .get_or_insert_with(HashMap::new)
381                            .insert("max_items".into(), serde_json::json!(v).into());
382                    }
383                }
384            }
385            "F3002" => {
386                let mut allowed: Vec<&str> = schema.properties.keys().map(|s| s.as_str()).collect();
387                allowed.sort();
388                ensure_ctx!(d)
389                    .extra
390                    .get_or_insert_with(HashMap::new)
391                    .insert("allowed_properties".into(), serde_json::json!(allowed).into());
392            }
393            "W9009" => {
394                ensure_ctx!(d).lifecycle = Some("deprecated".into());
395            }
396            "I9001" => {
397                let ctx = ensure_ctx!(d);
398                let lifecycle = if schema.create_only_properties.iter().any(|pointer| pointer == prop_path) {
399                    Some("create-only")
400                } else if schema.conditional_create_only_properties.iter().any(|pointer| pointer == prop_path) {
401                    Some("conditional-create-only")
402                } else {
403                    None
404                };
405                ctx.lifecycle = lifecycle.map(String::from);
406                if let Some(ref rs) = schema.replacement_strategy {
407                    ctx.extra
408                        .get_or_insert_with(HashMap::new)
409                        .insert("replacement_strategy".into(), serde_json::json!(rs).into());
410                }
411            }
412            "W9054" => {
413                ensure_ctx!(d).lifecycle = Some("write-only".into());
414            }
415            _ => {}
416        }
417    }
418    // Drain any budget exhaustions recorded by schema-local functions (e.g.
419    // depth-limited matching) during the context enrichment pass.
420    drain_schema_budget_exhaustions(model);
421}
422
423pub fn enrich_schema_context_standalone(diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
424    let store = CompiledSchemaStore::new();
425    enrich_schema_context(diagnostics, &store, model);
426}
427
428/// Continues a property-path lookup into `resolved`, or returns it when the path
429/// ends here. When resolution had to build an effective schema (a `$ref` with
430/// constraints of its own), the sub-schema is cloned out of it so the result does
431/// not borrow from the temporary.
432fn descend<'a>(
433    resolved: Cow<'a, PropSchema>,
434    rest: Option<&str>,
435    defs: &'a HashMap<String, PropSchema>,
436) -> Option<Cow<'a, PropSchema>> {
437    let Some(rest) = rest else {
438        return Some(resolved);
439    };
440    match resolved {
441        Cow::Borrowed(schema) => find_prop_schema(rest, &schema.properties, defs),
442        Cow::Owned(schema) => {
443            find_prop_schema(rest, &schema.properties, defs).map(|found| Cow::Owned(found.into_owned()))
444        }
445    }
446}
447
448fn find_prop_schema<'a>(
449    path: &str,
450    props: &'a HashMap<String, PropSchema>,
451    defs: &'a HashMap<String, PropSchema>,
452) -> Option<Cow<'a, PropSchema>> {
453    let mut segments = path.splitn(2, '.');
454    let top = segments.next()?;
455    let rest = segments.next().filter(|r| !r.is_empty());
456
457    if let Some(ps) = props.get(top) {
458        return descend(ps.resolve(defs), rest, defs);
459    }
460    for (pat, ps) in props.iter() {
461        if compile_pattern(pat).is_some_and(|re| re.is_match(top)) {
462            return descend(ps.resolve(defs), rest, defs);
463        }
464    }
465    None
466}
467
468fn find_prop_schema_deep<'a>(path: &str, schema: &'a CompiledSchema) -> Option<Cow<'a, PropSchema>> {
469    if let Some(ps) = find_prop_schema(path, &schema.properties, &schema.definitions) {
470        return Some(ps);
471    }
472    for sub in schema.one_of.iter().chain(schema.any_of.iter()).chain(schema.all_of.iter()) {
473        if let Some(ps) = find_prop_schema(path, &sub.properties, &schema.definitions) {
474            return Some(ps);
475        }
476    }
477    for ite in &schema.if_then_else {
478        if let Some(ref then_s) = ite.then_schema
479            && let Some(ps) = find_prop_schema(path, &then_s.properties, &schema.definitions)
480        {
481            return Some(ps);
482        }
483        if let Some(ref else_s) = ite.else_schema
484            && let Some(ps) = find_prop_schema(path, &else_s.properties, &schema.definitions)
485        {
486            return Some(ps);
487        }
488    }
489    None
490}
491
492/// Resolves a schema lifecycle pointer (a dotted property path such as
493/// `Source.Decryption.Url`, where `*` denotes an array element) against the
494/// resource's resolved properties, returning the pointer when that leaf is
495/// actually set in the template and `None` otherwise.
496///
497/// Lifecycle pointer lists frequently address a nested sub-property while the
498/// top-level parent is a required, non-deprecated block; keying only off the
499/// parent's presence would flag correct templates. The array-wildcard token is
500/// translated to the resolver's `{}` form so a deprecated leaf inside any array
501/// element is detected.
502fn present_lifecycle_pointer(m: &Arc<SemanticModel>, rid: &str, base: &str, pointer: &str) -> Option<String> {
503    let resolver_path = pointer.replace(".*.", ".{}.");
504    m.resolve_deep(rid, &format!("{}.{}", base, resolver_path)).map(|_| pointer.to_string())
505}
506
507fn validate_resource(
508    out: &mut Vec<Diagnostic>,
509    store: &CompiledSchemaStore,
510    m: &Arc<SemanticModel>,
511    rid: &str,
512    res: &ResolvedResource,
513    schema: &CompiledSchema,
514    region: Option<&str>,
515) {
516    let base = KEY_PROPERTIES;
517    let defs = &schema.definitions;
518
519    // Lifecycle pointers (deprecated/create-only/write-only) address a specific
520    // property, which for many resources is a nested sub-property (e.g. MediaConnect
521    // Flow deprecates only `Source.Decryption.Url`, never the required `Source`
522    // itself). Matching on the leaf's actual presence - not merely the top-level
523    // parent's - is required to avoid flagging correct templates.
524    for dp in &schema.deprecated_properties {
525        if let Some(pointer) = present_lifecycle_pointer(m, rid, base, dp) {
526            out.push(build_diagnostic(
527                "W9009",
528                &format!("Property '{}' is deprecated", pointer),
529                m,
530                rid,
531                &format!("{}.{}", base, pointer),
532                None,
533            ));
534        }
535    }
536
537    for cp in &schema.create_only_properties {
538        if let Some(pointer) = present_lifecycle_pointer(m, rid, base, cp) {
539            out.push(build_diagnostic(
540                "I9001",
541                &format!("Property '{}' is create-only; updating it will cause resource replacement", pointer),
542                m,
543                rid,
544                &format!("{}.{}", base, pointer),
545                None,
546            ));
547        }
548    }
549
550    for conditional_pointer in &schema.conditional_create_only_properties {
551        // Unconditional list takes precedence: skip if already reported above.
552        if schema.create_only_properties.contains(conditional_pointer) {
553            continue;
554        }
555        if let Some(pointer) = present_lifecycle_pointer(m, rid, base, conditional_pointer) {
556            out.push(build_diagnostic(
557                "I9001",
558                &format!(
559                    "Property '{}' is conditionally create-only; updating it may cause resource replacement",
560                    pointer
561                ),
562                m,
563                rid,
564                &format!("{}.{}", base, pointer),
565                None,
566            ));
567        }
568    }
569
570    for wo in &schema.write_only_properties {
571        for edge in m.graph.incoming(rid) {
572            if let RefKind::GetAtt { attr } = &edge.kind
573                && attr == wo
574                && edge.source_resource.starts_with("__output__")
575            {
576                let output_name = edge.source_resource.strip_prefix("__output__").unwrap_or(&edge.source_resource);
577                out.push(build_diagnostic(
578                    "W9054",
579                    &format!("Write-only property '{}' of '{}' is referenced in output '{}'", wo, rid, output_name),
580                    m,
581                    rid,
582                    &format!("{}.{}", base, wo),
583                    None,
584                ));
585            }
586        }
587    }
588
589    // When the whole Properties block is a deploy-time intrinsic (e.g.
590    // `Properties: !Ref AWS::NoValue`), the resolved view is empty and every
591    // required property would look missing. The effective properties are not
592    // known statically, so skip the key/required-property checks.
593    let key_scenarios = if res.properties_dynamic { Vec::new() } else { resource_property_key_scenarios(m, rid, res) };
594    for (actual_keys, conds) in &key_scenarios {
595        let scenario = if conds.is_empty() { None } else { Some(conds) };
596        validate_object_keys_inner(
597            out,
598            m,
599            rid,
600            &res.resource_type,
601            &schema.properties,
602            defs,
603            &schema.required,
604            schema.additional_properties,
605            &HashMap::new(),
606            &schema.dependent_required,
607            &schema.dependent_excluded,
608            &schema.required_or,
609            &schema.required_xor,
610            &schema.all_of,
611            &schema.any_of,
612            &schema.one_of,
613            actual_keys,
614            base,
615            scenario,
616        );
617    }
618
619    for prop_name in res.properties.keys() {
620        let Some(prop_schema) = schema.properties.get(prop_name) else {
621            continue;
622        };
623        let resolved = prop_schema.resolve(defs);
624        let prop_path = format!("{}.{}", base, prop_name);
625        validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
626    }
627
628    // Also validate properties that exist only inside conditional branches -
629    // when Properties is wrapped in Fn::If, res.properties has only the
630    // synthetic "Fn::If" key so the loop above would miss per-branch props.
631    let branch_property_names: HashSet<String> =
632        key_scenarios.iter().flat_map(|(keys, _)| keys.iter().cloned()).collect();
633    for prop_name in &branch_property_names {
634        if res.properties.contains_key(prop_name) {
635            continue;
636        }
637        let Some(prop_schema) = schema.properties.get(prop_name) else {
638            continue;
639        };
640        let resolved = prop_schema.resolve(defs);
641        let prop_path = format!("{}.{}", base, prop_name);
642        validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
643    }
644
645    let actual_keys: Vec<String> = res.properties.keys().cloned().collect();
646    for ite in &schema.if_then_else {
647        let matches = condition_matches(&ite.condition, &actual_keys, m, rid, defs);
648        let sub = if matches { &ite.then_schema } else { &ite.else_schema };
649        if let Some(sub) = sub {
650            if ite.enforce_full_branch {
651                // An overlay-stated conditional is enforced in full - its
652                // `required` list, `additionalProperties`, dependency maps, and
653                // property value constraints - so nothing the author wrote is
654                // silently dropped.
655                validate_sub(out, m, rid, &res.resource_type, &actual_keys, sub, defs, base, 0);
656            } else {
657                // Bundled conditionals enforce co-dependencies only: their
658                // richer semantics are owned by dedicated resource-specific
659                // rules, and enforcing them generically would double-report
660                // (see `IfThenElse::enforce_full_branch`).
661                validate_sub_dependencies(out, m, rid, &actual_keys, sub, base);
662            }
663        }
664    }
665}
666
667fn validate_object_keys(
668    out: &mut Vec<Diagnostic>,
669    m: &Arc<SemanticModel>,
670    rid: &str,
671    rtype: &str,
672    schema_props: &HashMap<String, PropSchema>,
673    defs: &HashMap<String, PropSchema>,
674    required: &[String],
675    additional_properties: Option<bool>,
676    pattern_props: &HashMap<String, PropSchema>,
677    dep_req: &HashMap<String, Vec<String>>,
678    dep_excl: &HashMap<String, Vec<String>>,
679    req_or: &[String],
680    req_xor: &[String],
681    all_of: &[SubSchema],
682    any_of: &[SubSchema],
683    one_of: &[SubSchema],
684    actual_keys: &[String],
685    base_path: &str,
686) {
687    validate_object_keys_inner(
688        out,
689        m,
690        rid,
691        rtype,
692        schema_props,
693        defs,
694        required,
695        additional_properties,
696        pattern_props,
697        dep_req,
698        dep_excl,
699        req_or,
700        req_xor,
701        all_of,
702        any_of,
703        one_of,
704        actual_keys,
705        base_path,
706        None,
707    )
708}
709
710fn validate_required_groups(
711    out: &mut Vec<Diagnostic>,
712    m: &Arc<SemanticModel>,
713    rid: &str,
714    actual_keys: &[String],
715    required_or: &[String],
716    required_xor: &[String],
717    base_path: &str,
718) {
719    if required_or.is_empty() && required_xor.is_empty() {
720        return;
721    }
722
723    // Collect scenario assignments for group evaluation.
724    // When an active SCENARIO_FILTER exists (inside validate_sub_under_assignment
725    // for oneOf/anyOf branch matching), it seeds the expansion so nested
726    // independent conditions are still evaluated across all their worlds
727    // relative to the outer constraint.
728    let members: Vec<&str> = required_or.iter().chain(required_xor.iter()).map(String::as_str).collect();
729    let Some(assignments) = required_group_scenario_assignments(m, rid, &members, base_path) else {
730        // Budget exceeded — fall back to targeted proof search that avoids
731        // full Cartesian enumeration while still detecting provable violations.
732        validate_required_groups_budget_fallback(out, m, rid, actual_keys, required_or, required_xor, base_path);
733        return;
734    };
735
736    if !required_or.is_empty() {
737        for assignment in &assignments {
738            let any_present = required_or.iter().any(|property| {
739                actual_keys.iter().any(|actual| actual == property)
740                    && property_present_under(m, rid, base_path, property, assignment)
741            });
742            if !any_present {
743                let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
744                out.push(build_diagnostic_conditional(
745                    "F3058",
746                    &format!("One of [{names}] is a required property"),
747                    m,
748                    rid,
749                    base_path,
750                    None,
751                    assignment_condition_map(assignment),
752                ));
753            }
754        }
755    }
756
757    if !required_xor.is_empty() {
758        for assignment in &assignments {
759            let count = required_xor
760                .iter()
761                .filter(|property| {
762                    actual_keys.iter().any(|actual| actual == property.as_str())
763                        && property_present_under(m, rid, base_path, property, assignment)
764                })
765                .count();
766            if count != 1 {
767                let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
768                out.push(build_diagnostic_conditional(
769                    "F3014",
770                    &format!("Exactly one of [{names}] must be specified"),
771                    m,
772                    rid,
773                    base_path,
774                    None,
775                    assignment_condition_map(assignment),
776                ));
777            }
778        }
779    }
780}
781
782/// Finds a concrete condition assignment proving that every authored member is
783/// absent. Missing members are already absent and add no constraint. The search
784/// explores only null alternatives and backtracks across nested conditionals,
785/// so it does not materialize the full product of present and absent worlds.
786fn all_members_absent_witness(
787    m: &Arc<SemanticModel>,
788    rid: &str,
789    actual_keys: &[String],
790    members: &[String],
791    base_path: &str,
792) -> Option<HashMap<String, bool>> {
793    let seed = active_scenario_filter(m, rid)?;
794
795    let mut alternatives = Vec::new();
796    for property in members {
797        if !actual_keys.iter().any(|actual| actual == property) {
798            continue;
799        }
800        let property_alternatives = property_presence_alternatives(m, rid, base_path, property, false, &seed);
801        if property_alternatives.is_empty() {
802            return None;
803        }
804        alternatives.push(property_alternatives);
805    }
806    alternatives.sort_by_key(Vec::len);
807    let mut merge_attempts = 0;
808    compatible_alternative_witness(m, &alternatives, 0, &seed, &mut merge_attempts)
809}
810
811/// Finds a concrete condition assignment proving that two distinct authored
812/// members are simultaneously present. Exactly-two is sufficient to prove a
813/// requiredXor violation and avoids enumerating every other member's state.
814fn multiple_members_present_witness(
815    m: &Arc<SemanticModel>,
816    rid: &str,
817    actual_keys: &[String],
818    members: &[String],
819    base_path: &str,
820) -> Option<HashMap<String, bool>> {
821    let seed = active_scenario_filter(m, rid)?;
822
823    let present_alternatives: Vec<Vec<HashMap<String, bool>>> = members
824        .iter()
825        .filter(|property| actual_keys.iter().any(|actual| actual == property.as_str()))
826        .map(|property| property_presence_alternatives(m, rid, base_path, property, true, &seed))
827        .collect();
828
829    let mut merge_attempts = 0;
830    for left in 0..present_alternatives.len() {
831        for right in (left + 1)..present_alternatives.len() {
832            for left_assignment in &present_alternatives[left] {
833                let Some(left_merged) = try_merge_assignments(&seed, left_assignment) else {
834                    continue;
835                };
836                if !assignment_is_proven_satisfiable(m, &left_merged) {
837                    continue;
838                }
839                for right_assignment in &present_alternatives[right] {
840                    if merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
841                        return None;
842                    }
843                    merge_attempts += 1;
844                    let Some(merged) = try_merge_assignments(&left_merged, right_assignment) else {
845                        continue;
846                    };
847                    if assignment_is_proven_satisfiable(m, &merged) {
848                        return Some(merged);
849                    }
850                }
851            }
852        }
853    }
854    None
855}
856
857/// The active composition assignment plus the resource's own creation
858/// condition. A schema violation is actionable only in a world where the
859/// resource can exist, so an impossible or unproven seed yields no witness.
860fn active_scenario_filter(m: &Arc<SemanticModel>, rid: &str) -> Option<HashMap<String, bool>> {
861    let mut seed = SCENARIO_FILTER.with(|filter| filter.borrow().clone().unwrap_or_default());
862    if m.resource_condition_is_valid(rid)
863        && let Some(condition) = m.resources.get(rid).and_then(|resource| resource.condition.as_ref())
864        && let Some(previous) = seed.insert(condition.clone(), true)
865        && !previous
866    {
867        return None;
868    }
869    assignment_is_proven_satisfiable(m, &seed).then_some(seed)
870}
871
872/// Returns the distinct reachable assignments under which one property is
873/// present (`want_present`) or absent. Raw scenarios are used so dynamic values
874/// still count as present, while only a concrete null represents
875/// `AWS::NoValue` absence.
876fn property_presence_alternatives(
877    m: &Arc<SemanticModel>,
878    rid: &str,
879    base_path: &str,
880    property: &str,
881    want_present: bool,
882    filter: &HashMap<String, bool>,
883) -> Vec<HashMap<String, bool>> {
884    if m.scenario_budget_exhausted() {
885        return Vec::new();
886    }
887    let (scenarios, was_curtailed) =
888        m.resolve_scenarios_with_limit(rid, &format!("{base_path}.{property}"), MAX_GROUP_PROPERTY_SCENARIOS);
889    if was_curtailed && !m.scenario_budget_exhausted() {
890        m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
891    }
892    let mut alternatives = Vec::new();
893    let mut seen = HashSet::new();
894    for (value, conditions) in scenarios {
895        let present = !matches!(value, ResolvedValue::Concrete { value } if value.is_null());
896        if present != want_present {
897            continue;
898        }
899        let Some(merged) = try_merge_assignments(filter, &conditions) else {
900            continue;
901        };
902        if assignment_is_proven_satisfiable(m, &merged) && seen.insert(canonical_assignment(&conditions)) {
903            alternatives.push(conditions);
904        }
905    }
906    alternatives
907}
908
909fn compatible_alternative_witness(
910    m: &Arc<SemanticModel>,
911    alternatives: &[Vec<HashMap<String, bool>>],
912    index: usize,
913    assignment: &HashMap<String, bool>,
914    merge_attempts: &mut usize,
915) -> Option<HashMap<String, bool>> {
916    let Some(group) = alternatives.get(index) else {
917        return Some(assignment.clone());
918    };
919    for alternative in group {
920        if *merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
921            return None;
922        }
923        *merge_attempts += 1;
924        let Some(merged) = try_merge_assignments(assignment, alternative) else {
925            continue;
926        };
927        if !assignment_is_proven_satisfiable(m, &merged) {
928            continue;
929        }
930        if let Some(witness) = compatible_alternative_witness(m, alternatives, index + 1, &merged, merge_attempts) {
931            return Some(witness);
932        }
933    }
934    None
935}
936
937/// A Fatal diagnostic requires an exact satisfiable witness. The condition
938/// solver's ordinary boolean API intentionally maps budget exhaustion to
939/// `true`; the tri-state API lets proof-producing validation decline to emit
940/// when that answer is unknown.
941fn assignment_is_proven_satisfiable(m: &Arc<SemanticModel>, assignment: &HashMap<String, bool>) -> bool {
942    if assignment.is_empty() {
943        return true;
944    }
945    let assumptions: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
946    matches!(m.conditions.satisfiability(&assumptions), Satisfiability::Satisfiable)
947}
948
949/// Targeted proof search for required groups after full world enumeration is
950/// curtailed. Each emitted diagnostic carries one reachable violating world;
951/// no conclusion depends on scenarios or SAT queries omitted by a budget.
952fn validate_required_groups_budget_fallback(
953    out: &mut Vec<Diagnostic>,
954    m: &Arc<SemanticModel>,
955    rid: &str,
956    actual_keys: &[String],
957    required_or: &[String],
958    required_xor: &[String],
959    base_path: &str,
960) {
961    if !required_or.is_empty()
962        && let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_or, base_path)
963    {
964        let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
965        out.push(build_diagnostic_conditional(
966            "F3058",
967            &format!("One of [{names}] is a required property"),
968            m,
969            rid,
970            base_path,
971            None,
972            assignment_condition_map(&witness),
973        ));
974    }
975
976    if required_xor.is_empty() {
977        return;
978    }
979    let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
980    if let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_xor, base_path) {
981        out.push(build_diagnostic_conditional(
982            "F3014",
983            &format!("Exactly one of [{names}] must be specified"),
984            m,
985            rid,
986            base_path,
987            None,
988            assignment_condition_map(&witness),
989        ));
990    }
991    if let Some(witness) = multiple_members_present_witness(m, rid, actual_keys, required_xor, base_path) {
992        out.push(build_diagnostic_conditional(
993            "F3014",
994            &format!("Exactly one of [{names}] must be specified"),
995            m,
996            rid,
997            base_path,
998            None,
999            assignment_condition_map(&witness),
1000        ));
1001    }
1002}
1003
1004/// Collect the distinct condition assignments under which a `requiredOr` or
1005/// `requiredXor` group must be evaluated. Delegates to the generic
1006/// `property_scenario_assignments` helper with the group member paths.
1007fn required_group_scenario_assignments(
1008    m: &Arc<SemanticModel>,
1009    rid: &str,
1010    members: &[&str],
1011    base_path: &str,
1012) -> Option<Vec<HashMap<String, bool>>> {
1013    let paths: Vec<String> = members.iter().map(|name| format!("{}.{}", base_path, name)).collect();
1014    let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
1015    property_scenario_assignments(m, rid, &refs)
1016}
1017
1018const MAX_GROUP_PROPERTY_SCENARIOS: usize = MAX_GROUP_SCENARIO_ASSIGNMENTS + 1;
1019const MAX_GROUP_WITNESS_MERGE_ATTEMPTS: usize = MAX_GROUP_SCENARIO_MERGE_ATTEMPTS;
1020
1021/// Computes the distinct satisfiable condition assignments under which a group
1022/// of property paths must be evaluated.
1023///
1024/// The active scenario filter seeds the expansion. Each property contributes
1025/// its distinct satisfiable condition alternatives, which are conflict-checked
1026/// and combined with prior assignments. Dynamic values still contribute because
1027/// their presence is known even when their contents are not. Missing properties
1028/// add no alternatives. Returns `None` when exact enumeration exceeds the
1029/// bounded work budget; callers then omit the group finding rather than infer a
1030/// schema violation from an incomplete set of condition worlds.
1031fn property_scenario_assignments(
1032    m: &Arc<SemanticModel>,
1033    rid: &str,
1034    property_paths: &[&str],
1035) -> Option<Vec<HashMap<String, bool>>> {
1036    let Some(seed) = active_scenario_filter(m, rid) else {
1037        return Some(Vec::new());
1038    };
1039    let mut assignments: Vec<HashMap<String, bool>> = vec![seed.clone()];
1040
1041    for property_path in property_paths {
1042        let (scenarios, was_curtailed) =
1043            m.resolve_scenarios_with_limit(rid, property_path, MAX_GROUP_PROPERTY_SCENARIOS);
1044        if was_curtailed {
1045            if !m.scenario_budget_exhausted() {
1046                m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1047            }
1048            return None;
1049        }
1050        if scenarios.is_empty() {
1051            continue;
1052        }
1053
1054        let mut property_assignments = Vec::new();
1055        let mut seen_property_assignments = HashSet::new();
1056        for (_, conditions) in &scenarios {
1057            if !is_satisfiable(m, conditions) || !scenario_consistent_with_filter(m, conditions) {
1058                continue;
1059            }
1060            if seen_property_assignments.insert(canonical_assignment(conditions)) {
1061                if property_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1062                    m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1063                    return None;
1064                }
1065                property_assignments.push(conditions.clone());
1066            }
1067        }
1068
1069        if property_assignments.is_empty() {
1070            continue;
1071        }
1072
1073        let mut next_assignments = Vec::new();
1074        let mut seen_assignments = HashSet::new();
1075        let mut merge_attempts = 0;
1076        for existing in &assignments {
1077            for alternative in &property_assignments {
1078                merge_attempts += 1;
1079                if merge_attempts > MAX_GROUP_SCENARIO_MERGE_ATTEMPTS {
1080                    m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts);
1081                    return None;
1082                }
1083                let Some(merged) = try_merge_assignments(existing, alternative) else {
1084                    continue;
1085                };
1086                if !is_satisfiable(m, &merged) || !scenario_consistent_with_filter(m, &merged) {
1087                    continue;
1088                }
1089                if seen_assignments.insert(canonical_assignment(&merged)) {
1090                    if next_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1091                        m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1092                        return None;
1093                    }
1094                    next_assignments.push(merged);
1095                }
1096            }
1097        }
1098
1099        if !next_assignments.is_empty() {
1100            assignments = next_assignments;
1101        }
1102    }
1103
1104    Some(if assignments.is_empty() { vec![seed] } else { assignments })
1105}
1106
1107fn canonical_assignment(assignment: &HashMap<String, bool>) -> Vec<(String, bool)> {
1108    let mut canonical: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1109    canonical.sort_unstable();
1110    canonical
1111}
1112
1113/// Attempt to merge two condition assignments. Returns `None` if they
1114/// contradict (same condition name, different boolean value).
1115fn try_merge_assignments(a: &HashMap<String, bool>, b: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
1116    let mut merged = a.clone();
1117    for (name, val) in b {
1118        if let Some(existing) = merged.get(name) {
1119            if existing != val {
1120                return None; // Contradiction
1121            }
1122        } else {
1123            merged.insert(name.clone(), *val);
1124        }
1125    }
1126    Some(merged)
1127}
1128
1129/// Whether a property resolves to a non-null value in at least one satisfiable
1130/// scenario that is consistent with `assignment`. Dynamic values count as
1131/// present because their contents are unknown but the authored property survives;
1132/// only a concrete null represents `AWS::NoValue` absence.
1133///
1134/// When called from within `validate_sub_under_assignment`, the active
1135/// `SCENARIO_FILTER` is also respected - a scenario must be consistent with
1136/// both the group assignment and the outer branch filter.
1137///
1138/// When resolution yields no scenarios (the value is opaque), the property is
1139/// conservatively considered present.
1140fn property_present_under(
1141    m: &Arc<SemanticModel>,
1142    rid: &str,
1143    base: &str,
1144    prop: &str,
1145    assignment: &HashMap<String, bool>,
1146) -> bool {
1147    let scenarios = m.resolve_scenarios(rid, &format!("{}.{}", base, prop));
1148    if scenarios.is_empty() {
1149        return true;
1150    }
1151    scenarios.iter().any(|(val, conds)| {
1152        if !is_satisfiable(m, conds) {
1153            return false;
1154        }
1155        // Check consistency with the active SCENARIO_FILTER (outer oneOf/anyOf
1156        // assignment from validate_sub_under_assignment).
1157        if !scenario_consistent_with_filter(m, conds) {
1158            return false;
1159        }
1160        // Check no contradicting keys between scenario conditions and group assignment.
1161        for (name, value) in assignment {
1162            if let Some(scenario_value) = conds.get(name)
1163                && scenario_value != value
1164            {
1165                return false;
1166            }
1167        }
1168        // Verify the merged set is satisfiable.
1169        if !assignment.is_empty() {
1170            let mut merged = conds.clone();
1171            for (name, value) in assignment {
1172                merged.insert(name.clone(), *value);
1173            }
1174            if !is_satisfiable(m, &merged) {
1175                return false;
1176            }
1177        }
1178        !matches!(val, ResolvedValue::Concrete { value } if value.is_null())
1179    })
1180}
1181
1182/// Validate object keys, tagging any emitted diagnostics with the given
1183/// condition scenario. When `scenario` is `None`, diagnostics are unconditioned.
1184/// When `Some`, each emitted diagnostic records the condition assumptions that
1185/// make the scenario reachable.
1186#[allow(clippy::too_many_arguments)]
1187fn validate_object_keys_inner(
1188    out: &mut Vec<Diagnostic>,
1189    m: &Arc<SemanticModel>,
1190    rid: &str,
1191    rtype: &str,
1192    schema_props: &HashMap<String, PropSchema>,
1193    defs: &HashMap<String, PropSchema>,
1194    required: &[String],
1195    additional_properties: Option<bool>,
1196    pattern_props: &HashMap<String, PropSchema>,
1197    dep_req: &HashMap<String, Vec<String>>,
1198    dep_excl: &HashMap<String, Vec<String>>,
1199    req_or: &[String],
1200    req_xor: &[String],
1201    all_of: &[SubSchema],
1202    any_of: &[SubSchema],
1203    one_of: &[SubSchema],
1204    actual_keys: &[String],
1205    base_path: &str,
1206    scenario: Option<&HashMap<String, bool>>,
1207) {
1208    // When an outer key scenario is provided, constrain nested composition to
1209    // the same condition world. The scope restores any prior nested assignment
1210    // during normal return and panic unwinding.
1211    let _scenario_filter_scope = match scenario {
1212        Some(outer_conditions) => {
1213            let Some(merged) = merged_scenario_filter(outer_conditions) else {
1214                return;
1215            };
1216            Some(ScenarioFilterScope::enter(merged))
1217        }
1218        None => None,
1219    };
1220
1221    let before_len = out.len();
1222    for req in required {
1223        if !actual_keys.contains(req) {
1224            out.push(build_diagnostic(
1225                "F3003",
1226                &format!("'{}' is a required property", req),
1227                m,
1228                rid,
1229                base_path,
1230                Some(&format!("Add the required property '{}'", req)),
1231            ));
1232        } else if base_path == KEY_PROPERTIES {
1233            check_required_not_null(out, m, rid, base_path, req);
1234        }
1235    }
1236
1237    if additional_properties == Some(false) && !is_custom_resource_type(rtype) {
1238        let known: HashSet<&str> = schema_props.keys().map(|s| s.as_str()).collect();
1239
1240        let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1241            pattern_props.keys().map(|p| compile_pattern(p)).collect();
1242        for key in actual_keys {
1243            if known.contains(key.as_str()) {
1244                continue;
1245            }
1246            let allowed_by_pattern =
1247                pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1248            if allowed_by_pattern {
1249                continue;
1250            }
1251            let suggestion = find_similar(key, &known);
1252            let msg = match suggestion {
1253                Some(s) => {
1254                    format!("Additional properties are not allowed ('{}' was unexpected. Did you mean '{}'?)", key, s)
1255                }
1256                None => format!("Additional properties are not allowed ('{}' was unexpected)", key),
1257            };
1258            out.push(build_diagnostic("F3002", &msg, m, rid, &format!("{}.{}", base_path, key), None));
1259        }
1260    }
1261
1262    for (trigger, excluded) in dep_excl {
1263        if actual_keys.contains(trigger) {
1264            for dep in excluded {
1265                if actual_keys.contains(dep) {
1266                    out.push(build_diagnostic(
1267                        "F3020",
1268                        &format!("'{}' should not be included with '{}'", dep, trigger),
1269                        m,
1270                        rid,
1271                        &format!("{}.{}", base_path, dep),
1272                        None,
1273                    ));
1274                }
1275            }
1276        }
1277    }
1278
1279    for (trigger, deps) in dep_req {
1280        if actual_keys.contains(trigger) {
1281            for dep in deps {
1282                if !actual_keys.contains(dep) {
1283                    out.push(build_diagnostic(
1284                        "F3021",
1285                        &format!("'{}' is a dependency of '{}'", dep, trigger),
1286                        m,
1287                        rid,
1288                        base_path,
1289                        Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
1290                    ));
1291                }
1292            }
1293        }
1294    }
1295
1296    validate_required_groups(out, m, rid, actual_keys, req_or, req_xor, base_path);
1297
1298    for sub in all_of {
1299        validate_sub(out, m, rid, rtype, actual_keys, sub, defs, base_path, 0);
1300    }
1301
1302    // anyOf/oneOf group decisions are made per template condition scenario. A
1303    // property valued through `Fn::If` has a different concrete value in each
1304    // reachable scenario, and each scenario may legitimately satisfy a
1305    // different branch - deciding the group from values across mutually
1306    // exclusive scenarios both invents findings (each scenario matches exactly
1307    // one branch, yet globally two branches look satisfied) and misses them (an
1308    // invalid scenario is masked by a valid sibling scenario).
1309    let group_assignments = if any_of.is_empty() && one_of.is_empty() {
1310        Some(Vec::new())
1311    } else {
1312        branch_scenario_assignments(m, rid, any_of.iter().chain(one_of.iter()), defs, base_path)
1313    }
1314    .unwrap_or_default();
1315
1316    if !any_of.is_empty() {
1317        for assignment in &group_assignments {
1318            let evaluations =
1319                evaluate_object_composition_branches(m, rid, rtype, actual_keys, any_of, defs, base_path, assignment);
1320            if evaluations.iter().all(|evaluation| !evaluation.matched) {
1321                out.push(build_composition_diagnostic(
1322                    "F3017",
1323                    CompositionKind::AnyOf,
1324                    &evaluations,
1325                    m,
1326                    rid,
1327                    base_path,
1328                    Some(rtype),
1329                    None,
1330                    assignment_condition_map(assignment),
1331                ));
1332            }
1333        }
1334    }
1335
1336    if !one_of.is_empty() {
1337        for assignment in &group_assignments {
1338            let evaluations =
1339                evaluate_object_composition_branches(m, rid, rtype, actual_keys, one_of, defs, base_path, assignment);
1340            let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
1341            if match_count != 1 {
1342                out.push(build_composition_diagnostic(
1343                    "F3018",
1344                    CompositionKind::OneOf,
1345                    &evaluations,
1346                    m,
1347                    rid,
1348                    base_path,
1349                    Some(rtype),
1350                    None,
1351                    assignment_condition_map(assignment),
1352                ));
1353            }
1354        }
1355    }
1356
1357    // Tag every diagnostic this invocation produced with the caller's
1358    // condition scenario (if any). Callers passing `None` leave diagnostics
1359    // unconditioned, matching pre-existing behavior.
1360    if let Some(conds) = scenario {
1361        for diag in out.iter_mut().skip(before_len) {
1362            if diag.condition_scenario.is_none() {
1363                diag.condition_scenario = Some(conds.clone());
1364            }
1365        }
1366    }
1367}
1368
1369/// Enumerate object key-sets visible at `prop_path`, one per condition
1370/// scenario. Returns at least one entry. When the value is not
1371/// condition-branching, returns a single entry with an empty scenario map.
1372/// When branches expose different keys, each branch contributes a distinct
1373/// entry annotated with the condition assumptions that select it.
1374fn object_key_scenarios(
1375    m: &Arc<SemanticModel>,
1376    rid: &str,
1377    prop_path: &str,
1378) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1379    let mut out: Vec<(Vec<String>, HashMap<String, bool>)> = Vec::new();
1380    for (val, conds) in m.resolve_scenarios_json(rid, prop_path) {
1381        if !is_satisfiable(m, &conds) {
1382            continue;
1383        }
1384        let Some(obj) = val.as_object() else {
1385            continue;
1386        };
1387        let mut keys: Vec<String> = obj.keys().cloned().collect();
1388        keys.sort();
1389        out.push((keys, conds));
1390    }
1391    // Deduplicate scenarios that produce the same key set - when both
1392    // branches of a condition have identical keys, the condition does not
1393    // affect key validation, so a single unconditioned entry suffices.
1394    let mut seen_keysets: HashMap<Vec<String>, HashMap<String, bool>> = HashMap::new();
1395    for (keys, conds) in out.drain(..) {
1396        seen_keysets
1397            .entry(keys)
1398            .and_modify(|existing| {
1399                // When two scenarios reach the same keys under complementary
1400                // assumptions, the result is unconditioned - drop shared vars
1401                // where the two assumption maps disagree.
1402                existing.retain(|k, v| conds.get(k) == Some(v));
1403            })
1404            .or_insert(conds);
1405    }
1406    seen_keysets.into_iter().collect()
1407}
1408
1409/// Top-level properties of a resource may themselves be wrapped in an
1410/// `Fn::If` - e.g. `Properties: {Fn::If: [Cond, {a: 1}, {b: 2}]}`. Return one
1411/// entry per reachable branch: each entry lists the concrete top-level keys
1412/// a branch exposes plus the condition assumptions that reach it. When
1413/// properties are not wrapped in `Fn::If`, returns a single unconditioned
1414/// entry built directly from `res.properties.keys()`.
1415fn resource_property_key_scenarios(
1416    m: &Arc<SemanticModel>,
1417    rid: &str,
1418    res: &ResolvedResource,
1419) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1420    let keys: Vec<&str> = res.properties.keys().map(String::as_str).collect();
1421    if keys.len() != 1 || keys[0] != FN_IF {
1422        let mut ks: Vec<String> = res.properties.keys().cloned().collect();
1423        ks.sort();
1424        return vec![(ks, HashMap::new())];
1425    }
1426    // Walk branches via the scenario resolver using the synthetic path
1427    // under which the parser stored the conditional.
1428    let path = format!("{}.{}", KEY_PROPERTIES, FN_IF);
1429    let scenarios = object_key_scenarios(m, rid, &path);
1430    if scenarios.is_empty() {
1431        return vec![(Vec::new(), HashMap::new())];
1432    }
1433    scenarios
1434}
1435
1436fn validate_sub(
1437    out: &mut Vec<Diagnostic>,
1438    m: &Arc<SemanticModel>,
1439    rid: &str,
1440    rtype: &str,
1441    actual_keys: &[String],
1442    sub: &SubSchema,
1443    defs: &HashMap<String, PropSchema>,
1444    base_path: &str,
1445    depth: usize,
1446) {
1447    // A conditional branch may itself be (or reference) a schema with further
1448    // conditionals, so recursion is bounded the same way value matching is: a
1449    // crafted definition graph must never exhaust the stack.
1450    if depth > MAX_MATCH_DEPTH {
1451        m.record_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1452        return;
1453    }
1454    // Resolve $ref in the branch - a branch that references a definition uses
1455    // the definition's constraints. A dangling ref (definition not found) makes
1456    // the branch fail validation (never vacuously match).
1457    let resolved;
1458    let effective_sub = if sub.ref_name.is_some() {
1459        resolved = sub.resolve(defs);
1460        if sub.ref_name.is_some() && resolved.ref_name.is_some() {
1461            // Dangling ref: definition not found → branch fails
1462            out.push(build_diagnostic(
1463                "F3003",
1464                &format!(
1465                    "Composition branch references undefined definition '{}'",
1466                    sub.ref_name.as_deref().unwrap_or("")
1467                ),
1468                m,
1469                rid,
1470                base_path,
1471                None,
1472            ));
1473            return;
1474        }
1475        &*resolved
1476    } else {
1477        sub
1478    };
1479
1480    for req in &effective_sub.required {
1481        if !actual_keys.contains(req) {
1482            // A requirement a dedicated resource-specific rule already reports
1483            // under its own ID (e.g. the S3 access-control rule for
1484            // OwnershipControls) must not additionally raise the generic Fatal -
1485            // the same exclusion the extension-fragment path applies.
1486            if extension_required_covered_by_dedicated_rule(rtype, req) {
1487                continue;
1488            }
1489            out.push(build_diagnostic(
1490                "F3003",
1491                &format!("'{}' is a required property", req),
1492                m,
1493                rid,
1494                base_path,
1495                Some(&format!("Add '{}'", req)),
1496            ));
1497        } else {
1498            check_required_not_null(out, m, rid, base_path, req);
1499        }
1500    }
1501
1502    // additionalProperties on the branch
1503    if effective_sub.additional_properties == Some(false) && !effective_sub.properties.is_empty() {
1504        let known: std::collections::HashSet<&str> = effective_sub.properties.keys().map(|s| s.as_str()).collect();
1505        let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1506            effective_sub.pattern_properties.keys().map(|p| compile_pattern(p)).collect();
1507        for key in actual_keys {
1508            if known.contains(key.as_str()) {
1509                continue;
1510            }
1511            let allowed_by_pattern =
1512                pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1513            if allowed_by_pattern {
1514                continue;
1515            }
1516            out.push(build_diagnostic(
1517                "F3002",
1518                &format!("Additional properties are not allowed ('{}' was unexpected)", key),
1519                m,
1520                rid,
1521                &format!("{}.{}", base_path, key),
1522                None,
1523            ));
1524        }
1525    }
1526
1527    validate_sub_dependencies(out, m, rid, actual_keys, effective_sub, base_path);
1528
1529    validate_required_groups(
1530        out,
1531        m,
1532        rid,
1533        actual_keys,
1534        &effective_sub.required_or,
1535        &effective_sub.required_xor,
1536        base_path,
1537    );
1538
1539    // Value-level matching: check each property constraint in the branch against
1540    // the concrete resolved values at base_path. This allows anyOf/oneOf branches
1541    // to discriminate by type, enum, const, numeric bounds, pattern, etc.
1542    validate_sub_value_constraints(out, m, rid, effective_sub, defs, base_path);
1543
1544    // Draft-07 conditional constraints inside the branch: the instance must
1545    // satisfy `then` when the condition holds and `else` otherwise. Evaluated
1546    // recursively so a branch that consists of `if`/`then`/`else` participates
1547    // in anyOf/oneOf matching per draft-07 instead of matching vacuously.
1548    // Bundled conditionals stay dependencies-only here too (see
1549    // `IfThenElse::enforce_full_branch`).
1550    for ite in &effective_sub.if_then_else {
1551        let matches = condition_matches_at(&ite.condition, actual_keys, m, rid, defs, base_path);
1552        let branch = if matches { &ite.then_schema } else { &ite.else_schema };
1553        if let Some(branch_schema) = branch {
1554            if ite.enforce_full_branch {
1555                validate_sub(out, m, rid, rtype, actual_keys, branch_schema, defs, base_path, depth + 1);
1556            } else {
1557                validate_sub_dependencies(out, m, rid, actual_keys, branch_schema, base_path);
1558            }
1559        }
1560    }
1561}
1562
1563/// Maximum depth for recursive `schema_value_matches` calls through nested
1564/// composition (allOf/anyOf/oneOf) and items. Prevents unbounded recursion
1565/// from cyclic or deeply nested schemas.
1566/// Returns `true` when `value` satisfies all representable constraints in
1567/// `schema`. Designed for branch matching: when no satisfiable scenario
1568/// produces a matching value, the branch is non-matching.
1569///
1570/// Conservative: returns `true` (match) when a constraint cannot be evaluated
1571/// (e.g. a pattern that won't compile, an unknown format, a dynamic/opaque
1572/// value). The caller must never invent a mismatch from uncertainty.
1573fn schema_value_matches(
1574    value: &serde_json::Value,
1575    schema: &PropSchema,
1576    defs: &HashMap<String, PropSchema>,
1577    depth: usize,
1578) -> bool {
1579    schema_value_failure_reasons(value, schema, defs, depth, "").is_empty()
1580}
1581
1582fn evaluate_value_composition_branches(
1583    value: &serde_json::Value,
1584    branches: &[SubSchema],
1585    defs: &HashMap<String, PropSchema>,
1586    property_path: &str,
1587) -> Vec<CompositionBranchEvaluation> {
1588    branches
1589        .iter()
1590        .enumerate()
1591        .map(|(index, branch)| {
1592            let failure_reasons = schema_value_failure_reasons(value, branch, defs, 0, property_path);
1593            let matched = failure_reasons.is_empty();
1594            CompositionBranchEvaluation::new(
1595                index + 1,
1596                matched,
1597                required_property_combinations(branch, defs, None),
1598                failure_reasons,
1599            )
1600        })
1601        .collect()
1602}
1603
1604fn schema_value_failure_reasons(
1605    value: &serde_json::Value,
1606    schema: &PropSchema,
1607    defs: &HashMap<String, PropSchema>,
1608    depth: usize,
1609    property_path: &str,
1610) -> Vec<CompositionFailureReason> {
1611    if depth > MAX_MATCH_DEPTH || value.is_null() {
1612        if depth > MAX_MATCH_DEPTH {
1613            record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1614        }
1615        return Vec::new();
1616    }
1617
1618    let resolved;
1619    let effective = if schema.ref_name.is_some() {
1620        resolved = schema.resolve(defs);
1621        if resolved.ref_name.is_some() {
1622            return vec![CompositionFailureReason::new(
1623                format!(
1624                    "Composition branch references undefined definition '{}'",
1625                    schema.ref_name.as_deref().unwrap_or("")
1626                ),
1627                property_path,
1628            )];
1629        }
1630        &*resolved
1631    } else {
1632        schema
1633    };
1634
1635    let mut reasons = Vec::new();
1636    if let Some(ref expected_type) = effective.prop_type
1637        && !type_matches(value, expected_type)
1638    {
1639        let coercible = expected_type
1640            .primary()
1641            .is_some_and(|expected| matches!(coerce_value(value, expected), CoerceResult::Coerced(_, _)));
1642        if !coercible {
1643            reasons.push(CompositionFailureReason::new(
1644                format!(
1645                    "{} has type '{}', expected type '{}'",
1646                    format_value(value),
1647                    json_value_type(value),
1648                    expected_type.names().collect::<Vec<_>>().join("|")
1649                ),
1650                property_path,
1651            ));
1652            return reasons;
1653        }
1654    }
1655
1656    if !effective.enum_values.is_empty() && !enum_matches(value, &effective.enum_values) {
1657        reasons.push(CompositionFailureReason::new(
1658            format!("{} is not one of {}", format_value(value), format_allowed_values(&effective.enum_values)),
1659            property_path,
1660        ));
1661    }
1662    if !effective.enum_case_insensitive.is_empty()
1663        && !enum_matches_case_insensitive(value, &effective.enum_case_insensitive)
1664    {
1665        reasons.push(CompositionFailureReason::new(
1666            format!(
1667                "{} is not one of {} (case-insensitive)",
1668                format_value(value),
1669                format_allowed_values(&effective.enum_case_insensitive)
1670            ),
1671            property_path,
1672        ));
1673    }
1674    if !effective.not_enum.is_empty() && enum_matches(value, &effective.not_enum) {
1675        reasons.push(CompositionFailureReason::new(
1676            format!("{} must not be one of {}", format_value(value), format_allowed_values(&effective.not_enum)),
1677            property_path,
1678        ));
1679    }
1680    if let Some(ref expected) = effective.const_value
1681        && !scalar_eq(value, expected)
1682    {
1683        reasons.push(CompositionFailureReason::new(
1684            format!("{} must equal {}", format_value(value), format_value(expected)),
1685            property_path,
1686        ));
1687    }
1688    if let Some(ref pattern) = effective.pattern
1689        && let Some(compiled) = compile_pattern(pattern)
1690        && let Some(actual) = coerce_to_string(value)
1691        && !compiled.is_match(&actual)
1692    {
1693        reasons.push(CompositionFailureReason::new(
1694            format!("{} does not match pattern '{pattern}'", format_value(value)),
1695            property_path,
1696        ));
1697    }
1698    if let Some(ref format) = effective.format
1699        && let Some(actual) = coerce_to_string(value)
1700        && !format_value_matches(&actual, format)
1701    {
1702        reasons.push(CompositionFailureReason::new(
1703            format!("{} does not match format '{format}'", format_value(value)),
1704            property_path,
1705        ));
1706    }
1707
1708    if let Some(number) = coerce_to_number(value) {
1709        if let Some(maximum) = effective.maximum
1710            && number > maximum
1711        {
1712            reasons.push(CompositionFailureReason::new(format!("{number} exceeds maximum {maximum}"), property_path));
1713        }
1714        if let Some(minimum) = effective.minimum
1715            && number < minimum
1716        {
1717            reasons.push(CompositionFailureReason::new(format!("{number} is below minimum {minimum}"), property_path));
1718        }
1719        if let Some(maximum) = effective.exclusive_maximum
1720            && number >= maximum
1721        {
1722            reasons.push(CompositionFailureReason::new(format!("{number} must be less than {maximum}"), property_path));
1723        }
1724        if let Some(minimum) = effective.exclusive_minimum
1725            && number <= minimum
1726        {
1727            reasons
1728                .push(CompositionFailureReason::new(format!("{number} must be greater than {minimum}"), property_path));
1729        }
1730        if let Some(multiple) = effective.multiple_of
1731            && multiple > 0.0
1732        {
1733            let remainder = (number / multiple).round() * multiple - number;
1734            let epsilon = multiple * 1e-9;
1735            if remainder.abs() > epsilon && (multiple - remainder.abs()).abs() > epsilon {
1736                reasons.push(CompositionFailureReason::new(
1737                    format!("{number} is not a multiple of {multiple}"),
1738                    property_path,
1739                ));
1740            }
1741        }
1742    }
1743
1744    if (effective.min_length.is_some() || effective.max_length.is_some())
1745        && let Some(actual) = coerce_to_string(value)
1746    {
1747        let length = actual.chars().count() as u64;
1748        if let Some(maximum) = effective.max_length
1749            && length > maximum
1750        {
1751            reasons.push(CompositionFailureReason::new(
1752                format!("String length {length} exceeds maximum {maximum}"),
1753                property_path,
1754            ));
1755        }
1756        if let Some(minimum) = effective.min_length
1757            && length < minimum
1758        {
1759            reasons.push(CompositionFailureReason::new(
1760                format!("String length {length} is below minimum {minimum}"),
1761                property_path,
1762            ));
1763        }
1764    }
1765
1766    if let Some(items) = value.as_array() {
1767        let length = items.iter().filter(|item| !item.is_null()).count() as u64;
1768        if let Some(maximum) = effective.max_items
1769            && length > maximum
1770        {
1771            reasons.push(CompositionFailureReason::new(
1772                format!("Array length {length} exceeds maximum {maximum}"),
1773                property_path,
1774            ));
1775        }
1776        if let Some(minimum) = effective.min_items
1777            && length < minimum
1778        {
1779            reasons.push(CompositionFailureReason::new(
1780                format!("Array length {length} is below minimum {minimum}"),
1781                property_path,
1782            ));
1783        }
1784        if effective.unique_items == Some(true) {
1785            let mut duplicate_found = false;
1786            for (index, item) in items.iter().enumerate() {
1787                if item.is_null() {
1788                    continue;
1789                }
1790                if items[..index].iter().any(|previous| !previous.is_null() && previous == item) {
1791                    duplicate_found = true;
1792                    break;
1793                }
1794            }
1795            if duplicate_found {
1796                reasons.push(CompositionFailureReason::new("Array items must be unique", property_path));
1797            }
1798        }
1799        if let Some(ref item_schema) = effective.items {
1800            for (index, item) in items.iter().enumerate() {
1801                if !schema_value_matches(item, item_schema, defs, depth + 1) {
1802                    let item_path = append_property_path(property_path, &index.to_string());
1803                    reasons.extend(schema_value_failure_reasons(item, item_schema, defs, depth + 1, &item_path));
1804                }
1805            }
1806        }
1807    }
1808
1809    if let Some(object) = value.as_object() {
1810        let property_present = |property: &str| object.get(property).is_some_and(|candidate| !candidate.is_null());
1811        let property_count = object.values().filter(|candidate| !candidate.is_null()).count() as u64;
1812        if let Some(maximum) = effective.max_properties
1813            && property_count > maximum
1814        {
1815            reasons.push(CompositionFailureReason::new(
1816                format!("Object has {property_count} properties, maximum is {maximum}"),
1817                property_path,
1818            ));
1819        }
1820        if let Some(minimum) = effective.min_properties
1821            && property_count < minimum
1822        {
1823            reasons.push(CompositionFailureReason::new(
1824                format!("Object has {property_count} properties, minimum is {minimum}"),
1825                property_path,
1826            ));
1827        }
1828        for required in &effective.required {
1829            if !property_present(required) {
1830                reasons
1831                    .push(CompositionFailureReason::new(format!("'{required}' is a required property"), property_path));
1832            }
1833        }
1834        for (trigger, dependencies) in &effective.dependent_required {
1835            if property_present(trigger) {
1836                for dependency in dependencies {
1837                    if !property_present(dependency) {
1838                        reasons.push(CompositionFailureReason::new(
1839                            format!("'{dependency}' is a dependency of '{trigger}'"),
1840                            property_path,
1841                        ));
1842                    }
1843                }
1844            }
1845        }
1846        for (trigger, excluded) in &effective.dependent_excluded {
1847            if property_present(trigger) {
1848                for property in excluded {
1849                    if property_present(property) {
1850                        reasons.push(CompositionFailureReason::new(
1851                            format!("'{property}' should not be included with '{trigger}'"),
1852                            append_property_path(property_path, property),
1853                        ));
1854                    }
1855                }
1856            }
1857        }
1858        if !effective.required_or.is_empty() && !effective.required_or.iter().any(|property| property_present(property))
1859        {
1860            reasons.push(CompositionFailureReason::new(
1861                format!(
1862                    "At least one of {} is required",
1863                    effective.required_or.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
1864                ),
1865                property_path,
1866            ));
1867        }
1868        if !effective.required_xor.is_empty() {
1869            let present = effective.required_xor.iter().filter(|property| property_present(property)).count();
1870            if present != 1 {
1871                reasons.push(CompositionFailureReason::new(
1872                    format!(
1873                        "Exactly one of {} is required, but {present} are present",
1874                        effective
1875                            .required_xor
1876                            .iter()
1877                            .map(|property| format!("'{property}'"))
1878                            .collect::<Vec<_>>()
1879                            .join(", ")
1880                    ),
1881                    property_path,
1882                ));
1883            }
1884        }
1885        if effective.additional_properties == Some(false)
1886            && (!effective.properties.is_empty() || !effective.pattern_properties.is_empty())
1887        {
1888            let pattern_matchers: Vec<Option<Arc<CompiledPattern>>> =
1889                effective.pattern_properties.keys().map(|pattern| compile_pattern(pattern)).collect();
1890            for (property, property_value) in object {
1891                if property_value.is_null() || effective.properties.contains_key(property) {
1892                    continue;
1893                }
1894                let allowed = pattern_matchers
1895                    .iter()
1896                    .any(|matcher| matcher.as_ref().is_none_or(|compiled| compiled.is_match(property)));
1897                if !allowed {
1898                    reasons.push(CompositionFailureReason::new(
1899                        format!("Additional property '{property}' is not allowed"),
1900                        append_property_path(property_path, property),
1901                    ));
1902                }
1903            }
1904        }
1905        for (property, property_schema) in &effective.properties {
1906            if let Some(property_value) = object.get(property)
1907                && !schema_value_matches(property_value, property_schema, defs, depth + 1)
1908            {
1909                let child_path = append_property_path(property_path, property);
1910                reasons.extend(schema_value_failure_reasons(
1911                    property_value,
1912                    property_schema,
1913                    defs,
1914                    depth + 1,
1915                    &child_path,
1916                ));
1917            }
1918        }
1919    }
1920
1921    for branch in &effective.all_of {
1922        if !schema_value_matches(value, branch, defs, depth + 1) {
1923            reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1924        }
1925    }
1926    if !effective.any_of.is_empty()
1927        && !effective.any_of.iter().any(|branch| schema_value_matches(value, branch, defs, depth + 1))
1928    {
1929        for branch in &effective.any_of {
1930            reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1931        }
1932    }
1933    if !effective.one_of.is_empty() {
1934        let matching: Vec<usize> = effective
1935            .one_of
1936            .iter()
1937            .enumerate()
1938            .filter_map(|(index, branch)| schema_value_matches(value, branch, defs, depth + 1).then_some(index + 1))
1939            .collect();
1940        if matching.is_empty() {
1941            for branch in &effective.one_of {
1942                reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1943            }
1944        } else if matching.len() > 1 {
1945            reasons.push(CompositionFailureReason::new(
1946                format!(
1947                    "Value matches nested oneOf branches {}; exactly one must match",
1948                    render_branch_numbers(&matching)
1949                ),
1950                property_path,
1951            ));
1952        }
1953    }
1954    for conditional in effective.if_then_else.iter().filter(|conditional| conditional.enforce_full_branch) {
1955        if let Some(object) = value.as_object() {
1956            let object_keys: Vec<String> = object
1957                .iter()
1958                .filter(|(_, candidate)| !candidate.is_null())
1959                .map(|(property, _)| property.clone())
1960                .collect();
1961            let condition_matches =
1962                condition_schema_value_matches(&conditional.condition, object, &object_keys, defs, depth + 1);
1963            let branch = if condition_matches { &conditional.then_schema } else { &conditional.else_schema };
1964            if let Some(branch_schema) = branch
1965                && !schema_value_matches(value, branch_schema, defs, depth + 1)
1966            {
1967                reasons.extend(schema_value_failure_reasons(value, branch_schema, defs, depth + 1, property_path));
1968            }
1969        }
1970    }
1971
1972    deduplicate_composition_reasons(reasons)
1973}
1974
1975fn append_property_path(base: &str, segment: &str) -> String {
1976    if base.is_empty() { segment.to_string() } else { format!("{base}.{segment}") }
1977}
1978
1979fn json_value_type(value: &serde_json::Value) -> &'static str {
1980    match value {
1981        serde_json::Value::Null => "null",
1982        serde_json::Value::Bool(_) => "boolean",
1983        serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
1984        serde_json::Value::Number(_) => "number",
1985        serde_json::Value::String(_) => "string",
1986        serde_json::Value::Array(_) => "array",
1987        serde_json::Value::Object(_) => "object",
1988    }
1989}
1990
1991/// Evaluate a `ConditionSchema` against a concrete object value for use in
1992/// `schema_value_matches`. Mirrors the logic of `condition_matches` but
1993/// operates on concrete JSON values rather than the semantic model.
1994fn condition_schema_value_matches(
1995    cond: &ConditionSchema,
1996    obj: &serde_json::Map<String, serde_json::Value>,
1997    obj_keys: &[String],
1998    defs: &HashMap<String, PropSchema>,
1999    depth: usize,
2000) -> bool {
2001    if depth > MAX_MATCH_DEPTH {
2002        record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
2003        return true; // conservative
2004    }
2005
2006    // anyOf conditions
2007    if !cond.any_of.is_empty() {
2008        return cond.any_of.iter().any(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1));
2009    }
2010
2011    // The instance evaluated here is always a JSON object, so a condition
2012    // stating any other `type` cannot match.
2013    if let Some(ref required_type) = cond.prop_type
2014        && !required_type.names().any(|name| name == "object")
2015    {
2016        return false;
2017    }
2018
2019    // required keys
2020    for req in &cond.required {
2021        if !obj_keys.iter().any(|k| k == req) {
2022            return false;
2023        }
2024    }
2025
2026    // property constraints
2027    for (prop_name, prop_schema) in &cond.properties {
2028        let resolved = prop_schema.resolve(defs);
2029        if let Some(prop_value) = obj.get(prop_name) {
2030            if !schema_value_matches(prop_value, &resolved, defs, depth + 1) {
2031                return false;
2032            }
2033        } else {
2034            // Property not present but the condition constrains it - only fail
2035            // if the constraint is a concrete value check (enum/const/pattern).
2036            let has_value_constraint = !resolved.enum_values.is_empty()
2037                || !resolved.enum_case_insensitive.is_empty()
2038                || resolved.const_value.is_some()
2039                || resolved.pattern.is_some();
2040            if has_value_constraint {
2041                return false;
2042            }
2043        }
2044    }
2045
2046    true
2047}
2048
2049/// Check value-level constraints in a composition branch against the concrete
2050/// resolved values at `base_path`. This produces a synthetic diagnostic into
2051/// `out` when no satisfiable scenario matches the branch's property value
2052/// constraints - making the branch non-matching for anyOf/oneOf evaluation.
2053///
2054/// Called after structural checks (required/additional/dependencies) so that
2055/// structural failures are not duplicated: if structural checks already failed,
2056/// this function still runs to add value mismatches but only emits them if the
2057/// branch has property-level constraints that go beyond structure.
2058fn validate_sub_value_constraints(
2059    out: &mut Vec<Diagnostic>,
2060    m: &Arc<SemanticModel>,
2061    rid: &str,
2062    sub: &SubSchema,
2063    defs: &HashMap<String, PropSchema>,
2064    base_path: &str,
2065) {
2066    // Only check if the branch has property constraints with value-level fields.
2067    // Branches that only define required/additionalProperties at the branch
2068    // level are already fully covered by the structural checks above. The
2069    // per-field predicate is exhaustive (`PropSchema::constrains_value`), so a
2070    // constraint field can never be silently skipped here.
2071    let has_value_constraints =
2072        sub.properties.values().any(|ps| ps.resolve(defs).constrains_value()) || sub_self_constrains_value(sub);
2073    if !has_value_constraints {
2074        return;
2075    }
2076
2077    // Check per-property value constraints in the branch
2078    for (prop_name, prop_schema) in &sub.properties {
2079        let resolved = prop_schema.resolve(defs);
2080        let prop_path = format!("{}.{}", base_path, prop_name);
2081        // Only scenarios consistent with the active group assignment (if any)
2082        // participate: a value from a mutually exclusive `Fn::If` branch must
2083        // not decide this assignment's branch match.
2084        let shared_scenarios = m.resolve_scenarios_json_shared(rid, &prop_path);
2085        let scenarios: Vec<_> =
2086            shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2087
2088        // When the property is absent from the template and not required, it
2089        // does not contribute a mismatch - the constraint is vacuously true.
2090        if scenarios.is_empty() {
2091            continue;
2092        }
2093
2094        // Check if ANY satisfiable scenario matches the branch constraint
2095        let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2096            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, &prop_path, val, conds) {
2097                return true; // conservative: unresolved scenarios do not cause a mismatch
2098            }
2099            schema_value_matches(val, &resolved, defs, 0)
2100        });
2101
2102        if !any_scenario_matches {
2103            // This diagnostic remains internal to an anyOf/oneOf decision and is
2104            // surfaced directly only for allOf. Preserve the concrete constraint
2105            // failure so the primary composition finding can explain this branch.
2106            let (offending, failure_detail) = scenarios
2107                .iter()
2108                .find(|(val, conds)| {
2109                    is_satisfiable(m, conds)
2110                        && !val.is_null()
2111                        && !defer_value_constraints(m, rid, &prop_path, val, conds)
2112                })
2113                .map(|(val, _)| {
2114                    let reasons = schema_value_failure_reasons(val, &resolved, defs, 0, &prop_path);
2115                    (format_value(val), render_composition_reasons(&reasons, &prop_path))
2116                })
2117                .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&resolved)));
2118            out.push(build_diagnostic(
2119                "F3017",
2120                &format!(
2121                    "{offending} at '{prop_name}' does not satisfy the composition branch constraint ({}): {failure_detail}",
2122                    describe_prop_constraints(&resolved)
2123                ),
2124                m,
2125                rid,
2126                &prop_path,
2127                None,
2128            ));
2129        }
2130    }
2131
2132    // Also check constraints on the branch value itself rather than on a named
2133    // property. Structural fields are projected out because scenario-aware key
2134    // validation above already enforces them.
2135    if sub_self_constrains_value(sub) {
2136        let self_schema = schema_for_self_value_constraints(sub);
2137        let shared_scenarios = m.resolve_scenarios_json_shared(rid, base_path);
2138        let scenarios: Vec<_> =
2139            shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2140        if !scenarios.is_empty() {
2141            let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2142                if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, base_path, val, conds)
2143                {
2144                    return true;
2145                }
2146                schema_value_matches(val, &self_schema, defs, 0)
2147            });
2148            if !any_scenario_matches {
2149                let (offending, failure_detail) = scenarios
2150                    .iter()
2151                    .find(|(val, conds)| {
2152                        is_satisfiable(m, conds)
2153                            && !val.is_null()
2154                            && !defer_value_constraints(m, rid, base_path, val, conds)
2155                    })
2156                    .map(|(val, _)| {
2157                        let reasons = schema_value_failure_reasons(val, &self_schema, defs, 0, base_path);
2158                        (format_value(val), render_composition_reasons(&reasons, base_path))
2159                    })
2160                    .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&self_schema)));
2161                out.push(build_diagnostic(
2162                    "F3017",
2163                    &format!(
2164                        "{offending} does not satisfy the composition branch constraint ({}): {failure_detail}",
2165                        describe_prop_constraints(&self_schema)
2166                    ),
2167                    m,
2168                    rid,
2169                    base_path,
2170                    None,
2171                ));
2172            }
2173        }
2174    }
2175}
2176
2177/// The distinct template-condition assignments under which an `anyOf`/`oneOf`
2178/// group must be decided. Delegates to the generic `property_scenario_assignments`
2179/// helper with the union of property paths referenced by all branches.
2180fn branch_scenario_assignments<'a>(
2181    m: &Arc<SemanticModel>,
2182    rid: &str,
2183    branches: impl Iterator<Item = &'a SubSchema>,
2184    defs: &HashMap<String, PropSchema>,
2185    base_path: &str,
2186) -> Option<Vec<HashMap<String, bool>>> {
2187    let mut property_names = BTreeSet::new();
2188    for branch in branches {
2189        let effective_branch = branch.resolve(defs);
2190        property_names.extend(effective_branch.properties.keys().cloned());
2191        property_names.extend(effective_branch.required.iter().cloned());
2192        property_names.extend(effective_branch.required_or.iter().cloned());
2193        property_names.extend(effective_branch.required_xor.iter().cloned());
2194    }
2195
2196    let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
2197    let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
2198    property_scenario_assignments(m, rid, &refs)
2199}
2200
2201/// The scenario tag for a group diagnostic: `None` for the unconditional
2202/// assignment (matching the untagged diagnostics emitted before scenario-aware
2203/// grouping), the assignment itself otherwise.
2204fn assignment_condition_map(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2205    if assignment.is_empty() { None } else { Some(assignment.clone()) }
2206}
2207
2208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2209enum CompositionKind {
2210    AnyOf,
2211    OneOf,
2212}
2213
2214impl CompositionKind {
2215    fn name(self) -> &'static str {
2216        match self {
2217            CompositionKind::AnyOf => "anyOf",
2218            CompositionKind::OneOf => "oneOf",
2219        }
2220    }
2221
2222    fn expected_constraint(self) -> &'static str {
2223        match self {
2224            CompositionKind::AnyOf => "at least one anyOf branch",
2225            CompositionKind::OneOf => "exactly one oneOf branch",
2226        }
2227    }
2228}
2229
2230#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2231struct CompositionFailureReason {
2232    message: String,
2233    property_path: String,
2234}
2235
2236impl CompositionFailureReason {
2237    fn new(message: impl Into<String>, property_path: impl Into<String>) -> Self {
2238        Self { message: message.into(), property_path: property_path.into() }
2239    }
2240}
2241
2242#[derive(Clone, Debug)]
2243struct CompositionBranchEvaluation {
2244    branch: usize,
2245    matched: bool,
2246    required_property_combinations: Vec<Vec<String>>,
2247    required_property_combinations_curtailed: bool,
2248    failure_reasons: Vec<CompositionFailureReason>,
2249}
2250
2251impl CompositionBranchEvaluation {
2252    fn new(
2253        branch: usize,
2254        matched: bool,
2255        required_property_combinations: (Vec<Vec<String>>, bool),
2256        failure_reasons: Vec<CompositionFailureReason>,
2257    ) -> Self {
2258        let (mut required_property_combinations, required_property_combinations_curtailed) =
2259            required_property_combinations;
2260        for combination in &mut required_property_combinations {
2261            combination.sort();
2262            combination.dedup();
2263        }
2264        required_property_combinations.retain(|combination| !combination.is_empty());
2265        required_property_combinations.sort();
2266        required_property_combinations.dedup();
2267        Self {
2268            branch,
2269            matched,
2270            required_property_combinations,
2271            required_property_combinations_curtailed,
2272            failure_reasons: deduplicate_composition_reasons(failure_reasons),
2273        }
2274    }
2275}
2276
2277fn deduplicate_composition_reasons(mut reasons: Vec<CompositionFailureReason>) -> Vec<CompositionFailureReason> {
2278    reasons.sort();
2279    reasons.dedup();
2280    reasons
2281}
2282
2283#[allow(clippy::too_many_arguments)]
2284fn evaluate_object_composition_branches(
2285    m: &Arc<SemanticModel>,
2286    rid: &str,
2287    rtype: &str,
2288    actual_keys: &[String],
2289    branches: &[SubSchema],
2290    defs: &HashMap<String, PropSchema>,
2291    base_path: &str,
2292    assignment: &HashMap<String, bool>,
2293) -> Vec<CompositionBranchEvaluation> {
2294    branches
2295        .iter()
2296        .enumerate()
2297        .map(|(index, branch)| {
2298            let mut branch_diagnostics = Vec::new();
2299            validate_sub_under_assignment(
2300                &mut branch_diagnostics,
2301                m,
2302                rid,
2303                rtype,
2304                actual_keys,
2305                branch,
2306                defs,
2307                base_path,
2308                assignment,
2309            );
2310            let matched = branch_diagnostics.is_empty();
2311            let mut failure_reasons: Vec<CompositionFailureReason> = branch_diagnostics
2312                .into_iter()
2313                .map(|diagnostic| {
2314                    CompositionFailureReason::new(
2315                        diagnostic.message,
2316                        diagnostic.property_path.unwrap_or_else(|| base_path.to_string()),
2317                    )
2318                })
2319                .collect();
2320            if !matched && failure_reasons.is_empty() {
2321                failure_reasons.push(CompositionFailureReason::new("The branch schema was not satisfied", base_path));
2322            }
2323            CompositionBranchEvaluation::new(
2324                index + 1,
2325                matched,
2326                required_property_combinations(branch, defs, Some(rtype)),
2327                failure_reasons,
2328            )
2329        })
2330        .collect()
2331}
2332
2333/// Maximum required-property combinations retained for one composition branch's
2334/// diagnostic context. These combinations explain a failure; they do not decide
2335/// whether the branch matched. Capping them therefore bounds message/context work
2336/// without changing validation correctness.
2337fn required_property_combinations(
2338    branch: &SubSchema,
2339    defs: &HashMap<String, PropSchema>,
2340    rtype: Option<&str>,
2341) -> (Vec<Vec<String>>, bool) {
2342    let resolved;
2343    let effective = if branch.ref_name.is_some() {
2344        resolved = branch.resolve(defs);
2345        if resolved.ref_name.is_some() {
2346            return (Vec::new(), false);
2347        }
2348        &*resolved
2349    } else {
2350        branch
2351    };
2352
2353    let mut base: Vec<String> = effective
2354        .required
2355        .iter()
2356        .filter(|property| {
2357            rtype.is_none_or(|resource_type| !extension_required_covered_by_dedicated_rule(resource_type, property))
2358        })
2359        .cloned()
2360        .collect();
2361    base.sort();
2362    base.dedup();
2363
2364    if effective.required.is_empty() && effective.required_or.is_empty() && effective.required_xor.is_empty() {
2365        return (Vec::new(), false);
2366    }
2367
2368    let mut combinations = vec![base];
2369    let mut curtailed = expand_required_choice_group(&mut combinations, &effective.required_or, false);
2370    curtailed |= expand_required_choice_group(&mut combinations, &effective.required_xor, true);
2371    (combinations, curtailed)
2372}
2373
2374/// Expands one required-choice group, returning whether the explanation set was
2375/// curtailed at [`MAX_REQUIRED_PROPERTY_COMBINATIONS`].
2376fn expand_required_choice_group(combinations: &mut Vec<Vec<String>>, choices: &[String], exactly_one: bool) -> bool {
2377    if choices.is_empty() {
2378        return false;
2379    }
2380
2381    let mut expanded = Vec::new();
2382    let mut curtailed = false;
2383    'combinations: for combination in combinations.iter() {
2384        let present = choices.iter().filter(|choice| combination.contains(choice)).count();
2385        if present > 0 {
2386            if !exactly_one || present == 1 {
2387                if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2388                    curtailed = true;
2389                    break;
2390                }
2391                expanded.push(combination.clone());
2392            }
2393            continue;
2394        }
2395        for choice in choices {
2396            if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2397                curtailed = true;
2398                break 'combinations;
2399            }
2400            let mut candidate = combination.clone();
2401            candidate.push(choice.clone());
2402            expanded.push(candidate);
2403        }
2404    }
2405    *combinations = expanded;
2406    curtailed
2407}
2408
2409#[allow(clippy::too_many_arguments)]
2410fn build_composition_diagnostic(
2411    rule_id: &str,
2412    kind: CompositionKind,
2413    evaluations: &[CompositionBranchEvaluation],
2414    m: &Arc<SemanticModel>,
2415    rid: &str,
2416    property_path: &str,
2417    resource_type: Option<&str>,
2418    actual_value: Option<&serde_json::Value>,
2419    condition_scenario: Option<HashMap<String, bool>>,
2420) -> Diagnostic {
2421    let matching_branches: Vec<usize> =
2422        evaluations.iter().filter(|evaluation| evaluation.matched).map(|evaluation| evaluation.branch).collect();
2423    let match_count = matching_branches.len();
2424    let branch_count = evaluations.len();
2425    let match_outcome = if match_count == 0 { "zeroMatches" } else { "multipleMatches" };
2426
2427    let target = resource_type.map(|rtype| format!(" for {rtype}")).unwrap_or_default();
2428    let mut message = match (kind, match_count) {
2429        (CompositionKind::AnyOf, _) => format!(
2430            "Value is not valid under any of the {branch_count} anyOf schemas{target} (0 branches matched; at least one is required)."
2431        ),
2432        (CompositionKind::OneOf, 0) => format!(
2433            "Value is not valid under any of the {branch_count} oneOf schemas{target} (0 branches matched; exactly one is required)."
2434        ),
2435        (CompositionKind::OneOf, _) => format!(
2436            "Value is valid under more than one of the {branch_count} oneOf schemas{target} ({match_count} branches matched; exactly one is required). Matching branches: {}.",
2437            render_branch_numbers(&matching_branches)
2438        ),
2439    };
2440
2441    let mut valid_combination_set = BTreeSet::new();
2442    let mut combinations_curtailed =
2443        evaluations.iter().any(|evaluation| evaluation.required_property_combinations_curtailed);
2444    'evaluations: for evaluation in evaluations {
2445        for combination in &evaluation.required_property_combinations {
2446            if valid_combination_set.contains(combination) {
2447                continue;
2448            }
2449            if valid_combination_set.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2450                combinations_curtailed = true;
2451                break 'evaluations;
2452            }
2453            valid_combination_set.insert(combination.clone());
2454        }
2455    }
2456    let valid_combinations: Vec<Vec<String>> = valid_combination_set.into_iter().collect();
2457    if !valid_combinations.is_empty() {
2458        message.push_str(" Required property combinations: ");
2459        message.push_str(
2460            &valid_combinations
2461                .iter()
2462                .map(|combination| {
2463                    format!(
2464                        "[{}]",
2465                        combination.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
2466                    )
2467                })
2468                .collect::<Vec<_>>()
2469                .join(" or "),
2470        );
2471        message.push('.');
2472    }
2473    if combinations_curtailed {
2474        m.record_budget_exhaustion(BudgetKind::RequiredPropertyCombinations);
2475        message.push_str(&format!(
2476            " Additional required-property combinations were omitted after the deterministic limit of {MAX_REQUIRED_PROPERTY_COMBINATIONS}."
2477        ));
2478    }
2479
2480    if match_count == 0 {
2481        let failure_summaries: Vec<String> = evaluations
2482            .iter()
2483            .filter(|evaluation| !evaluation.matched)
2484            .map(|evaluation| {
2485                let reasons = render_composition_reasons(&evaluation.failure_reasons, property_path);
2486                format!("branch {}: {reasons}", evaluation.branch)
2487            })
2488            .collect();
2489        if !failure_summaries.is_empty() {
2490            message.push_str(" Branch failures: ");
2491            message.push_str(&failure_summaries.join("; "));
2492            message.push('.');
2493        }
2494    }
2495
2496    let mut extra = HashMap::new();
2497    extra.insert("compositionKind".to_string(), serde_json::json!(kind.name()).into());
2498    extra.insert("matchOutcome".to_string(), serde_json::json!(match_outcome).into());
2499    extra.insert("branchCount".to_string(), serde_json::json!(branch_count).into());
2500    extra.insert("matchCount".to_string(), serde_json::json!(match_count).into());
2501    if !valid_combinations.is_empty() {
2502        extra.insert("validPropertyCombinations".to_string(), serde_json::json!(valid_combinations).into());
2503    }
2504    if combinations_curtailed {
2505        extra.insert("validPropertyCombinationsCurtailed".to_string(), serde_json::json!(true).into());
2506        extra.insert(
2507            "validPropertyCombinationsLimit".to_string(),
2508            serde_json::json!(MAX_REQUIRED_PROPERTY_COMBINATIONS).into(),
2509        );
2510    }
2511    if !matching_branches.is_empty() {
2512        extra.insert("matchingBranches".to_string(), serde_json::json!(matching_branches).into());
2513    }
2514    let branch_failures: Vec<serde_json::Value> = evaluations
2515        .iter()
2516        .filter(|evaluation| !evaluation.matched)
2517        .map(|evaluation| {
2518            let reasons: Vec<serde_json::Value> = evaluation
2519                .failure_reasons
2520                .iter()
2521                .map(|reason| {
2522                    serde_json::json!({
2523                        "message": reason.message,
2524                        "propertyPath": reason.property_path,
2525                    })
2526                })
2527                .collect();
2528            serde_json::json!({
2529                "branch": evaluation.branch,
2530                "reasons": reasons,
2531            })
2532        })
2533        .collect();
2534    if !branch_failures.is_empty() {
2535        extra.insert("branchFailures".to_string(), serde_json::json!(branch_failures).into());
2536    }
2537
2538    let mut diagnostic =
2539        build_diagnostic_conditional(rule_id, &message, m, rid, property_path, None, condition_scenario);
2540    diagnostic.context = Some(ViolationContext {
2541        actual_value: actual_value.cloned().map(Into::into),
2542        expected_constraint: Some(kind.expected_constraint().to_string()),
2543        property: None,
2544        lifecycle: None,
2545        resolution_source: None,
2546        extra: Some(extra),
2547    });
2548    diagnostic
2549}
2550
2551fn render_branch_numbers(branches: &[usize]) -> String {
2552    branches.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")
2553}
2554
2555fn render_composition_reasons(reasons: &[CompositionFailureReason], default_path: &str) -> String {
2556    reasons
2557        .iter()
2558        .map(|reason| {
2559            if reason.property_path.is_empty() || reason.property_path == default_path {
2560                reason.message.clone()
2561            } else {
2562                format!("at '{}': {}", reason.property_path, reason.message)
2563            }
2564        })
2565        .collect::<Vec<_>>()
2566        .join(", ")
2567}
2568
2569/// Runs `validate_sub` restricted to one condition assignment: only value
2570/// scenarios consistent with `assignment` participate in branch matching, so a
2571/// value from a mutually exclusive `Fn::If` branch cannot decide this one.
2572///
2573/// Preserves and restores any previously active SCENARIO_FILTER so that nested
2574/// composition (e.g. requiredXor inside a oneOf branch inside an outer oneOf)
2575/// retains the outer constraint rather than clearing it.
2576#[allow(clippy::too_many_arguments)]
2577fn validate_sub_under_assignment(
2578    out: &mut Vec<Diagnostic>,
2579    m: &Arc<SemanticModel>,
2580    rid: &str,
2581    rtype: &str,
2582    actual_keys: &[String],
2583    sub: &SubSchema,
2584    defs: &HashMap<String, PropSchema>,
2585    base_path: &str,
2586    assignment: &HashMap<String, bool>,
2587) {
2588    let Some(merged) = merged_scenario_filter(assignment) else {
2589        return;
2590    };
2591    let _scenario_filter_scope = ScenarioFilterScope::enter(merged.clone());
2592    let effective_keys: Vec<String> =
2593        actual_keys.iter().filter(|key| property_present_under(m, rid, base_path, key, &merged)).cloned().collect();
2594    validate_sub(out, m, rid, rtype, &effective_keys, sub, defs, base_path, 0);
2595}
2596
2597struct ScenarioFilterScope {
2598    previous: Option<HashMap<String, bool>>,
2599}
2600
2601impl ScenarioFilterScope {
2602    fn enter(assignment: HashMap<String, bool>) -> Self {
2603        let previous = SCENARIO_FILTER.with(|filter| filter.replace(Some(assignment)));
2604        Self { previous }
2605    }
2606}
2607
2608impl Drop for ScenarioFilterScope {
2609    fn drop(&mut self) {
2610        let previous = self.previous.take();
2611        SCENARIO_FILTER.with(|filter| {
2612            filter.replace(previous);
2613        });
2614    }
2615}
2616
2617fn merged_scenario_filter(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2618    SCENARIO_FILTER.with(|filter| {
2619        let mut merged = filter.borrow().clone().unwrap_or_default();
2620        for (name, value) in assignment {
2621            if merged.get(name).is_some_and(|previous| previous != value) {
2622                return None;
2623            }
2624            merged.insert(name.clone(), *value);
2625        }
2626        Some(merged)
2627    })
2628}
2629
2630fn reset_scenario_filter() {
2631    SCENARIO_FILTER.with(|filter| {
2632        filter.replace(None);
2633    });
2634}
2635
2636thread_local! {
2637    /// The condition assignment the current branch-matching pass is scoped to.
2638    ///
2639    /// Threaded as task-local state rather than a parameter because
2640    /// `validate_sub` recurses through conditional branches and nested
2641    /// composition, and the filter applies uniformly to every value lookup
2642    /// underneath one group decision.
2643    static SCENARIO_FILTER: std::cell::RefCell<Option<HashMap<String, bool>>> = const { std::cell::RefCell::new(None) };
2644
2645    /// Budget kinds exhausted by schema-local functions that lack direct access
2646    /// to the SemanticModel. Drained into the model's budget tracker after
2647    /// initial schema validation and after schema context enrichment.
2648    static SCHEMA_BUDGET_EXHAUSTIONS: std::cell::RefCell<BTreeSet<BudgetKind>> =
2649        const { std::cell::RefCell::new(BTreeSet::new()) };
2650}
2651
2652fn record_schema_budget_exhaustion(kind: BudgetKind) {
2653    SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2654        exhaustions.borrow_mut().insert(kind);
2655    });
2656}
2657
2658fn reset_schema_budget_exhaustions() {
2659    SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| exhaustions.borrow_mut().clear());
2660}
2661
2662fn drain_schema_budget_exhaustions(model: &Arc<SemanticModel>) {
2663    SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2664        for kind in std::mem::take(&mut *exhaustions.borrow_mut()) {
2665            model.record_budget_exhaustion(kind);
2666        }
2667    });
2668}
2669
2670/// Whether a value scenario's conditions are consistent with the active
2671/// assignment (if any): no contradicting keys, and the union satisfiable.
2672fn scenario_consistent_with_filter(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
2673    SCENARIO_FILTER.with(|filter| {
2674        let borrowed = filter.borrow();
2675        let Some(assignment) = borrowed.as_ref() else {
2676            return true;
2677        };
2678        for (name, value) in assignment {
2679            if let Some(scenario_value) = conds.get(name)
2680                && scenario_value != value
2681            {
2682                return false;
2683            }
2684        }
2685        let mut merged = conds.clone();
2686        for (name, value) in assignment {
2687            merged.insert(name.clone(), *value);
2688        }
2689        is_satisfiable(m, &merged)
2690    })
2691}
2692
2693/// Whether a composition branch states value constraints on the instance
2694/// itself (rather than on named properties). Destructured exhaustively so a
2695/// newly added constraint field cannot be silently skipped.
2696///
2697/// Structural fields - `properties`, `required`, `additional_properties`,
2698/// `pattern_properties`, and the dependency maps - are deliberately excluded:
2699/// `validate_sub` enforces them directly with their own rule IDs, and running
2700/// the value matcher for them here would double-report. `ref_name` is excluded
2701/// because the caller resolves the branch before the self-check.
2702fn sub_self_constrains_value(sub: &PropSchema) -> bool {
2703    let PropSchema {
2704        ref_name: _,
2705        prop_type,
2706        enum_values,
2707        enum_case_insensitive,
2708        not_enum,
2709        const_value,
2710        pattern,
2711        minimum,
2712        maximum,
2713        exclusive_minimum,
2714        exclusive_maximum,
2715        multiple_of,
2716        min_length,
2717        max_length,
2718        min_items,
2719        max_items,
2720        unique_items,
2721        min_properties,
2722        max_properties,
2723        format,
2724        properties: _,
2725        required: _,
2726        required_present: _,
2727        additional_properties: _,
2728        pattern_properties: _,
2729        items,
2730        all_of,
2731        any_of,
2732        one_of,
2733        if_then_else,
2734        dependent_required: _,
2735        dependent_excluded: _,
2736        required_or: _,
2737        required_xor: _,
2738    } = sub;
2739    prop_type.is_some()
2740        || !enum_values.is_empty()
2741        || !enum_case_insensitive.is_empty()
2742        || !not_enum.is_empty()
2743        || const_value.is_some()
2744        || pattern.is_some()
2745        || minimum.is_some()
2746        || maximum.is_some()
2747        || exclusive_minimum.is_some()
2748        || exclusive_maximum.is_some()
2749        || multiple_of.is_some()
2750        || min_length.is_some()
2751        || max_length.is_some()
2752        || min_items.is_some()
2753        || max_items.is_some()
2754        || unique_items == &Some(true)
2755        || min_properties.is_some()
2756        || max_properties.is_some()
2757        || format.is_some()
2758        || items.is_some()
2759        || !all_of.is_empty()
2760        || !any_of.is_empty()
2761        || !one_of.is_empty()
2762        || !if_then_else.is_empty()
2763}
2764
2765fn schema_for_self_value_constraints(sub: &PropSchema) -> PropSchema {
2766    PropSchema {
2767        ref_name: None,
2768        properties: HashMap::new(),
2769        required: Vec::new(),
2770        required_present: false,
2771        additional_properties: None,
2772        pattern_properties: HashMap::new(),
2773        dependent_required: HashMap::new(),
2774        dependent_excluded: HashMap::new(),
2775        required_or: Vec::new(),
2776        required_xor: Vec::new(),
2777        ..sub.clone()
2778    }
2779}
2780
2781/// A short, human-readable summary of the constraints a property schema states,
2782/// for composition-branch diagnostics. Names the checks a value would have to
2783/// satisfy, most specific first.
2784fn describe_prop_constraints(schema: &PropSchema) -> String {
2785    let mut parts: Vec<String> = Vec::new();
2786    if let Some(ref pt) = schema.prop_type {
2787        parts.push(format!("type '{}'", pt.names().collect::<Vec<_>>().join("|")));
2788    }
2789    if !schema.enum_values.is_empty() {
2790        parts.push(format!("one of {}", format_allowed_values(&schema.enum_values)));
2791    }
2792    if !schema.enum_case_insensitive.is_empty() {
2793        parts.push(format!("one of {} (case-insensitive)", format_allowed_values(&schema.enum_case_insensitive)));
2794    }
2795    if !schema.not_enum.is_empty() {
2796        parts.push(format!("none of {}", format_allowed_values(&schema.not_enum)));
2797    }
2798    if let Some(ref cv) = schema.const_value {
2799        parts.push(format!("exactly {cv}"));
2800    }
2801    if let Some(ref pattern) = schema.pattern {
2802        parts.push(format!("pattern '{pattern}'"));
2803    }
2804    if let Some(min) = schema.minimum {
2805        parts.push(format!("minimum {min}"));
2806    }
2807    if let Some(max) = schema.maximum {
2808        parts.push(format!("maximum {max}"));
2809    }
2810    if let Some(min) = schema.exclusive_minimum {
2811        parts.push(format!("exclusiveMinimum {min}"));
2812    }
2813    if let Some(max) = schema.exclusive_maximum {
2814        parts.push(format!("exclusiveMaximum {max}"));
2815    }
2816    if let Some(mult) = schema.multiple_of {
2817        parts.push(format!("multipleOf {mult}"));
2818    }
2819    if let Some(min) = schema.min_length {
2820        parts.push(format!("minLength {min}"));
2821    }
2822    if let Some(max) = schema.max_length {
2823        parts.push(format!("maxLength {max}"));
2824    }
2825    if let Some(min) = schema.min_items {
2826        parts.push(format!("minItems {min}"));
2827    }
2828    if let Some(max) = schema.max_items {
2829        parts.push(format!("maxItems {max}"));
2830    }
2831    if let Some(min) = schema.min_properties {
2832        parts.push(format!("minProperties {min}"));
2833    }
2834    if let Some(max) = schema.max_properties {
2835        parts.push(format!("maxProperties {max}"));
2836    }
2837    if !schema.required.is_empty() {
2838        parts.push(format!(
2839            "requires {}",
2840            schema.required.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", ")
2841        ));
2842    }
2843    if schema.items.is_some() {
2844        parts.push("an item schema".to_string());
2845    }
2846    if !schema.properties.is_empty() {
2847        parts.push("nested property constraints".to_string());
2848    }
2849    if !schema.all_of.is_empty() || !schema.any_of.is_empty() || !schema.one_of.is_empty() {
2850        parts.push("nested composition".to_string());
2851    }
2852    if !schema.if_then_else.is_empty() {
2853        parts.push("a conditional constraint".to_string());
2854    }
2855    if parts.is_empty() { "the branch schema".to_string() } else { parts.join(", ") }
2856}
2857
2858/// Validates the dependentRequired/dependentExcluded constraints of a subschema.
2859///
2860/// Split out from `validate_sub` so conditional (`if`/`then`) branches can still
2861/// enforce property co-dependencies without raising the unconditional structural
2862/// required check: a property that is required only when a sibling holds a
2863/// particular value is a semantic dependency, which dedicated resource-specific
2864/// rules own, not a Fatal structural violation.
2865fn validate_sub_dependencies(
2866    out: &mut Vec<Diagnostic>,
2867    m: &Arc<SemanticModel>,
2868    rid: &str,
2869    actual_keys: &[String],
2870    sub: &SubSchema,
2871    base_path: &str,
2872) {
2873    for (trigger, deps) in &sub.dependent_required {
2874        if actual_keys.contains(trigger) {
2875            for dep in deps {
2876                if !actual_keys.contains(dep) {
2877                    out.push(build_diagnostic(
2878                        "F3021",
2879                        &format!("'{}' is a dependency of '{}'", dep, trigger),
2880                        m,
2881                        rid,
2882                        base_path,
2883                        None,
2884                    ));
2885                }
2886            }
2887        }
2888    }
2889    for (trigger, excluded) in &sub.dependent_excluded {
2890        if actual_keys.contains(trigger) {
2891            for dep in excluded {
2892                if actual_keys.contains(dep) {
2893                    out.push(build_diagnostic(
2894                        "F3020",
2895                        &format!("'{}' should not be included with '{}'", dep, trigger),
2896                        m,
2897                        rid,
2898                        &format!("{}.{}", base_path, dep),
2899                        None,
2900                    ));
2901                }
2902            }
2903        }
2904    }
2905}
2906
2907fn collect_outer_resolved_scenarios(
2908    value: &ResolvedValue,
2909    assumptions: &HashMap<String, bool>,
2910    scenarios: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
2911) {
2912    match value {
2913        ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
2914            Some(true) => collect_outer_resolved_scenarios(if_true, assumptions, scenarios),
2915            Some(false) => collect_outer_resolved_scenarios(if_false, assumptions, scenarios),
2916            None => {
2917                let mut true_assumptions = assumptions.clone();
2918                true_assumptions.insert(condition.clone(), true);
2919                collect_outer_resolved_scenarios(if_true, &true_assumptions, scenarios);
2920                let mut false_assumptions = assumptions.clone();
2921                false_assumptions.insert(condition.clone(), false);
2922                collect_outer_resolved_scenarios(if_false, &false_assumptions, scenarios);
2923            }
2924        },
2925        ResolvedValue::Enum { variants } => {
2926            for variant in variants {
2927                collect_outer_resolved_scenarios(variant, assumptions, scenarios);
2928            }
2929        }
2930        _ => scenarios.push((value.clone(), assumptions.clone())),
2931    }
2932}
2933
2934fn outer_resolved_scenarios(
2935    model: &Arc<SemanticModel>,
2936    resource_id: &str,
2937    property_path: &str,
2938) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
2939    let Some(value) =
2940        model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
2941    else {
2942        return model.resolve_scenarios(resource_id, property_path);
2943    };
2944    let mut scenarios = Vec::new();
2945    collect_outer_resolved_scenarios(&value, &HashMap::new(), &mut scenarios);
2946    scenarios
2947}
2948
2949fn collect_outer_value_scenarios(
2950    value: &ResolvedValue,
2951    assumptions: &HashMap<String, bool>,
2952    scenarios: &mut Vec<(serde_json::Value, HashMap<String, bool>)>,
2953) {
2954    match value {
2955        ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
2956            Some(true) => collect_outer_value_scenarios(if_true, assumptions, scenarios),
2957            Some(false) => collect_outer_value_scenarios(if_false, assumptions, scenarios),
2958            None => {
2959                let mut true_assumptions = assumptions.clone();
2960                true_assumptions.insert(condition.clone(), true);
2961                collect_outer_value_scenarios(if_true, &true_assumptions, scenarios);
2962                let mut false_assumptions = assumptions.clone();
2963                false_assumptions.insert(condition.clone(), false);
2964                collect_outer_value_scenarios(if_false, &false_assumptions, scenarios);
2965            }
2966        },
2967        ResolvedValue::Enum { variants } => {
2968            for variant in variants {
2969                collect_outer_value_scenarios(variant, assumptions, scenarios);
2970            }
2971        }
2972        ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {}
2973        ResolvedValue::Concrete { .. } | ResolvedValue::List { .. } | ResolvedValue::Map { .. } => {
2974            scenarios.push((resolved_value_to_json(value), assumptions.clone()));
2975        }
2976    }
2977}
2978
2979fn schema_requires_nested_value_scenarios(schema: &PropSchema) -> bool {
2980    let has_composite_value = |value: &serde_json::Value| value.is_array() || value.is_object();
2981    schema.min_items.is_some()
2982        || schema.max_items.is_some()
2983        || schema.unique_items == Some(true)
2984        || schema.min_properties.is_some()
2985        || schema.max_properties.is_some()
2986        || schema.enum_values.iter().any(has_composite_value)
2987        || schema.enum_case_insensitive.iter().any(has_composite_value)
2988        || schema.not_enum.iter().any(has_composite_value)
2989        || schema.const_value.as_ref().is_some_and(has_composite_value)
2990}
2991
2992fn validation_scenarios(
2993    model: &Arc<SemanticModel>,
2994    resource_id: &str,
2995    property_path: &str,
2996    schema: &PropSchema,
2997) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
2998    if schema_requires_nested_value_scenarios(schema) {
2999        return model.resolve_scenarios_json(resource_id, property_path);
3000    }
3001    let Some(value) =
3002        model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
3003    else {
3004        return model.resolve_scenarios_json(resource_id, property_path);
3005    };
3006    let mut scenarios = Vec::new();
3007    collect_outer_value_scenarios(&value, &HashMap::new(), &mut scenarios);
3008    scenarios
3009}
3010
3011fn validate_prop(
3012    out: &mut Vec<Diagnostic>,
3013    store: &CompiledSchemaStore,
3014    m: &Arc<SemanticModel>,
3015    rid: &str,
3016    rtype: &str,
3017    prop_path: &str,
3018    schema: &PropSchema,
3019    defs: &HashMap<String, PropSchema>,
3020    region: Option<&str>,
3021) {
3022    let scenarios = validation_scenarios(m, rid, prop_path, schema);
3023
3024    let is_type_exempt = TYPE_CHECK_EXEMPT_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3025
3026    if scenarios.is_empty() && !is_type_exempt {
3027        validate_reference_type(out, store, m, rid, prop_path, schema);
3028    }
3029
3030    let res_suffix = describe_resolution(m, rid, prop_path).map(|s| format!(" (from {})", s)).unwrap_or_default();
3031
3032    // Type check - coerce before rejecting since string↔number, string↔boolean,
3033    // bool→string, number→string are silently coerced at deploy time.
3034    // Successful coercion → Warn; failed coercion → Fatal.
3035    if let Some(ref pt) = schema.prop_type
3036        && !is_type_exempt
3037    {
3038        let is_packaging_path = PACKAGING_PROPERTY_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3039        // Skip type checks for array elements whose parent array or the element itself
3040        // came from an intrinsic function - those are validated by function-specific rules.
3041        let from_intrinsic = m.is_from_intrinsic(rid, prop_path)
3042            || prop_path
3043                .rsplit_once('.')
3044                .and_then(|(parent, seg)| seg.parse::<usize>().ok().map(|_| parent))
3045                .is_some_and(|parent| m.is_from_intrinsic(rid, parent));
3046        // A property whose value embeds an `Fn::If` expands into one scenario per
3047        // branch. When the value is the wrong type at the property level (e.g. a
3048        // list where an object is required), every branch fails the same check,
3049        // which would emit the same type diagnostic once per branch. Track the
3050        // (rule, expected-type) pairs already reported for this path so the
3051        // property-level mismatch is reported once, as a single observable error.
3052        let mut emitted_type_errors: HashSet<(&str, &str)> = HashSet::new();
3053        for (val, conds) in &scenarios {
3054            if !is_satisfiable(m, conds) || val.is_null() {
3055                continue;
3056            }
3057            // Skip unresolved/malformed intrinsics - already validated by structure rules
3058            if is_unresolved_intrinsic(val) {
3059                continue;
3060            }
3061            // Skip packaging properties when value is a string - valid with `package` command
3062            if is_packaging_path && val.is_string() {
3063                continue;
3064            }
3065            // Skip elements from intrinsic-resolved arrays
3066            if from_intrinsic {
3067                continue;
3068            }
3069            if !type_matches(val, pt) {
3070                let expected = pt.primary().unwrap_or("unknown");
3071                match coerce_value(val, expected) {
3072                    CoerceResult::Coerced(_, ref description) => {
3073                        if emitted_type_errors.insert(("W9003", expected)) {
3074                            out.push(build_diagnostic_conditional(
3075                                "W9003",
3076                                &format!(
3077                                    "{}{} is not of type '{}' - automatically coerced ({})",
3078                                    format_value(val),
3079                                    res_suffix,
3080                                    expected,
3081                                    description
3082                                ),
3083                                m,
3084                                rid,
3085                                prop_path,
3086                                None,
3087                                condition_map(conds),
3088                            ));
3089                        }
3090                    }
3091                    _ => {
3092                        if emitted_type_errors.insert(("F3012", expected)) {
3093                            out.push(build_diagnostic_conditional(
3094                                "F3012",
3095                                &format!("{}{} is not of type '{}'", format_value(val), res_suffix, expected),
3096                                m,
3097                                rid,
3098                                prop_path,
3099                                None,
3100                                condition_map(conds),
3101                            ));
3102                        }
3103                    }
3104                }
3105            }
3106        }
3107    }
3108
3109    if !schema.enum_values.is_empty() {
3110        let prop_name = prop_path.strip_prefix("Properties.").unwrap_or(prop_path);
3111        // The regional allowed set for the effective scope: the configured region,
3112        // or the union of all regions when none is configured (so a value valid in
3113        // any region is accepted rather than only the platform default).
3114        let regional = store.region_enums().allowed_values(rtype, prop_name, region);
3115        for (val, conds) in &scenarios {
3116            if !is_satisfiable(m, conds) || val.is_null() {
3117                continue;
3118            }
3119            let matches = if let Some(regional_vals) = &regional {
3120                val.as_str().map(|s| regional_vals.contains(&s)).unwrap_or(false)
3121                    || enum_matches(val, &schema.enum_values)
3122            } else {
3123                enum_matches(val, &schema.enum_values)
3124            };
3125            if !matches {
3126                // Enum sets are snapshots of what a service accepts today; AWS adds
3127                // new values over time, so a value absent from the compiled schema may
3128                // still deploy successfully. Reporting this as a Warning (rather than a
3129                // guaranteed-failure Fatal) lets templates using a newer value proceed
3130                // and stay suppressible.
3131                let enum_desc = if regional.is_some() {
3132                    format!("allowed values for region '{}'", region_enums::region_label(region))
3133                } else {
3134                    format_allowed_values(&schema.enum_values)
3135                };
3136                out.push(build_diagnostic_conditional(
3137                    "W3030",
3138                    &format!("{}{} is not one of {}", format_value(val), res_suffix, enum_desc),
3139                    m,
3140                    rid,
3141                    prop_path,
3142                    None,
3143                    condition_map(conds),
3144                ));
3145            }
3146        }
3147    }
3148
3149    if !schema.enum_case_insensitive.is_empty() {
3150        for (val, conds) in &scenarios {
3151            if !is_satisfiable(m, conds) || val.is_null() {
3152                continue;
3153            }
3154            if !enum_matches_case_insensitive(val, &schema.enum_case_insensitive) {
3155                // Same open-world Warning treatment as the exact-match enum
3156                // check above; the suffix tells the author any casing of the
3157                // listed values is accepted.
3158                out.push(build_diagnostic_conditional(
3159                    "W3030",
3160                    &format!(
3161                        "{}{} is not one of {} (case-insensitive)",
3162                        format_value(val),
3163                        res_suffix,
3164                        format_allowed_values(&schema.enum_case_insensitive)
3165                    ),
3166                    m,
3167                    rid,
3168                    prop_path,
3169                    None,
3170                    condition_map(conds),
3171                ));
3172            }
3173        }
3174    }
3175
3176    if !schema.not_enum.is_empty() {
3177        for (val, conds) in &scenarios {
3178            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3179                continue;
3180            }
3181            if enum_matches(val, &schema.not_enum) {
3182                out.push(build_diagnostic_conditional(
3183                    "F3030",
3184                    &format!("{} must not be one of {}", format_value(val), format_allowed_values(&schema.not_enum)),
3185                    m,
3186                    rid,
3187                    prop_path,
3188                    None,
3189                    condition_map(conds),
3190                ));
3191            }
3192        }
3193    }
3194
3195    if let Some(ref cv) = schema.const_value {
3196        for (val, conds) in &scenarios {
3197            if !is_satisfiable(m, conds) || val.is_null() {
3198                continue;
3199            }
3200            if !scalar_eq(val, cv) {
3201                out.push(build_diagnostic_conditional(
3202                    "F3030",
3203                    &format!("{} was expected", cv),
3204                    m,
3205                    rid,
3206                    prop_path,
3207                    None,
3208                    condition_map(conds),
3209                ));
3210            }
3211        }
3212    }
3213
3214    if let Some(ref pat) = schema.pattern
3215        && let Some(re) = compile_pattern(pat)
3216    {
3217        // A value computed by an intrinsic (Fn::Sub/Fn::Join building, say, an S3
3218        // bucket name from AWS::Region) can't be pattern-checked the way a written
3219        // literal can, since its final value is only known at deploy time. Those
3220        // are covered by the Warning-level intrinsic rules rather than this Fatal
3221        // pattern check, so skip both parameter-sourced and intrinsic-sourced
3222        // values here.
3223        let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3224        for (val, conds) in &scenarios {
3225            if !is_satisfiable(m, conds) || val.is_null() {
3226                continue;
3227            }
3228            if let Some(s) = coerce_to_string(val) {
3229                if s.contains("{{") && s.contains("resolve") {
3230                    continue;
3231                }
3232                if from_param {
3233                    continue;
3234                }
3235                if !re.is_match(&s) {
3236                    out.push(build_diagnostic_conditional(
3237                        "F3031",
3238                        &format!("{} does not match pattern '{}'", format_value(val), pat),
3239                        m,
3240                        rid,
3241                        prop_path,
3242                        None,
3243                        condition_map(conds),
3244                    ));
3245                }
3246            }
3247        }
3248    }
3249
3250    if let Some(ref fmt) = schema.format {
3251        validate_format(out, m, rid, prop_path, fmt, &scenarios);
3252    }
3253
3254    for (val, conds) in &scenarios {
3255        if !is_satisfiable(m, conds) || val.is_null() {
3256            continue;
3257        }
3258        let Some(n) = coerce_to_number(val) else {
3259            continue;
3260        };
3261        if let Some(max) = schema.maximum
3262            && n > max
3263        {
3264            out.push(build_diagnostic_conditional(
3265                "F3034",
3266                &format!("{} is greater than the maximum of {}", n, max),
3267                m,
3268                rid,
3269                prop_path,
3270                None,
3271                condition_map(conds),
3272            ));
3273        }
3274        if let Some(min) = schema.minimum
3275            && n < min
3276        {
3277            out.push(build_diagnostic_conditional(
3278                "F3034",
3279                &format!("{} is less than the minimum of {}", n, min),
3280                m,
3281                rid,
3282                prop_path,
3283                None,
3284                condition_map(conds),
3285            ));
3286        }
3287        if let Some(emax) = schema.exclusive_maximum
3288            && n >= emax
3289        {
3290            out.push(build_diagnostic_conditional(
3291                "F3034",
3292                &format!("{} is >= exclusive maximum {}", n, emax),
3293                m,
3294                rid,
3295                prop_path,
3296                None,
3297                condition_map(conds),
3298            ));
3299        }
3300        if let Some(emin) = schema.exclusive_minimum
3301            && n <= emin
3302        {
3303            out.push(build_diagnostic_conditional(
3304                "F3034",
3305                &format!("{} is <= exclusive minimum {}", n, emin),
3306                m,
3307                rid,
3308                prop_path,
3309                None,
3310                condition_map(conds),
3311            ));
3312        }
3313        if let Some(mult) = schema.multiple_of
3314            && mult > 0.0
3315        {
3316            // Tolerant check for floating-point multipleOf: compute the
3317            // remainder and accept if it is within a small epsilon of zero
3318            // or the divisor itself (handles e.g. 0.3 / 0.1 = 2.9999...).
3319            let remainder = (n / mult).round() * mult - n;
3320            let epsilon = mult * 1e-9;
3321            if remainder.abs() > epsilon && (mult - remainder.abs()).abs() > epsilon {
3322                out.push(build_diagnostic_conditional(
3323                    "F3034",
3324                    &format!("{} is not a multiple of {}", n, mult),
3325                    m,
3326                    rid,
3327                    prop_path,
3328                    None,
3329                    condition_map(conds),
3330                ));
3331            }
3332        }
3333    }
3334
3335    if schema.min_length.is_some() || schema.max_length.is_some() {
3336        let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3337        for (val, conds) in &scenarios {
3338            if !is_satisfiable(m, conds) || val.is_null() {
3339                continue;
3340            }
3341            let Some(s) = coerce_to_string(val) else {
3342                continue;
3343            };
3344            if from_param {
3345                continue;
3346            }
3347            let len = s.chars().count() as u64;
3348            if let Some(max) = schema.max_length
3349                && len > max
3350            {
3351                out.push(build_diagnostic_conditional(
3352                    "F3033",
3353                    &format!("length {} exceeds maximum {}", len, max),
3354                    m,
3355                    rid,
3356                    prop_path,
3357                    None,
3358                    condition_map(conds),
3359                ));
3360            }
3361            if let Some(min) = schema.min_length
3362                && len < min
3363            {
3364                out.push(build_diagnostic_conditional(
3365                    "F3033",
3366                    &format!("length {} is below minimum {}", len, min),
3367                    m,
3368                    rid,
3369                    prop_path,
3370                    None,
3371                    condition_map(conds),
3372                ));
3373            }
3374        }
3375    }
3376
3377    for (val, conds) in &scenarios {
3378        if !is_satisfiable(m, conds) || val.is_null() {
3379            continue;
3380        }
3381        if let Some(arr) = val.as_array() {
3382            let len = arr.iter().filter(|item| !item.is_null()).count() as u64;
3383            if let Some(max) = schema.max_items
3384                && len > max
3385            {
3386                out.push(build_diagnostic_conditional(
3387                    "F3032",
3388                    &format!("expected maximum item count: {}, found: {}", max, len),
3389                    m,
3390                    rid,
3391                    prop_path,
3392                    None,
3393                    condition_map(conds),
3394                ));
3395            }
3396            if let Some(min) = schema.min_items
3397                && len < min
3398            {
3399                out.push(build_diagnostic_conditional(
3400                    "F3032",
3401                    &format!("expected minimum item count: {}, found: {}", min, len),
3402                    m,
3403                    rid,
3404                    prop_path,
3405                    None,
3406                    condition_map(conds),
3407                ));
3408            }
3409        }
3410    }
3411
3412    if schema.unique_items == Some(true) {
3413        for (val, conds) in &scenarios {
3414            if !is_satisfiable(m, conds) || val.is_null() {
3415                continue;
3416            }
3417            if let Some(arr) = val.as_array() {
3418                let mut seen = Vec::new();
3419                for item in arr {
3420                    // A null element is an `AWS::NoValue` that CloudFormation
3421                    // removes from the list at deploy time, so it is not a real
3422                    // member and two such elements are not a duplicate. Only the
3423                    // surviving concrete items are checked for uniqueness.
3424                    if item.is_null() {
3425                        continue;
3426                    }
3427                    if seen.contains(item) {
3428                        out.push(build_diagnostic_conditional(
3429                            "F3037",
3430                            "Array items are not unique",
3431                            m,
3432                            rid,
3433                            prop_path,
3434                            None,
3435                            condition_map(conds),
3436                        ));
3437                        break;
3438                    }
3439                    seen.push(item.clone());
3440                }
3441            }
3442        }
3443    }
3444
3445    let has_nested_constraints = !schema.properties.is_empty()
3446        || !schema.required.is_empty()
3447        || !schema.dependent_required.is_empty()
3448        || !schema.dependent_excluded.is_empty()
3449        || !schema.required_or.is_empty()
3450        || !schema.required_xor.is_empty()
3451        || !schema.all_of.is_empty()
3452        || !schema.any_of.is_empty()
3453        || !schema.one_of.is_empty()
3454        || !schema.if_then_else.is_empty()
3455        || schema.additional_properties.is_some();
3456    if has_nested_constraints {
3457        let nested_keys = collect_keys_deep(m, rid, prop_path);
3458        if !nested_keys.is_empty() {
3459            validate_object_keys(
3460                out,
3461                m,
3462                rid,
3463                rtype,
3464                &schema.properties,
3465                defs,
3466                &schema.required,
3467                schema.additional_properties,
3468                &schema.pattern_properties,
3469                &schema.dependent_required,
3470                &schema.dependent_excluded,
3471                &schema.required_or,
3472                &schema.required_xor,
3473                &schema.all_of,
3474                &schema.any_of,
3475                &schema.one_of,
3476                &nested_keys,
3477                prop_path,
3478            );
3479            // Property-level if/then/else on an object property: evaluate the
3480            // condition against the nested keys and enforce the selected branch
3481            // - in full for an overlay-stated conditional, dependencies-only
3482            // for a bundled one (see `IfThenElse::enforce_full_branch`).
3483            for ite in &schema.if_then_else {
3484                let matches = condition_matches_at(&ite.condition, &nested_keys, m, rid, defs, prop_path);
3485                let sub = if matches { &ite.then_schema } else { &ite.else_schema };
3486                if let Some(sub) = sub {
3487                    if ite.enforce_full_branch {
3488                        validate_sub(out, m, rid, rtype, &nested_keys, sub, defs, prop_path, 0);
3489                    } else {
3490                        validate_sub_dependencies(out, m, rid, &nested_keys, sub, prop_path);
3491                    }
3492                }
3493            }
3494        } else if !matches!(m.resolve_deep(rid, prop_path), Some(ResolvedValue::Conditional { .. })) {
3495            // An empty concrete object has no collected keys, but its object
3496            // constraints and composition branches still need evaluation.
3497            //
3498            // The `Conditional` guard skips properties that are an `Fn::If`:
3499            // there the branch-aware required-property rule owns the check and
3500            // anchors the diagnostic at the branch path (`<prop>.Fn::If.<idx>`),
3501            // so reporting here too would duplicate that finding at the
3502            // un-qualified property path.
3503            for (val, _conds) in m.resolve_scenarios(rid, prop_path) {
3504                if let ResolvedValue::Concrete { value } = &val
3505                    && let Some(obj) = value.as_object()
3506                {
3507                    let keys: Vec<String> = obj.keys().cloned().collect();
3508                    validate_object_keys(
3509                        out,
3510                        m,
3511                        rid,
3512                        rtype,
3513                        &schema.properties,
3514                        defs,
3515                        &schema.required,
3516                        schema.additional_properties,
3517                        &schema.pattern_properties,
3518                        &schema.dependent_required,
3519                        &schema.dependent_excluded,
3520                        &schema.required_or,
3521                        &schema.required_xor,
3522                        &schema.all_of,
3523                        &schema.any_of,
3524                        &schema.one_of,
3525                        &keys,
3526                        prop_path,
3527                    );
3528                }
3529            }
3530        }
3531
3532        // Property-level scalar composition: when the value is not an object
3533        // (nested_keys is empty) but the property schema carries anyOf/oneOf/
3534        // allOf/if_then_else, validate each concrete scenario against the
3535        // composition branches using schema_value_matches. This covers schemas
3536        // like KMS key identifiers where anyOf branches discriminate by format.
3537        // Skip when any scenario has an array/object value - those are validated
3538        // through the object-key or items paths instead.
3539        let has_scalar_composition = !schema.all_of.is_empty()
3540            || !schema.any_of.is_empty()
3541            || !schema.one_of.is_empty()
3542            || !schema.if_then_else.is_empty();
3543        if has_scalar_composition && nested_keys.is_empty() {
3544            let has_only_scalars = scenarios.iter().all(|(val, conds)| {
3545                !is_satisfiable(m, conds)
3546                    || val.is_null()
3547                    || is_unresolved_intrinsic(val)
3548                    || (!val.is_object() && !val.is_array())
3549            });
3550            if has_only_scalars {
3551                validate_prop_composition(out, m, rid, prop_path, schema, defs, &scenarios);
3552            }
3553        }
3554        for (pn, ps) in &schema.properties {
3555            let resolved = ps.resolve(defs);
3556            let sub_path = format!("{}.{}", prop_path, pn);
3557            let sub_scenarios = m.resolve_scenarios_json(rid, &sub_path);
3558            if !sub_scenarios.is_empty() || m.resolve_deep(rid, &sub_path).is_some() {
3559                validate_prop(out, store, m, rid, rtype, &sub_path, &resolved, defs, region);
3560            }
3561        }
3562    }
3563
3564    if let Some(ref item_schema) = schema.items {
3565        let resolved = item_schema.resolve(defs);
3566        // Use per-index paths instead of wildcard {} to avoid dedup mismatches
3567        let mut did_per_index = false;
3568        {
3569            let arr_len = match m.resolve_deep(rid, prop_path).or_else(|| m.resolve(rid, prop_path).cloned()) {
3570                Some(ResolvedValue::List { items }) => Some(items.len()),
3571                Some(ResolvedValue::Concrete { value: ref v }) if v.is_array() => Some(v.as_array().unwrap().len()),
3572                _ => None,
3573            };
3574            if let Some(len) = arr_len {
3575                did_per_index = true;
3576                for idx in 0..len {
3577                    let idx_path = format!("{}.{}", prop_path, idx);
3578                    validate_prop(out, store, m, rid, rtype, &idx_path, &resolved, defs, region);
3579                }
3580            } else {
3581                validate_prop(out, store, m, rid, rtype, &format!("{}.{{}}", prop_path), &resolved, defs, region);
3582            }
3583        }
3584        if !did_per_index && (!resolved.dependent_excluded.is_empty() || !resolved.dependent_required.is_empty()) {
3585            validate_array_item_constraints(out, m, rid, prop_path, &resolved);
3586        }
3587    }
3588}
3589
3590fn validate_array_item_constraints(
3591    out: &mut Vec<Diagnostic>,
3592    m: &Arc<SemanticModel>,
3593    rid: &str,
3594    array_path: &str,
3595    item_schema: &PropSchema,
3596) {
3597    let arr = match m.resolve_deep(rid, array_path).or_else(|| m.resolve(rid, array_path).cloned()) {
3598        Some(ResolvedValue::List { items }) => items,
3599        Some(ResolvedValue::Concrete { value: v }) => match v.into_inner() {
3600            serde_json::Value::Array(items) => {
3601                items.into_iter().map(|i| ResolvedValue::Concrete { value: i.into() }).collect()
3602            }
3603            _ => return,
3604        },
3605        _ => return,
3606    };
3607    for (idx, item) in arr.iter().enumerate() {
3608        let keys: Vec<String> = match item {
3609            ResolvedValue::Map { entries } => entries.iter().map(|e| e.key.clone()).collect(),
3610            ResolvedValue::Concrete { value: v } if v.is_object() => v.as_object().unwrap().keys().cloned().collect(),
3611            _ => continue,
3612        };
3613        let item_path = format!("{}.{}", array_path, idx);
3614        for (trigger, excluded) in &item_schema.dependent_excluded {
3615            if keys.iter().any(|k| k == trigger) {
3616                for dep in excluded {
3617                    if keys.iter().any(|k| k == dep) {
3618                        out.push(build_diagnostic(
3619                            "F3020",
3620                            &format!("'{}' should not be included with '{}'", dep, trigger),
3621                            m,
3622                            rid,
3623                            &format!("{}.{}", item_path, dep),
3624                            None,
3625                        ));
3626                    }
3627                }
3628            }
3629        }
3630        for (trigger, deps) in &item_schema.dependent_required {
3631            if keys.iter().any(|k| k == trigger) {
3632                for dep in deps {
3633                    if !keys.iter().any(|k| k == dep) {
3634                        out.push(build_diagnostic(
3635                            "F3021",
3636                            &format!("'{}' is a dependency of '{}'", dep, trigger),
3637                            m,
3638                            rid,
3639                            &item_path,
3640                            Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
3641                        ));
3642                    }
3643                }
3644            }
3645        }
3646    }
3647}
3648
3649fn collect_keys_deep(m: &Arc<SemanticModel>, rid: &str, path: &str) -> Vec<String> {
3650    let mut keys = HashSet::new();
3651    match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned()) {
3652        Some(ResolvedValue::Map { entries }) => {
3653            if entries.len() == 1 && is_intrinsic_key(&entries[0].key) {
3654                return Vec::new();
3655            }
3656            for e in &entries {
3657                keys.insert(e.key.clone());
3658            }
3659        }
3660        Some(ResolvedValue::Concrete { value: ref v }) if is_unresolved_intrinsic(v) => return Vec::new(),
3661        Some(ResolvedValue::Concrete { value: ref v }) if v.is_object() => {
3662            for k in v.as_object().unwrap().keys() {
3663                keys.insert(k.clone());
3664            }
3665        }
3666        _ => {}
3667    }
3668    if keys.is_empty() {
3669        for (val, conds) in &m.resolve_scenarios_json(rid, path) {
3670            if !is_satisfiable(m, conds) || val.is_null() || is_unresolved_intrinsic(val) {
3671                continue;
3672            }
3673            if let Some(obj) = val.as_object() {
3674                for k in obj.keys() {
3675                    keys.insert(k.clone());
3676                }
3677            }
3678        }
3679    }
3680    keys.into_iter().collect()
3681}
3682
3683fn type_matches(val: &serde_json::Value, pt: &PropType) -> bool {
3684    match pt {
3685        PropType::Single(s) => single_type(val, s),
3686        PropType::Multi(types) => types.iter().any(|t| single_type(val, t)),
3687    }
3688}
3689
3690fn single_type(val: &serde_json::Value, expected: &str) -> bool {
3691    match expected {
3692        "string" => val.is_string(),
3693        "integer" => {
3694            val.is_i64() || val.is_u64() || (val.is_f64() && val.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
3695        }
3696        "number" | "double" | "float" => val.is_number(),
3697        "boolean" => val.is_boolean(),
3698        "array" => val.is_array(),
3699        "object" => val.is_object(),
3700        "null" => val.is_null(),
3701        _ => true,
3702    }
3703}
3704
3705fn enum_matches(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3706    allowed.iter().any(|a| scalar_eq(a, val))
3707}
3708
3709/// Like [`enum_matches`] but string values match regardless of casing;
3710/// non-string values fall back to exact scalar comparison.
3711fn enum_matches_case_insensitive(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3712    allowed.iter().any(|a| match (a.as_str(), val.as_str()) {
3713        (Some(allowed_str), Some(val_str)) => allowed_str.eq_ignore_ascii_case(val_str),
3714        _ => scalar_eq(a, val),
3715    })
3716}
3717
3718fn check_required_not_null(out: &mut Vec<Diagnostic>, m: &Arc<SemanticModel>, rid: &str, base: &str, req: &str) {
3719    for (value, conds) in &outer_resolved_scenarios(m, rid, &format!("{}.{}", base, req)) {
3720        if !is_satisfiable(m, conds) || !scenario_consistent_with_filter(m, conds) {
3721            continue;
3722        }
3723        if matches!(value, ResolvedValue::Concrete { value } if value.is_null()) {
3724            out.push(build_diagnostic_conditional(
3725                "F3003",
3726                &format!("'{}' is a required property", req),
3727                m,
3728                rid,
3729                base,
3730                Some(&format!("Add the required property '{}'", req)),
3731                condition_map(conds),
3732            ));
3733        }
3734    }
3735}
3736
3737fn is_satisfiable(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
3738    if conds.is_empty() {
3739        return true;
3740    }
3741    m.conditions.is_satisfiable(&conds.iter().map(|(k, v)| (k.clone(), *v)).collect::<Vec<_>>())
3742}
3743
3744fn condition_map(conds: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
3745    if conds.is_empty() { None } else { Some(conds.clone()) }
3746}
3747
3748fn find_similar<'a>(key: &str, known: &HashSet<&'a str>) -> Option<&'a str> {
3749    let kl = key.to_lowercase();
3750    known
3751        .iter()
3752        .find(|k| {
3753            let l = k.to_lowercase();
3754            let max = kl.len().max(l.len());
3755            if max == 0 {
3756                return true;
3757            }
3758            let d = levenshtein_distance(&kl, &l);
3759            1.0 - (d as f64 / max as f64) > 0.8
3760        })
3761        .copied()
3762}
3763
3764fn levenshtein_distance(a: &str, b: &str) -> usize {
3765    let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
3766    let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
3767    for (i, row) in dp.iter_mut().enumerate() {
3768        row[0] = i;
3769    }
3770    for (j, cell) in dp[0].iter_mut().enumerate() {
3771        *cell = j;
3772    }
3773    for i in 1..=a.len() {
3774        for j in 1..=b.len() {
3775            let c = if a[i - 1] == b[j - 1] { 0 } else { 1 };
3776            dp[i][j] = (dp[i - 1][j] + 1).min(dp[i][j - 1] + 1).min(dp[i - 1][j - 1] + c);
3777        }
3778    }
3779    dp[a.len()][b.len()]
3780}
3781
3782fn format_value(val: &serde_json::Value) -> String {
3783    render_value(val)
3784}
3785
3786fn format_allowed_values(values: &[serde_json::Value]) -> String {
3787    render_value_list(values)
3788}
3789
3790fn condition_matches(
3791    cond: &ConditionSchema,
3792    actual_keys: &[String],
3793    m: &Arc<SemanticModel>,
3794    rid: &str,
3795    defs: &HashMap<String, PropSchema>,
3796) -> bool {
3797    condition_matches_at(cond, actual_keys, m, rid, defs, KEY_PROPERTIES)
3798}
3799
3800/// Evaluates a condition schema against the actual keys present at `base_path`.
3801/// `base_path` is the model path prefix for resolving property values (e.g.
3802/// `"Properties"` at the resource level, or `"Properties.Config"` for a nested
3803/// object property).
3804fn condition_matches_at(
3805    cond: &ConditionSchema,
3806    actual_keys: &[String],
3807    m: &Arc<SemanticModel>,
3808    rid: &str,
3809    defs: &HashMap<String, PropSchema>,
3810    base_path: &str,
3811) -> bool {
3812    if !cond.any_of.is_empty() {
3813        return cond.any_of.iter().any(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path));
3814    }
3815    if let Some(ref required_type) = cond.prop_type
3816        && !required_type.names().any(|name| name == "object")
3817    {
3818        return false;
3819    }
3820    for required_property in &cond.required {
3821        if !actual_keys.iter().any(|key| key == required_property)
3822            || !property_present_under(m, rid, base_path, required_property, &HashMap::new())
3823        {
3824            return false;
3825        }
3826    }
3827    for (property_name, property_schema) in &cond.properties {
3828        let resolved_schema = property_schema.resolve(defs);
3829        let property_path = format!("{}.{}", base_path, property_name);
3830        let scenarios = m.resolve_scenarios_json(rid, &property_path);
3831        let has_concrete_constraint = resolved_schema.pattern.is_some()
3832            || !resolved_schema.enum_values.is_empty()
3833            || !resolved_schema.not_enum.is_empty()
3834            || resolved_schema.const_value.is_some();
3835        if scenarios.is_empty() {
3836            if has_concrete_constraint {
3837                return false;
3838            }
3839            continue;
3840        }
3841        let reachable_scenarios: Vec<_> = scenarios
3842            .iter()
3843            .filter(|(_, conditions)| is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions))
3844            .collect();
3845        if reachable_scenarios.is_empty() || reachable_scenarios.iter().all(|(value, _)| value.is_null()) {
3846            continue;
3847        }
3848        for nested_required_property in &resolved_schema.required {
3849            let nested_path = format!("{}.{}", property_path, nested_required_property);
3850            let nested_property_exists =
3851                m.resolve_scenarios_json(rid, &nested_path).iter().any(|(value, conditions)| {
3852                    is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions) && !value.is_null()
3853                });
3854            if !nested_property_exists {
3855                return false;
3856            }
3857        }
3858        let compiled_pattern = resolved_schema.pattern.as_ref().and_then(|pattern| compile_pattern(pattern));
3859        if resolved_schema.pattern.is_some() && compiled_pattern.is_none() {
3860            return false;
3861        }
3862        let any_match = reachable_scenarios.iter().any(|(value, _)| {
3863            if value.is_null() {
3864                return true;
3865            }
3866            if !resolved_schema.enum_values.is_empty() && !enum_matches(value, &resolved_schema.enum_values) {
3867                return false;
3868            }
3869            if !resolved_schema.not_enum.is_empty() && enum_matches(value, &resolved_schema.not_enum) {
3870                return false;
3871            }
3872            if let Some(ref expected) = resolved_schema.const_value
3873                && !scalar_eq(value, expected)
3874            {
3875                return false;
3876            }
3877            if let Some(ref pattern) = compiled_pattern
3878                && !coerce_to_string(value).map(|text| pattern.is_match(&text)).unwrap_or(false)
3879            {
3880                return false;
3881            }
3882            if let Some(ref expected_type) = resolved_schema.prop_type
3883                && !type_matches(value, expected_type)
3884            {
3885                return false;
3886            }
3887            condition_bounds_match(value, &resolved_schema)
3888        });
3889        if !any_match {
3890            return false;
3891        }
3892    }
3893    true
3894}
3895
3896/// Whether `val` satisfies the length/count bounds a condition property states.
3897///
3898/// Draft-07 scopes each bound to one instance type - `minItems` to arrays,
3899/// `minLength` to strings, `minProperties` to objects - and an instance of any
3900/// other type passes vacuously.
3901fn condition_bounds_match(val: &serde_json::Value, schema: &PropSchema) -> bool {
3902    if let Some(items) = val.as_array() {
3903        let len = items.iter().filter(|item| !item.is_null()).count() as u64;
3904        if schema.min_items.is_some_and(|min| len < min) || schema.max_items.is_some_and(|max| len > max) {
3905            return false;
3906        }
3907    }
3908    if let Some(text) = val.as_str() {
3909        let len = text.chars().count() as u64;
3910        if schema.min_length.is_some_and(|min| len < min) || schema.max_length.is_some_and(|max| len > max) {
3911            return false;
3912        }
3913    }
3914    if let Some(members) = val.as_object() {
3915        let len = members.len() as u64;
3916        if schema.min_properties.is_some_and(|min| len < min) || schema.max_properties.is_some_and(|max| len > max) {
3917            return false;
3918        }
3919    }
3920    true
3921}
3922
3923/// Task 2: Validate type compatibility when a property's value is a Ref, GetAtt, or TypedDynamic.
3924/// Called as fallback when resolve_scenarios_json returns empty (it filters out References).
3925fn validate_reference_type(
3926    out: &mut Vec<Diagnostic>,
3927    store: &CompiledSchemaStore,
3928    m: &Arc<SemanticModel>,
3929    rid: &str,
3930    prop_path: &str,
3931    schema: &PropSchema,
3932) {
3933    let Some(ref expected_type) = schema.prop_type else {
3934        return;
3935    };
3936    let raw = m.resolve(rid, prop_path).cloned().or_else(|| m.resolve_deep(rid, prop_path));
3937    let Some(raw) = raw else { return };
3938
3939    match &raw {
3940        ResolvedValue::Reference { target, kind } => {
3941            let target_type = m.resources.get(target.as_str()).map(|r| r.resource_type.as_str());
3942            let Some(target_rtype) = target_type else {
3943                return;
3944            };
3945
3946            let source_type = match kind {
3947                RefKind::Ref => store.ref_types().ref_type_for(target_rtype),
3948                RefKind::GetAtt { attr } => store.ref_types().getatt_type_for(target_rtype, attr),
3949                _ => None,
3950            };
3951            let Some(source) = source_type else { return };
3952
3953            if !types_compatible(source, expected_type) {
3954                let ref_desc = match kind {
3955                    RefKind::Ref => format!("Ref to '{}'", target),
3956                    RefKind::GetAtt { attr: a } => {
3957                        format!("GetAtt {}.{}", target, a)
3958                    }
3959                    _ => return,
3960                };
3961                out.push(build_diagnostic(
3962                    "F3012",
3963                    &format!(
3964                        "{} ({}) returns '{}', but property expects '{}'",
3965                        ref_desc,
3966                        target_rtype,
3967                        source,
3968                        expected_type.primary().unwrap_or("unknown")
3969                    ),
3970                    m,
3971                    rid,
3972                    prop_path,
3973                    None,
3974                ));
3975            }
3976        }
3977        ResolvedValue::TypedDynamic { reason: _name, param_type } => {
3978            let source = cfn_param_type_to_schema_type(param_type);
3979            if !types_compatible(source, expected_type) {
3980                let expected = expected_type.primary().unwrap_or("unknown");
3981                // Parameters are coerced at deploy time - warn rather than error
3982                out.push(build_diagnostic(
3983                    "W9003",
3984                    &format!("Parameter type '{}' may not be compatible with expected type '{}'", param_type, expected),
3985                    m,
3986                    rid,
3987                    prop_path,
3988                    None,
3989                ));
3990            }
3991        }
3992        _ => {}
3993    }
3994}
3995
3996fn cfn_param_type_to_schema_type(param_type: &str) -> &str {
3997    match param_type {
3998        PARAM_TYPE_NUMBER => "number",
3999        PARAM_TYPE_STRING => "string",
4000        PARAM_TYPE_COMMA_DELIMITED_LIST => "array",
4001        t if t.starts_with("List<") => "array",
4002        t if t.starts_with("AWS::SSM::Parameter::") => "string",
4003        _ => "string",
4004    }
4005}
4006
4007fn types_compatible(source: &str, expected: &PropType) -> bool {
4008    match expected {
4009        PropType::Single(e) => single_type_compatible(source, e),
4010        PropType::Multi(types) => types.iter().any(|e| single_type_compatible(source, e)),
4011    }
4012}
4013
4014fn single_type_compatible(source: &str, expected: &str) -> bool {
4015    match (source, expected) {
4016        (s, e) if s == e => true,
4017        ("integer", "number") | ("number", "integer") => true,
4018        ("string", "number") | ("string", "integer") => true, // CFN coerces string→number
4019        ("number", "string") | ("integer", "string") | ("boolean", "string") => true, // CFN coerces to string
4020        (_, "null") => true,
4021        _ => false,
4022    }
4023}
4024
4025const EC2_IMAGE_ID_FORMAT: &str = "AWS::EC2::Image.Id";
4026const EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE: &str = "AWS::EC2::LaunchTemplate";
4027const EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH: &str = "Properties.LaunchTemplateData.ImageId";
4028const EC2_SSM_IMAGE_ALIAS_PREFIX: &str = "resolve:ssm:";
4029
4030static FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4031    let sources: [(&str, &str); 13] = [
4032        ("AWS::EC2::VPC.Id", r"^vpc-[a-f0-9]{8,17}$"),
4033        ("AWS::EC2::Subnet.Id", r"^subnet-[a-f0-9]{8,17}$"),
4034        ("AWS::EC2::SecurityGroup.Id", r"^sg-[a-f0-9]{8,17}$"),
4035        (EC2_IMAGE_ID_FORMAT, r"^ami-([0-9a-z]{8}|[0-9a-z]{17})$"),
4036        ("AWS::IAM::Role.Arn", IAM_ROLE_ARN_PATTERN),
4037        ("AWS::Logs::LogGroup.Name", r"^[\.\-_/#A-Za-z0-9]{1,512}$"),
4038        ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4039        ("AWS::EC2::KeyPair.KeyName", r"^[\x20-\x7E]{1,255}$"),
4040        ("AWS::EC2::AvailabilityZone.Name", r"^[a-z]{2}(-gov|-iso[a-z]*)?-[a-z]+-\d[a-z]$"),
4041        ("AWS::Route53::HostedZone.Id", r"^Z[A-Z0-9]{1,32}$"),
4042        ("AWS::EC2::Volume.Id", r"^vol-[a-f0-9]{8,17}$"),
4043        ("AWS::EC2::NetworkInterface.Id", r"^eni-[a-f0-9]{8,17}$"),
4044        ("AWS::SSM::Parameter.Name", r"^[a-zA-Z0-9_./-]{1,2048}$"),
4045    ];
4046    sources.into_iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (fmt, re))).collect()
4047});
4048
4049/// Additional format patterns used for composition branch discrimination.
4050/// These cover formats observed in upstream schemas that appear inside
4051/// anyOf/oneOf branches (e.g. KMS key identifiers, network CIDRs).
4052/// Formats already in `FORMAT_PATTERNS` are reused from there.
4053static BRANCH_FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4054    let sources: &[(&str, &str)] = &[
4055        // KMS key ARN: arn:partition:kms:region:account:key/<key-id>, where the
4056        // key ID is a UUID or a multi-Region `mrk-` + 32 hex digits. An alias ARN
4057        // (arn:...:alias/<name>) identifies a key just as a key ARN does, and
4058        // key-identifier properties whose composition carries this format accept
4059        // alias ARNs, so the ARN branch admits both suffix forms.
4060        (
4061            "AWS::KMS::Key.Arn",
4062            r"^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:\d{12}:(key/(mrk-[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})|alias/[a-zA-Z0-9:/_-]{1,250})$",
4063        ),
4064        // KMS key ID: standard UUID, multi-Region `mrk-` + 32 hex digits (no
4065        // dashes), or an alias name - the scalar identifiers KMS accepts for a key
4066        (
4067            "AWS::KMS::Key.Id",
4068            r"^(mrk-[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|alias/[a-zA-Z0-9:/_-]{1,250})$",
4069        ),
4070        // KMS alias name: alias/<name>, at most 256 chars including the prefix
4071        ("AWS::KMS::Alias.AliasName", r"^alias/[a-zA-Z0-9:/_-]{1,250}$"),
4072        // Security group name
4073        ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4074        // IPv4 CIDR notation
4075        ("ipv4-network", r"^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$"),
4076        // IPv6 CIDR notation (simplified - accepts valid hex groups with prefix length)
4077        ("ipv6-network", r"^[0-9a-fA-F:]+/\d{1,3}$"),
4078        // ISO 8601 date-time
4079        ("date-time", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4080        // Timestamp (same as date-time for validation purposes)
4081        ("timestamp", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4082    ];
4083    sources.iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (*fmt, re))).collect()
4084});
4085
4086/// Returns true when a string value matches the given format constraint for
4087/// branch discrimination in `schema_value_matches`. Reuses `FORMAT_PATTERNS`
4088/// for known diagnostic formats, falls back to `BRANCH_FORMAT_PATTERNS` for
4089/// composition-specific formats. Unknown formats are treated as annotations
4090/// (conservative true - no mismatch).
4091///
4092/// List-level formats (e.g. `.Ids`, `.Names`) are handled by matching each
4093/// element against the singular form when the value is a scalar string.
4094fn format_value_matches(value: &str, format: &str) -> bool {
4095    // Try the main diagnostic format patterns first
4096    if let Some(re) = FORMAT_PATTERNS.get(format) {
4097        return re.is_match(value);
4098    }
4099    // Try the branch-discrimination patterns
4100    if let Some(re) = BRANCH_FORMAT_PATTERNS.get(format) {
4101        return re.is_match(value);
4102    }
4103    // List-level formats: validate individual items against the singular form
4104    match format {
4105        "AWS::EC2::SecurityGroup.Ids" => {
4106            FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Id").is_none_or(|re| re.is_match(value))
4107        }
4108        "AWS::EC2::SecurityGroup.Names" => {
4109            FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Name").is_none_or(|re| re.is_match(value))
4110        }
4111        // json format: value must be syntactically valid JSON
4112        "json" => serde_json::from_str::<serde_json::Value>(value).is_ok(),
4113        // Unknown formats are annotations - conservative true
4114        _ => true,
4115    }
4116}
4117
4118fn sns_kms_identifier_is_runtime_validated(model: &SemanticModel, resource_id: &str, property_path: &str) -> bool {
4119    property_path == "Properties.KmsMasterKeyId"
4120        && model.resource(resource_id).is_some_and(|resource| resource.resource_type == "AWS::SNS::Topic")
4121}
4122
4123fn ec2_resolves_ssm_image_alias(
4124    model: &SemanticModel,
4125    resource_id: &str,
4126    property_path: &str,
4127    format: &str,
4128    value: &str,
4129) -> bool {
4130    format == EC2_IMAGE_ID_FORMAT
4131        && property_path == EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH
4132        && value.starts_with(EC2_SSM_IMAGE_ALIAS_PREFIX)
4133        && model
4134            .resource(resource_id)
4135            .is_some_and(|resource| resource.resource_type == EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE)
4136}
4137
4138/// Validates property-level composition (anyOf/oneOf/allOf/if_then_else) when
4139/// the property value is a scalar (no nested object keys). Uses
4140/// `schema_value_matches` to test each concrete satisfiable scenario against
4141/// the composition branches, emitting F3017/F3018 diagnostics with condition
4142/// scenarios. Dynamic/unresolved values are treated conservatively (no false
4143/// positives).
4144fn validate_prop_composition(
4145    out: &mut Vec<Diagnostic>,
4146    m: &Arc<SemanticModel>,
4147    rid: &str,
4148    prop_path: &str,
4149    schema: &PropSchema,
4150    defs: &HashMap<String, PropSchema>,
4151    scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4152) {
4153    // allOf: every branch must match for every satisfiable scenario
4154    for branch in &schema.all_of {
4155        for (val, conds) in scenarios {
4156            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4157                continue;
4158            }
4159            if !schema_value_matches(val, branch, defs, 0) {
4160                out.push(build_diagnostic_conditional(
4161                    "F3017",
4162                    "Value does not satisfy allOf constraint",
4163                    m,
4164                    rid,
4165                    prop_path,
4166                    None,
4167                    condition_map(conds),
4168                ));
4169                break;
4170            }
4171        }
4172    }
4173
4174    // anyOf: at least one branch must match for each satisfiable scenario
4175    if !schema.any_of.is_empty() && !sns_kms_identifier_is_runtime_validated(m, rid, prop_path) {
4176        for (val, conds) in scenarios {
4177            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4178                continue;
4179            }
4180            let evaluations = evaluate_value_composition_branches(val, &schema.any_of, defs, prop_path);
4181            if evaluations.iter().all(|evaluation| !evaluation.matched) {
4182                out.push(build_composition_diagnostic(
4183                    "F3017",
4184                    CompositionKind::AnyOf,
4185                    &evaluations,
4186                    m,
4187                    rid,
4188                    prop_path,
4189                    None,
4190                    Some(val),
4191                    condition_map(conds),
4192                ));
4193            }
4194        }
4195    }
4196
4197    // oneOf: exactly one branch must match for each satisfiable scenario
4198    if !schema.one_of.is_empty() {
4199        for (val, conds) in scenarios {
4200            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4201                continue;
4202            }
4203            let evaluations = evaluate_value_composition_branches(val, &schema.one_of, defs, prop_path);
4204            let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
4205            if match_count != 1 {
4206                out.push(build_composition_diagnostic(
4207                    "F3018",
4208                    CompositionKind::OneOf,
4209                    &evaluations,
4210                    m,
4211                    rid,
4212                    prop_path,
4213                    None,
4214                    Some(val),
4215                    condition_map(conds),
4216                ));
4217            }
4218        }
4219    }
4220
4221    // if/then/else: evaluate condition and enforce the selected branch. Only
4222    // overlay-stated conditionals participate; bundled ones are owned by
4223    // dedicated rules (see `IfThenElse::enforce_full_branch`).
4224    for ite in schema.if_then_else.iter().filter(|ite| ite.enforce_full_branch) {
4225        for (val, conds) in scenarios {
4226            if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4227                continue;
4228            }
4229            // For scalar if/then/else, the condition evaluates against the value
4230            // itself when the value is an object, otherwise conservative (condition
4231            // passes, only then-branch is checked).
4232            let cond_matches = if let Some(obj) = val.as_object() {
4233                let obj_keys: Vec<String> = obj
4234                    .iter()
4235                    .filter(|(_, candidate)| !candidate.is_null())
4236                    .map(|(property, _)| property.clone())
4237                    .collect();
4238                condition_schema_value_matches(&ite.condition, obj, &obj_keys, defs, 0)
4239            } else {
4240                // Scalar value: condition cannot meaningfully constrain it
4241                // (conditions check object keys/properties); treat as matching
4242                // and validate the then-branch.
4243                true
4244            };
4245            let branch = if cond_matches { &ite.then_schema } else { &ite.else_schema };
4246            if let Some(branch_schema) = branch
4247                && !schema_value_matches(val, branch_schema, defs, 0)
4248            {
4249                out.push(build_diagnostic_conditional(
4250                    "F3017",
4251                    "Value does not satisfy conditional schema constraint",
4252                    m,
4253                    rid,
4254                    prop_path,
4255                    None,
4256                    condition_map(conds),
4257                ));
4258            }
4259        }
4260    }
4261}
4262
4263fn validate_format(
4264    out: &mut Vec<Diagnostic>,
4265    m: &Arc<SemanticModel>,
4266    rid: &str,
4267    prop_path: &str,
4268    format: &str,
4269    scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4270) {
4271    let Some(re) = FORMAT_PATTERNS.get(format) else {
4272        return;
4273    };
4274
4275    for (val, conds) in scenarios {
4276        if !is_satisfiable(m, conds) || val.is_null() {
4277            continue;
4278        }
4279        if let Some(s) = coerce_to_string(val) {
4280            if s.contains("${") && m.is_from_intrinsic(rid, prop_path) {
4281                continue;
4282            }
4283            if m.is_from_parameter(rid, prop_path) {
4284                continue;
4285            }
4286            if ec2_resolves_ssm_image_alias(m, rid, prop_path, format, &s) {
4287                continue;
4288            }
4289            if !re.is_match(&s) {
4290                let rule_id = format_rule_for_format(format).unwrap_or("E1103");
4291                out.push(build_diagnostic_conditional(
4292                    rule_id,
4293                    &format!("{} does not match format '{}'", format_value(val), format),
4294                    m,
4295                    rid,
4296                    prop_path,
4297                    None,
4298                    condition_map(conds),
4299                ));
4300            }
4301        }
4302    }
4303}
4304
4305fn validate_lifecycle(out: &mut Vec<Diagnostic>, store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
4306    let lifecycle = store.lifecycle();
4307    for (rid, res) in &model.resources {
4308        if let Some(entry) = lifecycle.resource_lifecycle(&res.resource_type) {
4309            let (rule_id, msg) = match (entry.status.as_str(), entry.date.as_deref()) {
4310                ("shutdown", Some(d)) => (
4311                    "E3710",
4312                    format!("Resource type '{}' is from a service that was shut down on {}", res.resource_type, d),
4313                ),
4314                ("shutdown", None) => (
4315                    "E3710",
4316                    format!("Resource type '{}' is from a service that has been shut down", res.resource_type),
4317                ),
4318                ("sunset", Some(d)) => (
4319                    "W3696",
4320                    format!(
4321                        "Resource type '{}' is from a service that will be shut down on {}. Plan to migrate to an alternative",
4322                        res.resource_type, d
4323                    ),
4324                ),
4325                ("sunset", None) => {
4326                    ("W3696", format!("Resource type '{}' is from a service that is sunsetting", res.resource_type))
4327                }
4328                ("maintenance", Some(d)) => (
4329                    "W3697",
4330                    format!(
4331                        "Resource type '{}' is from a service in maintenance mode since {}. Consider migrating to an alternative",
4332                        res.resource_type, d
4333                    ),
4334                ),
4335                ("maintenance", None) => {
4336                    ("W3697", format!("Resource type '{}' is from a service in maintenance mode", res.resource_type))
4337                }
4338                _ => continue,
4339            };
4340            out.push(build_diagnostic(rule_id, &msg, model, rid, "", None));
4341        }
4342
4343        if (res.resource_type == "AWS::Lambda::Function" || res.resource_type == SAM_FUNCTION_TYPE)
4344            // Only a literal Runtime string is validated against the deprecation
4345            // list; a Runtime produced by an intrinsic (e.g. Fn::FindInMap)
4346            // resolves at deploy time and is handled by the intrinsic rules
4347            // instead.
4348            && !model.is_from_intrinsic(rid, "Properties.Runtime")
4349        {
4350            for (val, _) in &model.resolve_scenarios_json(rid, "Properties.Runtime") {
4351                let Some(runtime) = val.as_str() else {
4352                    continue;
4353                };
4354                // All three deprecation bands share one dated message; only the
4355                // rule id and severity differ by how far the runtime is through
4356                // its lifecycle. The band is a snapshot taken at data-sync time.
4357                let (rule_id, band) = if lifecycle.is_runtime_eol(runtime) {
4358                    ("E2533", Some(RuntimeLifecycleBand::EndOfLife))
4359                } else if lifecycle.is_runtime_create_blocked(runtime) {
4360                    ("E2531", Some(RuntimeLifecycleBand::CreateBlocked))
4361                } else if lifecycle.is_runtime_deprecated(runtime) {
4362                    ("W2531", Some(RuntimeLifecycleBand::Deprecated))
4363                } else {
4364                    ("", None)
4365                };
4366                if let Some(band) = band {
4367                    out.push(build_diagnostic(
4368                        rule_id,
4369                        &runtime_deprecation_message(lifecycle, runtime, band),
4370                        model,
4371                        rid,
4372                        "Properties.Runtime",
4373                        None,
4374                    ));
4375                }
4376            }
4377        }
4378    }
4379}
4380
4381#[derive(Clone, Copy)]
4382enum RuntimeLifecycleBand {
4383    Deprecated,
4384    CreateBlocked,
4385    EndOfLife,
4386}
4387
4388/// Builds the dated runtime-deprecation message using the lifecycle band from
4389/// the embedded data snapshot. A string successor renders single-quoted; a
4390/// missing successor renders as bare `None` (Python `repr`).
4391fn runtime_deprecation_message(
4392    lifecycle: &crate::store::LifecycleStore,
4393    runtime: &str,
4394    band: RuntimeLifecycleBand,
4395) -> String {
4396    let Some(dates) = lifecycle.runtime_lifecycle(runtime) else {
4397        return format!("Runtime '{}' is deprecated", runtime);
4398    };
4399    let successor = match &dates.successor {
4400        Some(s) => format!("'{}'", s),
4401        None => "None".to_string(),
4402    };
4403    let (creation_status, update_status) = match band {
4404        RuntimeLifecycleBand::Deprecated => ("will be disabled", "will be disabled"),
4405        RuntimeLifecycleBand::CreateBlocked => ("was disabled", "will be disabled"),
4406        RuntimeLifecycleBand::EndOfLife => ("was disabled", "were disabled"),
4407    };
4408    format!(
4409        "Runtime '{}' was deprecated on '{}'. Creation {} on '{}', and updates {} on '{}'. Please consider updating to {}",
4410        runtime, dates.deprecated, creation_status, dates.create_block, update_status, dates.update_block, successor
4411    )
4412}
4413
4414fn validate_extensions(
4415    out: &mut Vec<Diagnostic>,
4416    store: &CompiledSchemaStore,
4417    model: &Arc<SemanticModel>,
4418    rid: &str,
4419    res: &ResolvedResource,
4420) {
4421    let Some(exts) = store.extensions().get(&res.resource_type) else {
4422        return;
4423    };
4424    for ext in exts {
4425        if ext.get("cfnGather").is_some() {
4426            validate_cfn_gather(out, model, rid, res, ext);
4427        } else if ext.get("if").is_some() {
4428            validate_extension_if_then_else(out, model, rid, res, ext);
4429        } else if let Some(all_of) = ext.get("allOf").and_then(|v| v.as_array()) {
4430            for sub_ext in all_of {
4431                if sub_ext.get("if").is_some() {
4432                    validate_extension_if_then_else(out, model, rid, res, sub_ext);
4433                }
4434            }
4435        }
4436    }
4437}
4438
4439fn validate_cfn_gather(
4440    out: &mut Vec<Diagnostic>,
4441    model: &Arc<SemanticModel>,
4442    rid: &str,
4443    res: &ResolvedResource,
4444    ext: &serde_json::Value,
4445) {
4446    let Some(gather_def) = ext.get("cfnGather") else {
4447        return;
4448    };
4449    let Some(gather_slots) = gather_def.get("gather").and_then(|v| v.as_object()) else {
4450        return;
4451    };
4452    let Some(schema) = gather_def.get("schema") else {
4453        return;
4454    };
4455
4456    let mut context = serde_json::Map::new();
4457    for (slot_name, slot_def) in gather_slots {
4458        let slot_obj = match slot_def.as_object() {
4459            Some(o) => o,
4460            None => continue,
4461        };
4462        let properties = slot_obj.get("properties").and_then(|v| v.as_object());
4463        let reference_path = slot_obj.get("reference").and_then(|v| v.as_str());
4464
4465        let target_rid = if let Some(ref_path) = reference_path {
4466            // A reference may be a nested JSON pointer (e.g.
4467            // /RedrivePolicy/deadLetterTargetArn); convert it to the dotted
4468            // property path follow_ref expects.
4469            let prop_key = ref_path.trim_start_matches('/').replace('/', ".");
4470            model.follow_ref(rid, &format!("Properties.{}", prop_key)).map(String::from)
4471        } else {
4472            Some(rid.to_string())
4473        };
4474
4475        let Some(target) = target_rid else { continue };
4476
4477        if let Some(filter) = slot_obj.get("filter").and_then(|v| v.as_object())
4478            && let Some(expected_type) = filter.get("type").and_then(|v| v.as_str())
4479        {
4480            let actual_type = model.resources.get(&target).map(|r| r.resource_type.as_str());
4481            if actual_type != Some(expected_type) {
4482                continue;
4483            }
4484        }
4485
4486        let mut slot_values = serde_json::Map::new();
4487        if let Some(props) = properties {
4488            for (prop_name, prop_def) in props {
4489                // A gather property spec is either a bare JSON-pointer string
4490                // ("/RestApiId") or an object ({"path": "/FifoQueue", "default":
4491                // false}). Both forms appear in the extension data; reading only
4492                // the object form silently drops the value for the string form
4493                // and makes the whole cross-resource check a no-op.
4494                let (path, default_val) = match prop_def {
4495                    serde_json::Value::String(p) => (Some(p.as_str()), None),
4496                    _ => (prop_def.get("path").and_then(|v| v.as_str()), prop_def.get("default").cloned()),
4497                };
4498                let resolved_path = path.map(|p| format!("Properties.{}", p.trim_start_matches('/').replace('/', ".")));
4499
4500                let resolved = resolved_path
4501                    .as_ref()
4502                    .and_then(|p| model.resolve_scenarios_json(&target, p).into_iter().next().map(|(v, _)| v));
4503                let value = resolved.or(default_val).unwrap_or(serde_json::Value::Null);
4504                slot_values.insert(prop_name.clone(), value);
4505            }
4506        }
4507        context.insert(slot_name.clone(), serde_json::Value::Object(slot_values));
4508    }
4509
4510    let context_val = serde_json::Value::Object(context);
4511
4512    let resolved_schema = resolve_data_in_schema(schema, &context_val);
4513    evaluate_gather_schema(out, model, rid, res, &resolved_schema, &context_val);
4514}
4515
4516/// Whether an extension-required property is already reported by a dedicated
4517/// native rule under its own specific ID, so the generic F3003 must be
4518/// suppressed to avoid double-reporting. Keyed on `(resource type, required
4519/// property)`:
4520/// - S3 `AccessControl`→`OwnershipControls` is covered by the dedicated S3
4521///   access-control rule.
4522/// - ELBv2 Listener HTTPS/TLS→`Certificates` is covered by the dedicated
4523///   listener-certificate rule (E3676), which fires on the same trigger.
4524/// - RDS DBInstance `BackupRetentionPeriod` is a retention-period advisory the
4525///   dedicated retention rule (I3013) already emits; the extension carrying a
4526///   `then.required` for it must not additionally raise a Fatal F3003 (a
4527///   missing retention period is informational, never a required-property
4528///   error).
4529fn extension_required_covered_by_dedicated_rule(resource_type: &str, prop_name: &str) -> bool {
4530    matches!(
4531        (resource_type, prop_name),
4532        ("AWS::S3::Bucket", "OwnershipControls")
4533            | ("AWS::ElasticLoadBalancingV2::Listener", "Certificates")
4534            | ("AWS::RDS::DBInstance", "BackupRetentionPeriod")
4535    )
4536}
4537
4538fn validate_extension_if_then_else(
4539    out: &mut Vec<Diagnostic>,
4540    model: &Arc<SemanticModel>,
4541    rid: &str,
4542    res: &ResolvedResource,
4543    ext: &serde_json::Value,
4544) {
4545    let Some(if_schema) = ext.get("if") else {
4546        return;
4547    };
4548    let if_matches = extension_condition_matches(if_schema, model, rid);
4549    let branch = if if_matches { ext.get("then") } else { ext.get("else") };
4550    let Some(branch_schema) = branch else { return };
4551
4552    if let Some(required) = branch_schema.get("required").and_then(|v| v.as_array()) {
4553        for req in required {
4554            if let Some(prop_name) = req.as_str()
4555                && !res.properties.contains_key(prop_name)
4556            {
4557                // Some extensions express a requirement that a dedicated rule
4558                // already reports under its own specific ID (e.g. the S3
4559                // AccessControl→OwnershipControls extension is the dedicated S3
4560                // access-control rule). Emitting the generic F3003 on top of that
4561                // dedicated diagnostic would be a double-report, so skip it.
4562                if extension_required_covered_by_dedicated_rule(&res.resource_type, prop_name) {
4563                    continue;
4564                }
4565                // Dedup: compiled base schema's if_then_else may already have
4566                // emitted a required-property diagnostic for the same required property (the
4567                // extension schemas sometimes mirror the base schema's conditional
4568                // requirements). Skip to avoid double-reporting.
4569                let already_reported = out.iter().any(|d| {
4570                    d.rule_id == "F3003"
4571                        && d.resource_logical_id() == Some(rid)
4572                        && d.message.contains(&format!("'{}' is a required property", prop_name))
4573                });
4574                if already_reported {
4575                    continue;
4576                }
4577                out.push(build_diagnostic(
4578                    "F3003",
4579                    &format!("'{}' is a required property (from extension)", prop_name),
4580                    model,
4581                    rid,
4582                    KEY_PROPERTIES,
4583                    Some(&format!("Add '{}'", prop_name)),
4584                ));
4585            }
4586        }
4587    }
4588
4589    if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4590        for (prop_name, constraint) in props {
4591            if constraint == &serde_json::Value::Bool(false) && res.properties.contains_key(prop_name) {
4592                // Extension marks the property as non-applicable in this configuration.
4593                // CloudFormation does not reject such properties - it ignores them.
4594                // Emit as Info so the finding is surfaced but does not block deployment
4595                // or cause `good/` fixtures to fail the no-errors contract.
4596                out.push(build_diagnostic(
4597                    "I9002",
4598                    &format!("'{}' is ignored in this configuration (from extension)", prop_name),
4599                    model,
4600                    rid,
4601                    &format!("Properties.{}", prop_name),
4602                    None,
4603                ));
4604            }
4605        }
4606    }
4607
4608    if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4609        for (prop_name, constraint) in props {
4610            let constraint_obj = match constraint.as_object() {
4611                Some(o) => o,
4612                None => continue,
4613            };
4614            let Some(enum_vals) = constraint_obj.get("enum").and_then(|v| v.as_array()) else {
4615                continue;
4616            };
4617            let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", prop_name));
4618            for (val, conds) in &scenarios {
4619                if !is_satisfiable(model, conds) {
4620                    continue;
4621                }
4622                // NOTE: these extension enums use per-enum case
4623                // sensitivity - case-insensitive for engine names
4624                // (Engine: "MySQL" is accepted against "mysql") but case-sensitive
4625                // for others (ReplicaMode: "Mounted" is rejected against
4626                // "mounted"). Distinguishing them requires the per-rule mapping
4627                // (tracked with the extension→specific-ID work); until then keep
4628                // the case-insensitive fallback, since a false negative on
4629                // ReplicaMode is preferable to false positives on valid engines.
4630                let matches_enum = enum_vals.iter().any(|e| {
4631                    scalar_eq(e, val) || e.as_str().zip(val.as_str()).is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))
4632                });
4633                if !matches_enum {
4634                    let allowed = render_str_list(enum_vals.iter().filter_map(|v| v.as_str()));
4635                    out.push(build_diagnostic(
4636                        "E9006",
4637                        &format!("'{}' is not one of {}", coerce_to_string(val).unwrap_or_default(), allowed),
4638                        model,
4639                        rid,
4640                        &format!("Properties.{}", prop_name),
4641                        None,
4642                    ));
4643                }
4644            }
4645        }
4646    }
4647}
4648
4649/// Check whether a resolved JSON value satisfies a single JSON Schema constraint object
4650/// containing `enum`, `const`, or `pattern`.  Returns `true` when no recognised keyword
4651/// is present (open constraint).
4652fn match_constraint_value(constraint: &serde_json::Map<String, serde_json::Value>, val: &serde_json::Value) -> bool {
4653    if let Some(enum_vals) = constraint.get("enum").and_then(|v| v.as_array()) {
4654        return enum_vals.iter().any(|e| scalar_eq(e, val));
4655    }
4656    if let Some(cv) = constraint.get("const") {
4657        return scalar_eq(val, cv);
4658    }
4659    if let Some(pat) = constraint.get("pattern").and_then(|v| v.as_str()) {
4660        return val.as_str().and_then(|s| compile_pattern(pat).map(|re| re.is_match(s))).unwrap_or(false);
4661    }
4662    true
4663}
4664
4665fn extension_condition_matches(if_schema: &serde_json::Value, model: &Arc<SemanticModel>, rid: &str) -> bool {
4666    let Some(obj) = if_schema.as_object() else {
4667        return false;
4668    };
4669
4670    if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4671        for req in required {
4672            if let Some(name) = req.as_str() {
4673                let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", name));
4674                if scenarios.is_empty() {
4675                    return false;
4676                }
4677            }
4678        }
4679    }
4680
4681    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4682        for (prop_name, constraint) in props {
4683            let prop_path = format!("Properties.{}", prop_name);
4684            let scenarios = model.resolve_scenarios_json(rid, &prop_path);
4685            // A property schema of `false` means the property must be ABSENT for
4686            // the condition to hold (JSON Schema: `false` rejects any value). So
4687            // the `if` matches only when the property resolves to nothing in
4688            // every satisfiable scenario; if it is present anywhere, the
4689            // condition fails. (e.g. RDS read-replica: SourceDBInstanceIdentifier
4690            // present ⇒ the BackupRetentionPeriod requirement does not apply.)
4691            if constraint == &serde_json::Value::Bool(false) {
4692                let present = scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4693                if present {
4694                    return false;
4695                }
4696                continue;
4697            }
4698            if scenarios.is_empty() {
4699                return false;
4700            }
4701            let constraint_obj = match constraint.as_object() {
4702                Some(o) => o,
4703                None => continue,
4704            };
4705            // Check nested required sub-properties within the constraint
4706            if let Some(nested_required) = constraint_obj.get("required").and_then(|v| v.as_array()) {
4707                for sub_req in nested_required {
4708                    if let Some(sub_name) = sub_req.as_str() {
4709                        let sub_path = format!("{}.{}", prop_path, sub_name);
4710                        let sub_scenarios = model.resolve_scenarios_json(rid, &sub_path);
4711                        let sub_exists = sub_scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4712                        if !sub_exists {
4713                            return false;
4714                        }
4715                    }
4716                }
4717            }
4718            let any_match = scenarios.iter().any(|(val, conds)| {
4719                if !is_satisfiable(model, conds) {
4720                    return false;
4721                }
4722                if let Some(not_schema) = constraint_obj.get("not").and_then(|v| v.as_object()) {
4723                    let inner_matches = match_constraint_value(not_schema, val);
4724                    return !inner_matches;
4725                }
4726                match_constraint_value(constraint_obj, val)
4727            });
4728            if !any_match {
4729                return false;
4730            }
4731        }
4732    }
4733    true
4734}
4735
4736fn resolve_data_in_schema(schema: &serde_json::Value, context: &serde_json::Value) -> serde_json::Value {
4737    match schema {
4738        serde_json::Value::Object(obj) => {
4739            if let Some(lookup) = obj.get("$lookup").and_then(|v| v.as_object()) {
4740                let key = lookup
4741                    .get("key")
4742                    .map(|k| resolve_data_in_schema(k, context))
4743                    .and_then(|v| v.as_str().map(String::from));
4744                let map = lookup.get("map").and_then(|v| v.as_object());
4745                if let (Some(k), Some(m)) = (key, map) {
4746                    return m.get(&k).cloned().unwrap_or(serde_json::Value::Null);
4747                }
4748                return serde_json::Value::Null;
4749            }
4750            if let Some(pointer) = obj.get("$data").and_then(|v| v.as_str()) {
4751                return resolve_json_pointer(context, pointer);
4752            }
4753            let mut result = serde_json::Map::new();
4754            for (k, v) in obj {
4755                result.insert(k.clone(), resolve_data_in_schema(v, context));
4756            }
4757            serde_json::Value::Object(result)
4758        }
4759        serde_json::Value::Array(arr) => {
4760            serde_json::Value::Array(arr.iter().map(|v| resolve_data_in_schema(v, context)).collect())
4761        }
4762        other => other.clone(),
4763    }
4764}
4765
4766fn resolve_json_pointer(root: &serde_json::Value, pointer: &str) -> serde_json::Value {
4767    let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
4768    let mut current = root;
4769    for part in &parts {
4770        if part.is_empty() {
4771            continue;
4772        }
4773        current = match current {
4774            serde_json::Value::Object(m) => match m.get(*part) {
4775                Some(v) => v,
4776                None => return serde_json::Value::Null,
4777            },
4778            serde_json::Value::Array(a) => match part.parse::<usize>() {
4779                Ok(idx) => match a.get(idx) {
4780                    Some(v) => v,
4781                    None => return serde_json::Value::Null,
4782                },
4783                Err(_) => return serde_json::Value::Null,
4784            },
4785            _ => return serde_json::Value::Null,
4786        };
4787    }
4788    current.clone()
4789}
4790
4791fn evaluate_gather_schema(
4792    out: &mut Vec<Diagnostic>,
4793    model: &Arc<SemanticModel>,
4794    rid: &str,
4795    _res: &ResolvedResource,
4796    schema: &serde_json::Value,
4797    context: &serde_json::Value,
4798) {
4799    let Some(obj) = schema.as_object() else {
4800        return;
4801    };
4802    // NOTE: this generic gather path only surfaces top-level const mismatches as
4803    // E3030. These cross-resource constraints belong under specific dedicated
4804    // rule IDs (E3699, E3707, E3709, …) and dedicated native rules already cover
4805    // the reachable cases (E3502, E3707, E3698, …). Broadening this path to also
4806    // unwrap `cfnContext` or evaluate bare `properties` schemas made it emit
4807    // generic E3030 duplicates alongside those dedicated rules; keep it scoped to
4808    // an explicit if/then/else so it does not double-report. Emitting the
4809    // per-resource IDs is tracked as a dedicated follow-up.
4810    if let Some(if_val) = obj.get("if") {
4811        let matches = gather_condition_matches(if_val, context);
4812        let branch = if matches { obj.get("then") } else { obj.get("else") };
4813        if let Some(branch_val) = branch {
4814            evaluate_gather_constraints(out, model, rid, branch_val, context);
4815        }
4816    }
4817}
4818
4819fn gather_condition_matches(condition: &serde_json::Value, context: &serde_json::Value) -> bool {
4820    let Some(obj) = condition.as_object() else {
4821        return false;
4822    };
4823
4824    if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4825        for req in required {
4826            if let Some(name) = req.as_str() {
4827                let val = resolve_json_pointer(context, &format!("/{}", name));
4828                if val.is_null() {
4829                    return false;
4830                }
4831            }
4832        }
4833    }
4834
4835    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4836        for (prop_name, constraint) in props {
4837            let actual = resolve_json_pointer(context, &format!("/{}", prop_name));
4838            if actual.is_null() {
4839                return false;
4840            }
4841            if !gather_prop_matches(&actual, constraint) {
4842                return false;
4843            }
4844        }
4845    }
4846    true
4847}
4848
4849fn gather_prop_matches(actual: &serde_json::Value, constraint: &serde_json::Value) -> bool {
4850    let Some(obj) = constraint.as_object() else {
4851        return true;
4852    };
4853
4854    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4855        for (k, v) in props {
4856            let child = match actual.get(k) {
4857                Some(c) => c,
4858                None => return false,
4859            };
4860            if !gather_prop_matches(child, v) {
4861                return false;
4862            }
4863        }
4864    }
4865    if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4866        for req in required {
4867            if let Some(name) = req.as_str()
4868                && actual.get(name).is_none()
4869            {
4870                return false;
4871            }
4872        }
4873    }
4874    if let Some(cv) = obj.get("const") {
4875        return scalar_eq(actual, cv);
4876    }
4877    if let Some(enum_vals) = obj.get("enum").and_then(|v| v.as_array()) {
4878        return enum_vals.iter().any(|e| e == actual);
4879    }
4880    true
4881}
4882
4883fn evaluate_gather_constraints(
4884    out: &mut Vec<Diagnostic>,
4885    model: &Arc<SemanticModel>,
4886    rid: &str,
4887    schema: &serde_json::Value,
4888    context: &serde_json::Value,
4889) {
4890    let Some(obj) = schema.as_object() else {
4891        return;
4892    };
4893    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4894        for (slot_name, slot_constraints) in props {
4895            let slot_val = resolve_json_pointer(context, &format!("/{}", slot_name));
4896            check_gather_property_constraints(out, model, rid, slot_name, &slot_val, slot_constraints);
4897        }
4898    }
4899}
4900
4901fn check_gather_property_constraints(
4902    out: &mut Vec<Diagnostic>,
4903    model: &Arc<SemanticModel>,
4904    rid: &str,
4905    slot_name: &str,
4906    actual: &serde_json::Value,
4907    constraint: &serde_json::Value,
4908) {
4909    let Some(obj) = constraint.as_object() else {
4910        return;
4911    };
4912    if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4913        for (prop_name, prop_constraint) in props {
4914            let prop_val = actual.get(prop_name).unwrap_or(&serde_json::Value::Null);
4915            let pc = match prop_constraint.as_object() {
4916                Some(o) => o,
4917                None => continue,
4918            };
4919            if let Some(cv) = pc.get("const")
4920                && !cv.is_null()
4921                && !scalar_eq(prop_val, cv)
4922            {
4923                out.push(build_diagnostic(
4924                    "E3030",
4925                    &format!(
4926                        "Cross-resource constraint: {}.{} is {} but must be {} (from referenced resource)",
4927                        slot_name,
4928                        prop_name,
4929                        format_value(prop_val),
4930                        format_value(cv)
4931                    ),
4932                    model,
4933                    rid,
4934                    KEY_PROPERTIES,
4935                    None,
4936                ));
4937            }
4938            // Numeric cross-resource bounds (e.g. SQS VisibilityTimeout vs Lambda
4939            // Timeout, ESM BatchSize for FIFO queues) are reported by the dedicated
4940            // cross-resource rules at their specific locations. Emitting a generic
4941            // schema-bounds diagnostic here as well would double-report the same
4942            // issue, so the const equality check above is the only gather
4943            // constraint surfaced directly.
4944        }
4945    }
4946}
4947
4948fn describe_resolution(m: &Arc<SemanticModel>, rid: &str, prop_path: &str) -> Option<String> {
4949    let top_level_path;
4950    let (val, value_path) = match m.resolve(rid, prop_path) {
4951        Some(val) => (val, prop_path),
4952        None => {
4953            let stripped = prop_path.strip_prefix("Properties.")?;
4954            let top = stripped.split('.').next()?;
4955            let val = m.resources.get(rid)?.properties.get(top)?;
4956            top_level_path = format!("Properties.{}", top);
4957            (val, top_level_path.as_str())
4958        }
4959    };
4960    match val {
4961        ResolvedValue::Reference { target, kind } => {
4962            let kind_str = match kind {
4963                RefKind::Ref => "Ref",
4964                RefKind::GetAtt { attr: a } => {
4965                    return Some(format!("GetAtt {}.{}", target, a));
4966                }
4967                RefKind::Sub { var: _ } => "Sub",
4968                RefKind::DependsOn => return None,
4969            };
4970            Some(format!("{} to '{}'", kind_str, target))
4971        }
4972        ResolvedValue::Enum { variants: _ } => Some("parameter with AllowedValues".into()),
4973        ResolvedValue::Conditional { condition: cond, if_true: _, if_false: _ } => {
4974            Some(format!("Fn::If on condition '{}'", cond))
4975        }
4976        ResolvedValue::Dynamic { reason: desc } => Some(format!("dynamic ({})", desc)),
4977        // A typed value that is unknown until deployment is only a parameter when
4978        // the model says a parameter produced it. A cross-stack import and a
4979        // dynamic reference are just as unknown and carry the same declared type,
4980        // so naming them a parameter would report something the template does not
4981        // contain - and would print the explanation where a name belongs.
4982        ResolvedValue::TypedDynamic { reason: desc, param_type: typ } => match m.parameter_name_at(rid, value_path) {
4983            Some(parameter) => Some(format!("parameter '{}' (type {})", parameter, typ)),
4984            None => Some(format!("dynamic ({})", desc)),
4985        },
4986        _ => None,
4987    }
4988}
4989
4990fn build_diagnostic(
4991    rule_id: &str,
4992    msg: &str,
4993    m: &Arc<SemanticModel>,
4994    rid: &str,
4995    prop: &str,
4996    fix: Option<&str>,
4997) -> Diagnostic {
4998    build_diagnostic_conditional(rule_id, msg, m, rid, prop, fix, None)
4999}
5000
5001fn build_diagnostic_conditional(
5002    rule_id: &str,
5003    msg: &str,
5004    m: &Arc<SemanticModel>,
5005    rid: &str,
5006    prop: &str,
5007    fix: Option<&str>,
5008    conds: Option<HashMap<String, bool>>,
5009) -> Diagnostic {
5010    let span = if rid.is_empty() { resolve_section_span(rule_id, m.as_ref()) } else { m.resource_span(rid, prop) };
5011    RegisteredDiagnostic::new(rule_id, msg)
5012        .resource(rid, m.resources.get(rid).map(|r| r.resource_type.clone()))
5013        .property_path(prop)
5014        .location(span)
5015        .suggested_fix(fix)
5016        .condition_scenario(conds)
5017        .phase(Phase::Schema)
5018        .build()
5019}
5020
5021#[cfg(test)]
5022mod tests {
5023    use super::*;
5024    use serde_json::json;
5025
5026    fn current_scenario_filter() -> Option<HashMap<String, bool>> {
5027        SCENARIO_FILTER.with(|filter| filter.borrow().clone())
5028    }
5029
5030    #[test]
5031    fn scenario_filter_scope_restores_outer_assignment_after_unwind() {
5032        reset_scenario_filter();
5033        let _outer_scope = ScenarioFilterScope::enter(HashMap::from([("Outer".to_string(), true)]));
5034        let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5035            let _inner_scope = ScenarioFilterScope::enter(HashMap::from([("Inner".to_string(), false)]));
5036            assert_eq!(current_scenario_filter(), Some(HashMap::from([("Inner".to_string(), false)])));
5037            panic!("exercise unwind cleanup");
5038        }));
5039        assert!(unwind.is_err());
5040        assert_eq!(current_scenario_filter(), Some(HashMap::from([("Outer".to_string(), true)])));
5041    }
5042
5043    #[test]
5044    fn validation_entry_clears_a_stale_scenario_filter() {
5045        SCENARIO_FILTER.with(|filter| {
5046            filter.replace(Some(HashMap::from([("PreviousTemplate".to_string(), true)])));
5047        });
5048        let diagnostics = diagnostics_for_mode_value("managed");
5049        assert!(diagnostics.is_empty(), "the new validation must not inherit a prior scenario assignment");
5050        assert!(current_scenario_filter().is_none(), "validation leaves no scenario filter behind");
5051    }
5052
5053    #[test]
5054    fn levenshtein_distance_identical_strings() {
5055        assert_eq!(levenshtein_distance("abc", "abc"), 0);
5056    }
5057
5058    #[test]
5059    fn levenshtein_distance_empty_strings() {
5060        assert_eq!(levenshtein_distance("", ""), 0);
5061    }
5062
5063    #[test]
5064    fn levenshtein_distance_one_empty() {
5065        assert_eq!(levenshtein_distance("abc", ""), 3);
5066        assert_eq!(levenshtein_distance("", "xyz"), 3);
5067    }
5068
5069    #[test]
5070    fn levenshtein_distance_single_substitution() {
5071        assert_eq!(levenshtein_distance("cat", "bat"), 1);
5072    }
5073
5074    #[test]
5075    fn levenshtein_distance_single_insertion() {
5076        assert_eq!(levenshtein_distance("abc", "abcd"), 1);
5077    }
5078
5079    #[test]
5080    fn levenshtein_distance_single_deletion() {
5081        assert_eq!(levenshtein_distance("abcd", "abc"), 1);
5082    }
5083
5084    #[test]
5085    fn levenshtein_distance_transposition() {
5086        assert_eq!(levenshtein_distance("ab", "ba"), 2);
5087    }
5088
5089    #[test]
5090    fn levenshtein_distance_completely_different() {
5091        assert_eq!(levenshtein_distance("abc", "xyz"), 3);
5092    }
5093
5094    #[test]
5095    fn find_similar_exact_match() {
5096        let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5097        assert_eq!(find_similar("BucketName", &known), Some("BucketName"));
5098    }
5099
5100    #[test]
5101    fn find_similar_close_typo() {
5102        let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5103        let result = find_similar("Bucketame", &known);
5104        assert_eq!(result, Some("BucketName"));
5105    }
5106
5107    #[test]
5108    fn find_similar_no_match() {
5109        let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5110        assert_eq!(find_similar("CompletelyDifferent", &known), None, "dissimilar name should not match");
5111    }
5112
5113    #[test]
5114    fn find_similar_case_insensitive() {
5115        let known: HashSet<&str> = ["BucketName"].into_iter().collect();
5116        assert_eq!(find_similar("bucketname", &known), Some("BucketName"));
5117    }
5118
5119    #[test]
5120    fn type_matches_string() {
5121        assert!(type_matches(&json!("hello"), &PropType::Single("string".into())));
5122        assert!(!type_matches(&json!(42), &PropType::Single("string".into())));
5123    }
5124
5125    #[test]
5126    fn type_matches_integer() {
5127        assert!(type_matches(&json!(42), &PropType::Single("integer".into())));
5128        assert!(type_matches(&json!(42.0), &PropType::Single("integer".into())));
5129        assert!(!type_matches(&json!(42.5), &PropType::Single("integer".into())));
5130        assert!(!type_matches(&json!("42"), &PropType::Single("integer".into())));
5131    }
5132
5133    #[test]
5134    fn type_matches_number() {
5135        assert!(type_matches(&json!(42), &PropType::Single("number".into())));
5136        assert!(type_matches(&json!(3.14), &PropType::Single("number".into())));
5137        assert!(!type_matches(&json!("3.14"), &PropType::Single("number".into())));
5138    }
5139
5140    #[test]
5141    fn type_matches_boolean() {
5142        assert!(type_matches(&json!(true), &PropType::Single("boolean".into())));
5143        assert!(!type_matches(&json!("true"), &PropType::Single("boolean".into())));
5144    }
5145
5146    #[test]
5147    fn type_matches_array() {
5148        assert!(type_matches(&json!([1, 2]), &PropType::Single("array".into())));
5149        assert!(!type_matches(&json!("[]"), &PropType::Single("array".into())));
5150    }
5151
5152    #[test]
5153    fn type_matches_object() {
5154        assert!(type_matches(&json!({"a": 1}), &PropType::Single("object".into())));
5155        assert!(!type_matches(&json!("{}"), &PropType::Single("object".into())));
5156    }
5157
5158    #[test]
5159    fn type_matches_null() {
5160        assert!(type_matches(&json!(null), &PropType::Single("null".into())));
5161        assert!(!type_matches(&json!("null"), &PropType::Single("null".into())));
5162    }
5163
5164    #[test]
5165    fn type_matches_multi_with_null() {
5166        let pt = PropType::Multi(vec!["string".into(), "null".into()]);
5167        assert!(type_matches(&json!("hello"), &pt));
5168        assert!(type_matches(&json!(null), &pt));
5169        assert!(!type_matches(&json!(42), &pt));
5170    }
5171
5172    #[test]
5173    fn type_matches_unknown_type_always_true() {
5174        assert!(type_matches(&json!("anything"), &PropType::Single("custom_type".into())));
5175    }
5176
5177    #[test]
5178    fn enum_matches_exact() {
5179        assert!(enum_matches(&json!("a"), &[json!("a"), json!("b")]));
5180        assert!(!enum_matches(&json!("c"), &[json!("a"), json!("b")]));
5181    }
5182
5183    #[test]
5184    fn enum_matches_coerced_number_to_string() {
5185        assert!(enum_matches(&json!("42"), &[json!(42)]));
5186    }
5187
5188    #[test]
5189    fn enum_matches_empty_allowed() {
5190        assert!(!enum_matches(&json!("a"), &[]));
5191    }
5192
5193    #[test]
5194    fn enum_matches_case_insensitive_any_casing() {
5195        let allowed = [json!("managed"), json!("unmanaged")];
5196        assert!(enum_matches_case_insensitive(&json!("managed"), &allowed));
5197        assert!(enum_matches_case_insensitive(&json!("MANAGED"), &allowed));
5198        assert!(enum_matches_case_insensitive(&json!("Unmanaged"), &allowed));
5199    }
5200
5201    #[test]
5202    fn enum_matches_case_insensitive_rejects_unlisted_value() {
5203        let allowed = [json!("managed"), json!("unmanaged")];
5204        assert!(!enum_matches_case_insensitive(&json!("BOGUS"), &allowed));
5205    }
5206
5207    #[test]
5208    fn enum_matches_case_insensitive_non_string_uses_exact_comparison() {
5209        assert!(enum_matches_case_insensitive(&json!(42), &[json!(42)]));
5210        assert!(!enum_matches_case_insensitive(&json!(43), &[json!(42)]));
5211    }
5212
5213    fn store_with_case_insensitive_mode_enum() -> CompiledSchemaStore {
5214        let mode =
5215            PropSchema { enum_case_insensitive: vec![json!("managed"), json!("unmanaged")], ..Default::default() };
5216        let mut properties = HashMap::new();
5217        properties.insert("Mode".to_string(), mode);
5218        let mut store = CompiledSchemaStore::new();
5219        store.insert_schema(CompiledSchema {
5220            type_name: "AWS::Fake::Type".to_string(),
5221            properties,
5222            ..Default::default()
5223        });
5224        store
5225    }
5226
5227    fn diagnostics_for_mode_value(mode_value: &str) -> Vec<Diagnostic> {
5228        let template = format!(
5229            r#"{{"Resources":{{"Widget":{{"Type":"AWS::Fake::Type","Properties":{{"Mode":"{}"}}}}}}}}"#,
5230            mode_value
5231        );
5232        let model = Arc::new(SemanticModel::from_bytes(template.as_bytes()).expect("template parses"));
5233        validate_all_resources(&store_with_case_insensitive_mode_enum(), &model, None)
5234    }
5235
5236    #[test]
5237    fn case_insensitive_enum_accepts_any_casing_of_allowed_value() {
5238        for accepted in ["managed", "MANAGED", "Unmanaged"] {
5239            let w3030: Vec<Diagnostic> =
5240                diagnostics_for_mode_value(accepted).into_iter().filter(|d| d.rule_id == "W3030").collect();
5241            assert!(w3030.is_empty(), "'{accepted}' must not fire W3030, got: {w3030:?}");
5242        }
5243    }
5244
5245    #[test]
5246    fn case_insensitive_enum_flags_unlisted_value_with_marked_message() {
5247        let diags = diagnostics_for_mode_value("BOGUS");
5248        let w3030 = diags.iter().find(|d| d.rule_id == "W3030").expect("W3030 fires for a value not in the enum");
5249        assert_eq!(w3030.severity, rules::Severity::Warn);
5250        assert_eq!(w3030.property_path.as_deref(), Some("Properties.Mode"));
5251        assert_eq!(w3030.message, "'BOGUS' is not one of ['managed', 'unmanaged'] (case-insensitive)");
5252    }
5253
5254    #[test]
5255    fn format_value_wraps_strings_in_quotes() {
5256        assert_eq!(format_value(&json!("hello")), "'hello'");
5257    }
5258
5259    #[test]
5260    fn format_value_renders_non_strings_directly() {
5261        assert_eq!(format_value(&json!(42)), "42");
5262        assert_eq!(format_value(&json!(true)), "true");
5263        assert_eq!(format_value(&json!(null)), "null");
5264    }
5265
5266    #[test]
5267    fn single_type_compatible_same_type() {
5268        assert!(single_type_compatible("string", "string"));
5269        assert!(single_type_compatible("integer", "integer"));
5270    }
5271
5272    #[test]
5273    fn single_type_compatible_integer_number_interchangeable() {
5274        assert!(single_type_compatible("integer", "number"));
5275        assert!(single_type_compatible("number", "integer"));
5276    }
5277
5278    #[test]
5279    fn single_type_compatible_string_to_number_coercion() {
5280        assert!(single_type_compatible("string", "number"));
5281        assert!(single_type_compatible("string", "integer"));
5282    }
5283
5284    #[test]
5285    fn single_type_compatible_number_to_string_coercion() {
5286        assert!(single_type_compatible("number", "string"));
5287        assert!(single_type_compatible("integer", "string"));
5288        assert!(single_type_compatible("boolean", "string"));
5289    }
5290
5291    #[test]
5292    fn single_type_compatible_null_always_compatible() {
5293        assert!(single_type_compatible("string", "null"));
5294        assert!(single_type_compatible("array", "null"));
5295    }
5296
5297    #[test]
5298    fn single_type_compatible_incompatible() {
5299        assert!(!single_type_compatible("string", "array"));
5300        assert!(!single_type_compatible("array", "object"));
5301        assert!(!single_type_compatible("boolean", "integer"));
5302    }
5303
5304    #[test]
5305    fn param_type_number() {
5306        assert_eq!(cfn_param_type_to_schema_type("Number"), "number");
5307    }
5308
5309    #[test]
5310    fn param_type_string() {
5311        assert_eq!(cfn_param_type_to_schema_type("String"), "string");
5312    }
5313
5314    #[test]
5315    fn param_type_comma_delimited_list() {
5316        assert_eq!(cfn_param_type_to_schema_type("CommaDelimitedList"), "array");
5317    }
5318
5319    #[test]
5320    fn param_type_list_prefix() {
5321        assert_eq!(cfn_param_type_to_schema_type("List<Number>"), "array");
5322        assert_eq!(cfn_param_type_to_schema_type("List<AWS::EC2::Subnet::Id>"), "array");
5323    }
5324
5325    #[test]
5326    fn param_type_ssm_parameter() {
5327        assert_eq!(cfn_param_type_to_schema_type("AWS::SSM::Parameter::Value<String>"), "string");
5328    }
5329
5330    #[test]
5331    fn param_type_unknown_defaults_to_string() {
5332        assert_eq!(cfn_param_type_to_schema_type("AWS::EC2::VPC::Id"), "string");
5333    }
5334
5335    #[test]
5336    fn resolve_json_pointer_root() {
5337        let data = json!({"a": 1});
5338        let result = resolve_json_pointer(&data, "/");
5339        assert_eq!(result, json!({"a": 1}));
5340    }
5341
5342    #[test]
5343    fn resolve_json_pointer_nested() {
5344        let data = json!({"a": {"b": {"c": 42}}});
5345        assert_eq!(resolve_json_pointer(&data, "/a/b/c"), json!(42));
5346    }
5347
5348    #[test]
5349    fn resolve_json_pointer_missing_key() {
5350        let data = json!({"a": 1});
5351        assert_eq!(resolve_json_pointer(&data, "/b"), json!(null));
5352    }
5353
5354    #[test]
5355    fn resolve_json_pointer_array_index() {
5356        let data = json!({"items": [10, 20, 30]});
5357        assert_eq!(resolve_json_pointer(&data, "/items/1"), json!(20));
5358    }
5359
5360    #[test]
5361    fn resolve_json_pointer_array_out_of_bounds() {
5362        let data = json!({"items": [10]});
5363        assert_eq!(resolve_json_pointer(&data, "/items/5"), json!(null));
5364    }
5365
5366    #[test]
5367    fn resolve_json_pointer_non_numeric_index_on_array() {
5368        let data = json!([1, 2, 3]);
5369        assert_eq!(resolve_json_pointer(&data, "/abc"), json!(null));
5370    }
5371
5372    #[test]
5373    fn resolve_json_pointer_scalar_intermediate() {
5374        let data = json!({"a": "string_value"});
5375        assert_eq!(resolve_json_pointer(&data, "/a/b"), json!(null));
5376    }
5377
5378    #[test]
5379    fn resolve_data_simple_reference() {
5380        let schema = json!({"$data": "/source/protocol"});
5381        let context = json!({"source": {"protocol": "tcp"}});
5382        assert_eq!(resolve_data_in_schema(&schema, &context), json!("tcp"));
5383    }
5384
5385    #[test]
5386    fn resolve_data_missing_reference() {
5387        let schema = json!({"$data": "/missing/path"});
5388        let context = json!({"source": {"protocol": "tcp"}});
5389        assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5390    }
5391
5392    #[test]
5393    fn resolve_data_nested_object() {
5394        let schema = json!({
5395            "properties": {
5396                "port": {"const": {"$data": "/source/port"}}
5397            }
5398        });
5399        let context = json!({"source": {"port": 443}});
5400        let resolved = resolve_data_in_schema(&schema, &context);
5401        assert_eq!(resolved["properties"]["port"]["const"], json!(443));
5402    }
5403
5404    #[test]
5405    fn resolve_data_array() {
5406        let schema = json!([{"$data": "/a"}, {"$data": "/b"}]);
5407        let context = json!({"a": 1, "b": 2});
5408        assert_eq!(resolve_data_in_schema(&schema, &context), json!([1, 2]));
5409    }
5410
5411    #[test]
5412    fn resolve_data_non_object_passthrough() {
5413        assert_eq!(resolve_data_in_schema(&json!("plain"), &json!({})), json!("plain"));
5414        assert_eq!(resolve_data_in_schema(&json!(42), &json!({})), json!(42));
5415        assert_eq!(resolve_data_in_schema(&json!(null), &json!({})), json!(null));
5416    }
5417
5418    #[test]
5419    fn resolve_data_lookup() {
5420        let schema = json!({
5421            "$lookup": {
5422                "key": {"$data": "/source/engine"},
5423                "map": {
5424                    "aurora": {"const": 3306},
5425                    "redis": {"const": 6379}
5426                }
5427            }
5428        });
5429        let context = json!({"source": {"engine": "aurora"}});
5430        assert_eq!(resolve_data_in_schema(&schema, &context), json!({"const": 3306}));
5431    }
5432
5433    #[test]
5434    fn resolve_data_lookup_missing_key() {
5435        let schema = json!({
5436            "$lookup": {
5437                "key": {"$data": "/source/engine"},
5438                "map": {"aurora": 3306}
5439            }
5440        });
5441        let context = json!({"source": {"engine": "postgres"}});
5442        assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5443    }
5444
5445    #[test]
5446    fn types_compatible_single() {
5447        assert!(types_compatible("string", &PropType::Single("string".into())));
5448        assert!(!types_compatible("array", &PropType::Single("string".into())));
5449    }
5450
5451    #[test]
5452    fn types_compatible_multi() {
5453        let pt = PropType::Multi(vec!["string".into(), "null".into()]);
5454        assert!(types_compatible("string", &pt));
5455        assert!(types_compatible("integer", &pt));
5456        assert!(!types_compatible("array", &PropType::Multi(vec!["string".into(), "integer".into()])));
5457    }
5458
5459    #[test]
5460    fn single_type_double_and_float_are_number() {
5461        assert!(single_type(&json!(3.14), "double"));
5462        assert!(single_type(&json!(3.14), "float"));
5463        assert!(single_type(&json!(42), "double"));
5464    }
5465
5466    #[test]
5467    fn single_type_integer_rejects_fractional() {
5468        assert!(!single_type(&json!(3.14), "integer"));
5469    }
5470
5471    #[test]
5472    fn single_type_integer_accepts_whole_float() {
5473        assert!(single_type(&json!(5.0), "integer"));
5474    }
5475
5476    #[test]
5477    fn find_prop_schema_direct_lookup() {
5478        let mut props = HashMap::new();
5479        props.insert(
5480            "Name".into(),
5481            PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5482        );
5483        let defs = HashMap::new();
5484        let result = find_prop_schema("Name", &props, &defs).expect("Name should be found");
5485        assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("string"));
5486    }
5487
5488    #[test]
5489    fn find_prop_schema_nested_path() {
5490        let inner = PropSchema { prop_type: Some(PropType::Single("integer".into())), ..Default::default() };
5491        let mut inner_props = HashMap::new();
5492        inner_props.insert("Port".into(), inner);
5493        let outer = PropSchema { properties: inner_props, ..Default::default() };
5494        let mut props = HashMap::new();
5495        props.insert("Config".into(), outer);
5496        let defs = HashMap::new();
5497        let result = find_prop_schema("Config.Port", &props, &defs).expect("Config.Port should be found");
5498        assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("integer"));
5499    }
5500
5501    #[test]
5502    fn find_prop_schema_missing_returns_none() {
5503        let props = HashMap::new();
5504        let defs = HashMap::new();
5505        assert!(find_prop_schema("Missing", &props, &defs).is_none(), "missing prop should return None");
5506    }
5507
5508    #[test]
5509    fn find_prop_schema_resolves_ref() {
5510        let mut props = HashMap::new();
5511        props.insert("Config".into(), PropSchema { ref_name: Some("ConfigDef".into()), ..Default::default() });
5512        let mut defs = HashMap::new();
5513        defs.insert(
5514            "ConfigDef".into(),
5515            PropSchema { prop_type: Some(PropType::Single("object".into())), ..Default::default() },
5516        );
5517        let result = find_prop_schema("Config", &props, &defs).expect("Config should be found");
5518        assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("object"));
5519    }
5520
5521    #[test]
5522    fn find_prop_schema_deep_direct() {
5523        let mut props = HashMap::new();
5524        props.insert(
5525            "Name".into(),
5526            PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5527        );
5528        let schema = CompiledSchema { type_name: "Test".into(), properties: props, ..Default::default() };
5529        assert!(find_prop_schema_deep("Name", &schema).is_some(), "direct property should be found");
5530    }
5531
5532    #[test]
5533    fn find_prop_schema_deep_searches_one_of() {
5534        let mut sub_props = HashMap::new();
5535        sub_props.insert(
5536            "Special".into(),
5537            PropSchema { prop_type: Some(PropType::Single("boolean".into())), ..Default::default() },
5538        );
5539        let schema = CompiledSchema {
5540            type_name: "Test".into(),
5541            one_of: vec![crate::compiled::SubSchema { properties: sub_props, ..Default::default() }],
5542            ..Default::default()
5543        };
5544        let result = find_prop_schema_deep("Special", &schema);
5545        assert!(result.is_some(), "expected to find Special in oneOf sub-schema");
5546        assert_eq!(result.unwrap().prop_type.as_ref().unwrap().primary(), Some("boolean"));
5547    }
5548
5549    #[test]
5550    fn find_prop_schema_deep_searches_if_then_else() {
5551        let mut then_props = HashMap::new();
5552        then_props.insert(
5553            "ConditionalProp".into(),
5554            PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5555        );
5556        let schema = CompiledSchema {
5557            type_name: "Test".into(),
5558            if_then_else: vec![crate::compiled::IfThenElse {
5559                condition: Default::default(),
5560                then_schema: Some(crate::compiled::SubSchema { properties: then_props, ..Default::default() }),
5561                else_schema: None,
5562                enforce_full_branch: false,
5563            }],
5564            ..Default::default()
5565        };
5566        assert!(
5567            find_prop_schema_deep("ConditionalProp", &schema).is_some(),
5568            "conditional property should be found via if-then-else"
5569        );
5570    }
5571
5572    #[test]
5573    fn condition_matches_empty_condition_always_true() {
5574        let cond = ConditionSchema::default();
5575        let keys = vec!["A".into(), "B".into()];
5576        let model = Arc::new(SemanticModel::from_bytes(
5577            b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n  R:\n    Type: AWS::CloudFormation::WaitConditionHandle"
5578        ).unwrap());
5579        let defs = HashMap::new();
5580        assert!(condition_matches(&cond, &keys, &model, "R", &defs));
5581    }
5582
5583    #[test]
5584    fn condition_matches_required_key_missing() {
5585        let cond = ConditionSchema { required: vec!["MissingKey".into()], ..Default::default() };
5586        let keys = vec!["A".into()];
5587        let model = Arc::new(SemanticModel::from_bytes(
5588            b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n  R:\n    Type: AWS::CloudFormation::WaitConditionHandle"
5589        ).unwrap());
5590        let defs = HashMap::new();
5591        assert!(!condition_matches(&cond, &keys, &model, "R", &defs));
5592    }
5593
5594    #[test]
5595    fn schema_value_matches_required_groups_with_null_members_absent() {
5596        let defs = HashMap::new();
5597        let required_or = PropSchema { required_or: vec!["A".into(), "B".into()], ..Default::default() };
5598        assert!(schema_value_matches(&json!({ "A": 1 }), &required_or, &defs, 0));
5599        assert!(!schema_value_matches(&json!({ "A": null }), &required_or, &defs, 0));
5600
5601        let required_xor = PropSchema { required_xor: vec!["A".into(), "B".into()], ..Default::default() };
5602        assert!(schema_value_matches(&json!({ "A": 1, "B": null }), &required_xor, &defs, 0));
5603        assert!(!schema_value_matches(&json!({ "A": 1, "B": 2 }), &required_xor, &defs, 0));
5604        assert!(!schema_value_matches(&json!({ "A": null, "B": null }), &required_xor, &defs, 0));
5605    }
5606
5607    #[test]
5608    fn gather_condition_matches_empty_true() {
5609        let cond = json!({});
5610        let ctx = json!({"a": 1});
5611        assert!(gather_condition_matches(&cond, &ctx));
5612    }
5613
5614    #[test]
5615    fn gather_condition_matches_required_present() {
5616        let cond = json!({"required": ["a"]});
5617        let ctx = json!({"a": 1});
5618        assert!(gather_condition_matches(&cond, &ctx));
5619    }
5620
5621    #[test]
5622    fn gather_condition_matches_required_missing() {
5623        let cond = json!({"required": ["b"]});
5624        let ctx = json!({"a": 1});
5625        assert!(!gather_condition_matches(&cond, &ctx));
5626    }
5627
5628    #[test]
5629    fn gather_prop_matches_const() {
5630        assert!(gather_prop_matches(&json!("tcp"), &json!({"const": "tcp"})));
5631        assert!(!gather_prop_matches(&json!("udp"), &json!({"const": "tcp"})));
5632    }
5633
5634    #[test]
5635    fn gather_prop_matches_enum() {
5636        assert!(gather_prop_matches(&json!("a"), &json!({"enum": ["a", "b"]})));
5637        assert!(!gather_prop_matches(&json!("c"), &json!({"enum": ["a", "b"]})));
5638    }
5639
5640    #[test]
5641    fn gather_prop_matches_nested_properties() {
5642        let actual = json!({"inner": {"key": "val"}});
5643        let constraint = json!({"properties": {"inner": {"properties": {"key": {"const": "val"}}}}});
5644        assert!(gather_prop_matches(&actual, &constraint));
5645    }
5646
5647    fn property_names(prefix: &str, count: usize) -> Vec<String> {
5648        (0..count).map(|index| format!("{prefix}{index}")).collect()
5649    }
5650
5651    #[test]
5652    fn required_property_combinations_accept_exact_cardinality_boundary() {
5653        let branch = SubSchema {
5654            required_or: property_names("Or", 16),
5655            required_xor: property_names("Xor", 16),
5656            ..Default::default()
5657        };
5658        let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
5659        assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
5660        assert!(!curtailed, "exactly 16 × 16 combinations must be retained in full");
5661    }
5662
5663    #[test]
5664    fn required_property_combinations_report_one_over_boundary() {
5665        let branch = SubSchema {
5666            required_or: property_names("Or", 17),
5667            required_xor: property_names("Xor", 16),
5668            ..Default::default()
5669        };
5670        let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
5671        assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
5672        assert!(curtailed, "17 × 16 combinations must visibly hit the explanation limit");
5673    }
5674}