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