Skip to main content

schema_validator/
validate.rs

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