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