Skip to main content

validation_engine/
engine.rs

1#[cfg(test)]
2use data_source::AdditionalSchemaSource;
3use data_source::embedded::{CFN_LINT_VERSION, RESOURCE_SCHEMA_VERSION};
4use diagnostics::{
5    BudgetExhaustionRecord, DetailLevel, Diagnostic, Entity, PerformanceMetrics, Phase, PhaseMetric,
6    RegisteredDiagnostic, RelatedResource, ReportMetadata, ReportStatus, ResourceRef, Summary, ValidationReport,
7    ViolationContext, apply_filters, diagnostic_from_parse_defect, phase_metric, resolve_section_span,
8};
9use rules::{
10    FilterConfig, RuleInfo, RuleMetadataEntry, RuleOrigin, Severity, is_fatal_rule, is_valid_custom_rule_id,
11    rule_number,
12};
13use schema_validator::{
14    OverlayCatalog, SchemaValidationResult, SchemaValidator, SchemaValidatorConfig, build_overlay_catalog,
15};
16use serde::{Deserialize, Serialize};
17use std::any::Any;
18use std::collections::HashMap;
19use std::error;
20use std::fmt;
21use std::panic::{AssertUnwindSafe, catch_unwind};
22use std::sync::Arc;
23use template_model::resolved_value::effective_path_from_scenario_source_path;
24use template_model::{
25    EntityType, JsonValue, ParseConfig, ParseError, ParseResult, PseudoParameterOverrides, SemanticModel, SourceSpan,
26    UNKNOWN_SPAN, entity_identity, is_sam_transform_error_message, region_enums, span_to_option,
27};
28use web_time::Instant;
29
30pub const DIAGNOSTIC_SOURCE_PATH_FIELD: &str = "source_path";
31
32#[derive(Debug)]
33pub enum ValidationError {
34    Parse(ParseError),
35    Engine(String),
36}
37
38impl fmt::Display for ValidationError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            ValidationError::Parse(e) => write!(f, "{}", e),
42            ValidationError::Engine(msg) => write!(f, "{}", msg),
43        }
44    }
45}
46
47impl error::Error for ValidationError {
48    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
49        match self {
50            ValidationError::Parse(e) => Some(e),
51            ValidationError::Engine(_) => None,
52        }
53    }
54}
55
56impl From<ParseError> for ValidationError {
57    fn from(e: ParseError) -> Self {
58        ValidationError::Parse(e)
59    }
60}
61
62impl From<String> for ValidationError {
63    fn from(s: String) -> Self {
64        ValidationError::Engine(s)
65    }
66}
67
68impl From<&str> for ValidationError {
69    fn from(s: &str) -> Self {
70        ValidationError::Engine(s.to_string())
71    }
72}
73
74/// Selects which validation engine evaluates rules.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
77#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
78#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
79pub enum EngineType {
80    #[default]
81    Rego,
82    Cel,
83}
84
85impl EngineType {
86    pub const fn as_str(&self) -> &'static str {
87        match self {
88            EngineType::Rego => "Rego",
89            EngineType::Cel => "CEL",
90        }
91    }
92
93    /// Parses an engine selector, accepting `rego`/`cel` case-insensitively.
94    /// Returns an error describing the valid options rather than panicking, so a
95    /// bad selector becomes a handleable failure instead of a process abort.
96    pub fn parse(raw: &str) -> Result<Self, String> {
97        match raw.to_lowercase().as_str() {
98            "rego" => Ok(EngineType::Rego),
99            "cel" => Ok(EngineType::Cel),
100            other => Err(format!("Unknown engine type '{other}'; expected 'rego' or 'cel'")),
101        }
102    }
103}
104
105impl fmt::Display for EngineType {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.write_str(self.as_str())
108    }
109}
110
111/// A pre-read rule file provided by the caller (custom Rego/CEL or Guard DSL).
112///
113/// `name` identifies the rule source in error messages and logging. For a
114/// file-backed rule this is typically the filesystem path; otherwise it is
115/// whatever label the caller provides.
116///
117/// `content` is the full source text of the rule file.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
120#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
121#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
122pub struct ExternalRuleSource {
123    pub name: String,
124    pub content: String,
125}
126
127#[derive(Default, Clone, Serialize, Deserialize)]
128#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
129#[cfg_attr(feature = "wasm-bindings", tsify(from_wasm_abi))]
130#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
131#[serde(rename_all = "camelCase")]
132pub struct EngineConfig {
133    /// Engine-native custom rules (Rego or CEL depending on engine).
134    #[serde(default)]
135    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
136    pub custom_rules: Vec<ExternalRuleSource>,
137    /// Guard DSL rules as raw source text, usable regardless of the selected engine.
138    #[serde(default)]
139    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
140    pub guard_rules: Vec<ExternalRuleSource>,
141    /// Optional schema validator configuration. A standalone engine derives its
142    /// schema-aware rule metadata from this config. Language APIs also use it to
143    /// construct the schema validator bundled with the engine, so both components
144    /// observe the same additional schemas.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    #[cfg_attr(feature = "wasm-bindings", tsify(optional))]
147    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
148    pub schema_validator_config: Option<SchemaValidatorConfig>,
149}
150
151impl EngineConfig {
152    /// Starts from the default configuration.
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Adds engine-native custom rules (Rego or CEL, depending on the engine).
158    pub fn with_custom_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
159        self.custom_rules.extend(rules);
160        self
161    }
162
163    /// Adds Guard DSL rules, usable regardless of the selected engine.
164    pub fn with_guard_rules(mut self, rules: impl IntoIterator<Item = ExternalRuleSource>) -> Self {
165        self.guard_rules.extend(rules);
166        self
167    }
168
169    /// Sets the nested schema validator configuration. When the engine is built
170    /// standalone via `new(EngineConfig)`, it derives overlay-aware metadata from
171    /// the configured additional schemas.
172    pub fn with_schema_validator_config(mut self, config: SchemaValidatorConfig) -> Self {
173        self.schema_validator_config = Some(config);
174        self
175    }
176
177    /// Builds an [`OverlayCatalog`] from the nested schema validator config's
178    /// additional schemas. Returns an empty catalog when no schema config is set
179    /// or the config carries no overlays.
180    ///
181    /// This is an internal helper for standalone engine construction.
182    #[doc(hidden)]
183    pub fn build_overlay_catalog(&self) -> Result<OverlayCatalog, ValidationError> {
184        let additional_schemas = match &self.schema_validator_config {
185            Some(cfg) if !cfg.additional_schemas.is_empty() => &cfg.additional_schemas,
186            _ => return Ok(OverlayCatalog::default()),
187        };
188        let overlays: Vec<(String, serde_json::Value)> = additional_schemas
189            .iter()
190            .map(|s| s.resolve().map_err(|e| ValidationError::Engine(e.0)))
191            .collect::<Result<Vec<_>, _>>()?;
192        build_overlay_catalog(overlays)
193            .map_err(|e| ValidationError::Engine(format!("Failed to apply an additional schema: {e}")))
194    }
195}
196
197#[derive(Clone, Default)]
198pub struct ValidateConfig {
199    pub filters: FilterConfig,
200    pub detail_level: DetailLevel,
201    pub severity_level: Severity,
202    pub parameter_overrides: HashMap<String, String>,
203    pub pseudo_parameter_overrides: PseudoParameterOverrides,
204    /// When true, Warn-severity diagnostics are upgraded to Error.
205    pub strict: bool,
206    /// When true, all built-in rules
207    /// Only user-provided custom rules and guard rules are evaluated.
208    pub disable_builtin_rules: bool,
209}
210
211pub trait ValidationEngine {
212    fn engine_name(&self) -> &str;
213
214    fn evaluate_rules(
215        &self,
216        model: &Arc<SemanticModel>,
217        config: &ValidateConfig,
218    ) -> Result<Vec<Diagnostic>, ValidationError>;
219
220    fn list_rules(&self) -> Vec<RuleInfo>;
221
222    /// Built-in rule metadata from the rules registry only.
223    fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry>;
224
225    /// Metadata for rules not in the registry: custom user rules and translated guard rules.
226    fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry>;
227
228    fn init_metric(&self) -> &PhaseMetric;
229}
230
231pub(crate) fn validate(
232    engine: &dyn ValidationEngine,
233    schema_validator: &SchemaValidator,
234    result: ParseResult,
235    config: ValidateConfig,
236    file_path: String,
237) -> Result<ValidationReport, ValidationError> {
238    let model = Arc::new(result.model);
239    let model_build = PhaseMetric { duration_ms: result.model_build_ms };
240    log::info!(
241        "Validating: {} resources, {} types (engine={})",
242        model.resources.len(),
243        model.resources_by_type.len(),
244        engine.engine_name()
245    );
246
247    let schema_result = if config.disable_builtin_rules {
248        SchemaValidationResult { diagnostics: Vec::new(), metric: phase_metric(Instant::now()) }
249    } else {
250        schema_validator.validate(&model, config.pseudo_parameter_overrides.region.as_deref())
251    };
252    let mut all_diagnostics = schema_result.diagnostics;
253
254    if !config.disable_builtin_rules {
255        let invalid = config.pseudo_parameter_overrides.invalid_overrides();
256        if !invalid.is_empty() {
257            let message = format!("Invalid pseudo-parameter override(s): {}", invalid.join("; "));
258            all_diagnostics.push(RegisteredDiagnostic::new("W9012", message).build());
259        }
260    }
261
262    let t_eval = Instant::now();
263    let engine_diags = engine.evaluate_rules(&model, &config)?;
264    all_diagnostics.extend(engine_diags);
265    let eval_metric = phase_metric(t_eval);
266
267    let t_post = Instant::now();
268    if !config.disable_builtin_rules {
269        all_diagnostics.extend(crate::step_functions::validate_all_state_machines(&model));
270        all_diagnostics.extend(model.diagnostics.iter().map(diagnostic_from_parse_defect));
271
272        // The satisfiability budget is consumed almost entirely by the rule
273        // evaluation that just ran, so this is the earliest point the exhausted
274        // queries are observable. Budget exhaustions are recorded into the
275        // model-level tracker by the condition model as they occur.
276
277        if config.pseudo_parameter_overrides.region.is_none() && region_enums::template_has_region_scoped_value(&model)
278        {
279            all_diagnostics.push(
280                RegisteredDiagnostic::new(
281                    "I9003",
282                    "No region supplied; region-scoped instance/node types were validated against all regions. \
283                     A value reported valid here may still be unavailable in your target region - pass a region to \
284                     validate against it specifically.",
285                )
286                .build(),
287            );
288        }
289    }
290
291    gate_sam_transform_errors(&mut all_diagnostics);
292    gate_cdk_suppressed_rules(&mut all_diagnostics, &model);
293
294    backfill_locations(&mut all_diagnostics, &model);
295    backfill_entities(&mut all_diagnostics, &model);
296
297    let registry_metadata = engine.rule_metadata();
298    let external_metadata = engine.external_rule_metadata();
299    enrich_diagnostics(&mut all_diagnostics, &model, registry_metadata, &external_metadata, &config.detail_level);
300
301    if config.detail_level.needs_context() {
302        schema_validator.enrich_context(&mut all_diagnostics, &model);
303        for d in all_diagnostics.iter_mut() {
304            if d.context.is_none() && d.phase != Some(Phase::Schema) {
305                d.context = build_context(
306                    &d.rule_id,
307                    d.resource_logical_id(),
308                    d.property_path.as_deref().unwrap_or(""),
309                    &model,
310                );
311            }
312        }
313    }
314
315    // Filtering may suppress the visible warning, so report metadata is captured
316    // independently before finalization.
317    let exhausted_kinds = model.exhausted_budget_kinds();
318    let budget_exhaustion_records: Vec<BudgetExhaustionRecord> = exhausted_kinds
319        .iter()
320        .map(|kind| BudgetExhaustionRecord {
321            kind: kind.as_str().to_string(),
322            description: kind.description().to_string(),
323            limit: kind.limit(),
324            analysis_incomplete: kind.analysis_incomplete(),
325        })
326        .collect();
327    let analysis_incomplete = model.budget_analysis_incomplete();
328
329    if !config.disable_builtin_rules && !exhausted_kinds.is_empty() {
330        let message = if analysis_incomplete {
331            "Deterministic validation budgets were exhausted; analysis may be incomplete and some findings may be omitted"
332        } else {
333            "Deterministic validation budgets were exhausted; analysis completed but some detail was omitted"
334        };
335        let budget_context = budget_exhaustion_records
336            .iter()
337            .map(|record| {
338                serde_json::json!({
339                    "kind": record.kind.as_str(),
340                    "description": record.description.as_str(),
341                    "limit": record.limit,
342                    "analysisIncomplete": record.analysis_incomplete,
343                })
344            })
345            .collect();
346        let mut context_extra = HashMap::new();
347        context_extra.insert("budgetExhaustions".to_string(), serde_json::Value::Array(budget_context).into());
348        let mut budget_warning = RegisteredDiagnostic::new("W9052", message).build();
349        budget_warning.context = Some(ViolationContext {
350            actual_value: None,
351            expected_constraint: None,
352            property: None,
353            lifecycle: None,
354            resolution_source: None,
355            extra: Some(context_extra),
356        });
357
358        let warning_index = all_diagnostics.len();
359        all_diagnostics.push(budget_warning);
360        let warning_slice = &mut all_diagnostics[warning_index..];
361        backfill_locations(warning_slice, &model);
362        backfill_entities(warning_slice, &model);
363        enrich_diagnostics(warning_slice, &model, registry_metadata, &external_metadata, &config.detail_level);
364    }
365
366    let (total_before, suppressed) = finalize_diagnostics(&mut all_diagnostics, &config);
367
368    if suppressed > 0 {
369        log::info!("Filtered {} -> {} diagnostics ({} suppressed)", total_before, all_diagnostics.len(), suppressed);
370    }
371
372    let excluded_cats = config.filters.excluded_categories();
373    let active_rule_count = registry_metadata
374        .iter()
375        .filter(|(_, entry)| !entry.category.as_deref().is_some_and(|c| excluded_cats.contains(c)))
376        .count() as u32;
377
378    let mut report = build_report(
379        all_diagnostics,
380        &model,
381        suppressed,
382        Some(active_rule_count),
383        config.strict,
384        config.severity_level,
385        file_path,
386    );
387    if analysis_incomplete {
388        report.status = ReportStatus::AnalysisIncomplete;
389    }
390    report.metadata.budget_exhaustions = (!budget_exhaustion_records.is_empty()).then_some(budget_exhaustion_records);
391    let finalize_metric = phase_metric(t_post);
392
393    report.performance.schema_init = schema_validator.init_metric().clone();
394    report.performance.engine_init = engine.init_metric().clone();
395    report.performance.model_build = model_build;
396    report.performance.schema_validate = schema_result.metric;
397    report.performance.rule_evaluation = eval_metric;
398    report.performance.diagnostic_finalize = finalize_metric;
399
400    Ok(report)
401}
402
403struct DataSourceVersions {
404    cfn_lint_version: &'static str,
405    resource_schema_version: &'static str,
406}
407
408static DATA_SOURCE_VERSIONS: DataSourceVersions =
409    DataSourceVersions { cfn_lint_version: CFN_LINT_VERSION, resource_schema_version: RESOURCE_SCHEMA_VERSION };
410
411#[cfg(any(test, feature = "test"))]
412pub fn validate_bytes(
413    engine: &dyn ValidationEngine,
414    schema_validator: &SchemaValidator,
415    bytes: &[u8],
416    config: ValidateConfig,
417) -> Result<ValidationReport, ValidationError> {
418    validate_bytes_with_path(engine, schema_validator, bytes, config, String::new())
419}
420
421pub fn validate_bytes_with_path(
422    engine: &dyn ValidationEngine,
423    schema_validator: &SchemaValidator,
424    bytes: &[u8],
425    config: ValidateConfig,
426    file_path: String,
427) -> Result<ValidationReport, ValidationError> {
428    let total_start = Instant::now();
429    let result = match SemanticModel::parse(
430        bytes,
431        ParseConfig {
432            parameters: config.parameter_overrides.clone(),
433            pseudo_parameters: config.pseudo_parameter_overrides.clone(),
434        },
435    ) {
436        Ok(r) => r,
437        Err(e) => {
438            let span = match (e.line, e.column) {
439                (Some(l), Some(c)) => SourceSpan { start_line: l, start_column: c, end_line: l, end_column: c },
440                _ => UNKNOWN_SPAN,
441            };
442            let diag = RegisteredDiagnostic::new("F1101", e.message).location(span).phase(Phase::Parse).build();
443            let diags = vec![diag];
444            let report = ValidationReport {
445                file_path,
446                status: ReportStatus::Error,
447                version: env!("CARGO_PKG_VERSION").to_string(),
448                diagnostics: diags,
449                metadata: ReportMetadata {
450                    rules_evaluated: 0,
451                    cfn_lint_version: DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
452                    resource_schema_version: DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
453                    resources_scanned: 0,
454                    counts: Summary { fatal: 1, errors: 0, warnings: 0, informational: 0, debug: 0 },
455                    suppressed: 0,
456                    strict: config.strict,
457                    severity_level: config.severity_level,
458                    budget_exhaustions: None,
459                },
460                performance: PerformanceMetrics {
461                    schema_init: PhaseMetric { duration_ms: 0.0 },
462                    engine_init: PhaseMetric { duration_ms: 0.0 },
463                    model_build: PhaseMetric { duration_ms: 0.0 },
464                    schema_validate: PhaseMetric { duration_ms: 0.0 },
465                    rule_evaluation: PhaseMetric { duration_ms: 0.0 },
466                    diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
467                    validate_total: phase_metric(total_start),
468                },
469            };
470            return Ok(report);
471        }
472    };
473    let mut report = validate(engine, schema_validator, result, config, file_path)?;
474    report.performance.validate_total = phase_metric(total_start);
475    Ok(report)
476}
477
478/// Runs `operation`, converting any unwinding panic into an error produced by
479/// `on_panic` instead of letting it escape into the caller. Every embedding
480/// boundary routes through this guard, so an internal invariant violation on
481/// adversarial input becomes a structured, catchable error rather than crashing
482/// or trapping the host.
483///
484/// `on_panic` receives the panic's message and maps it to the caller's own error
485/// type (for example a [`ValidationError`] or a binding-specific error type),
486/// keeping the guard reusable across every binding without coupling it to one
487/// error type.
488///
489/// The guard only works under the `unwind` panic strategy - [`catch_unwind`]
490/// intercepts unwinding panics, not aborts. The workspace pins `panic = "unwind"`
491/// in both the dev and release profiles (`src/Cargo.toml`), so on native targets
492/// a panic is caught here before it can unwind across an FFI boundary - which
493/// would be undefined behavior.
494///
495/// On `wasm32-unknown-unknown` the standard library is compiled with
496/// `panic = "abort"`: a panic traps the instance and `catch_unwind` cannot
497/// intercept it, so the only recovery is to recreate the instance. There this
498/// guard is a no-op that becomes effective only if the wasm build opts into the
499/// unwind strategy. Wrapping the wasm path is therefore harmless and
500/// forward-compatible rather than load-bearing today.
501pub fn catch_panics<T, E>(
502    operation: impl FnOnce() -> Result<T, E>,
503    on_panic: impl FnOnce(String) -> E,
504) -> Result<T, E> {
505    match catch_unwind(AssertUnwindSafe(operation)) {
506        Ok(result) => result,
507        Err(payload) => Err(on_panic(panic_message(payload.as_ref()))),
508    }
509}
510
511/// Convenience wrapper over [`catch_panics`] for the core validation flow: a
512/// panic becomes a structured [`ValidationError::Engine`] carrying the message.
513pub fn validate_catching_panics<F>(validate: F) -> Result<ValidationReport, ValidationError>
514where
515    F: FnOnce() -> Result<ValidationReport, ValidationError>,
516{
517    catch_panics(validate, |message| ValidationError::Engine(format!("Internal validation error: {message}")))
518}
519
520pub fn semantic_model_to_input_json(model: &SemanticModel) -> Result<serde_json::Value, ValidationError> {
521    serde_json::to_value(model.to_diagnostic_json()).map_err(|e| {
522        ValidationError::Engine(format!("Failed to serialize the semantic model for rule evaluation: {e}"))
523    })
524}
525
526fn panic_message(payload: &(dyn Any + Send)) -> String {
527    if let Some(message) = payload.downcast_ref::<&str>() {
528        (*message).to_string()
529    } else if let Some(message) = payload.downcast_ref::<String>() {
530        message.clone()
531    } else {
532        "Panic payload was not a string".to_string()
533    }
534}
535
536fn parse_span_coordinate(
537    diagnostic: &serde_json::Value,
538    field_name: &str,
539    rule_id: &str,
540) -> Result<Option<u32>, String> {
541    let Some(value) = diagnostic.get(field_name) else {
542        return Ok(None);
543    };
544    if value.is_null() {
545        return Ok(None);
546    }
547    let coordinate = value
548        .as_u64()
549        .ok_or_else(|| format!("Diagnostic '{}' field '{}' must be a non-negative integer", rule_id, field_name))?;
550    u32::try_from(coordinate)
551        .map(Some)
552        .map_err(|_| format!("Diagnostic '{}' field '{}' exceeds the supported coordinate range", rule_id, field_name))
553}
554
555fn resolve_model_diagnostic_span(
556    rule_id: &str,
557    model: &SemanticModel,
558    resource_id: Option<&str>,
559    property_path: Option<&str>,
560    source_path: Option<&str>,
561) -> Result<SourceSpan, String> {
562    if let Some(source_path) = source_path {
563        let resource_id = resource_id.ok_or_else(|| {
564            format!("Diagnostic '{}' provides '{}' without a resource_id", rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD)
565        })?;
566        let property_path = property_path.ok_or_else(|| {
567            format!(
568                "Diagnostic '{}' on resource '{}' provides '{}' without a resource_path",
569                rule_id, resource_id, DIAGNOSTIC_SOURCE_PATH_FIELD
570            )
571        })?;
572        let effective_source_path = effective_path_from_scenario_source_path(source_path)
573            .ok_or_else(|| format!("Diagnostic '{}' has malformed scenario source path '{}'", rule_id, source_path))?;
574        if effective_source_path != property_path {
575            return Err(format!(
576                "Diagnostic '{}' source path '{}' resolves to '{}' instead of resource path '{}'",
577                rule_id, source_path, effective_source_path, property_path
578            ));
579        }
580    }
581
582    let location_path = source_path.or(property_path).unwrap_or("");
583    Ok(if let Some(resource_id) = resource_id {
584        model.resource_span(resource_id, location_path)
585    } else {
586        model.diagnostic_span(None, location_path).unwrap_or_else(|| resolve_section_span(rule_id, model))
587    })
588}
589
590fn validate_explicit_diagnostic_span(
591    diagnostic: &serde_json::Value,
592    rule_id: &str,
593    model_span: SourceSpan,
594) -> Result<(), String> {
595    let start_line = parse_span_coordinate(diagnostic, "start_line", rule_id)?;
596    let start_column = parse_span_coordinate(diagnostic, "start_column", rule_id)?;
597    let end_line = parse_span_coordinate(diagnostic, "end_line", rule_id)?;
598    let end_column = parse_span_coordinate(diagnostic, "end_column", rule_id)?;
599
600    if start_line.is_none() && start_column.is_none() && end_line.is_none() && end_column.is_none() {
601        return Ok(());
602    }
603    let (Some(start_line), Some(start_column)) = (start_line, start_column) else {
604        return Err(format!("Diagnostic '{}' explicit span must provide both start_line and start_column", rule_id));
605    };
606    let explicit_end = match (end_line, end_column) {
607        (None, None) => None,
608        (Some(line), Some(column)) => Some((line, column)),
609        _ => {
610            return Err(format!("Diagnostic '{}' explicit span must provide both end_line and end_column", rule_id));
611        }
612    };
613    if model_span == UNKNOWN_SPAN {
614        return Err(format!(
615            "Diagnostic '{}' explicit span cannot be verified because its resource path has no model-derived location",
616            rule_id
617        ));
618    }
619
620    let start_matches = start_line == model_span.start_line && start_column == model_span.start_column;
621    let end_matches =
622        explicit_end.is_none_or(|(line, column)| line == model_span.end_line && column == model_span.end_column);
623    if !start_matches || !end_matches {
624        let (explicit_end_line, explicit_end_column) = explicit_end.unwrap_or((start_line, start_column));
625        return Err(format!(
626            "Diagnostic '{}' explicit span {}:{}-{}:{} does not match model-derived span {}:{}-{}:{}",
627            rule_id,
628            start_line,
629            start_column,
630            explicit_end_line,
631            explicit_end_column,
632            model_span.start_line,
633            model_span.start_column,
634            model_span.end_line,
635            model_span.end_column
636        ));
637    }
638    Ok(())
639}
640
641pub(crate) fn parse_diagnostic(
642    val: &serde_json::Value,
643    model: &SemanticModel,
644    source_override: Option<&RuleOrigin>,
645) -> Result<Diagnostic, String> {
646    let rule_id =
647        val.get("rule_id").and_then(|v| v.as_str()).ok_or("Diagnostic missing required field 'rule_id'")?.to_string();
648    if rule_id.trim().is_empty() {
649        return Err("Diagnostic has an empty 'rule_id'".to_string());
650    }
651    let message = val
652        .get("message")
653        .and_then(|v| v.as_str())
654        .ok_or_else(|| format!("Diagnostic '{}' missing required field 'message'", rule_id))?
655        .to_string();
656    if message.trim().is_empty() {
657        return Err(format!("Diagnostic '{}' has an empty 'message'", rule_id));
658    }
659    let resource_id = val.get("resource_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
660    let entity = resource_id
661        .as_ref()
662        .and_then(|rid| Entity::resource(rid, model.resources.get(rid.as_str()).map(|r| r.resource_type.clone())));
663    let property_path =
664        val.get("resource_path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(|s| s.to_string());
665
666    let source_path = match val.get(DIAGNOSTIC_SOURCE_PATH_FIELD) {
667        None | Some(serde_json::Value::Null) => None,
668        Some(serde_json::Value::String(path)) if !path.is_empty() => Some(path.as_str()),
669        Some(_) => {
670            return Err(format!(
671                "Diagnostic '{}' field '{}' must be a non-empty string",
672                rule_id, DIAGNOSTIC_SOURCE_PATH_FIELD
673            ));
674        }
675    };
676    let span =
677        resolve_model_diagnostic_span(&rule_id, model, resource_id.as_deref(), property_path.as_deref(), source_path)?;
678    validate_explicit_diagnostic_span(val, &rule_id, span)?;
679
680    let is_custom_or_guard = source_override.is_some_and(|o| matches!(o, RuleOrigin::Custom | RuleOrigin::Guard));
681
682    let suggested_fix = val.get("suggested_fix").and_then(|v| v.as_str()).map(|s| s.to_string());
683    let documentation_url = val.get("documentation_url").and_then(|v| v.as_str()).map(|s| s.to_string());
684    let related_resources = val.get("related_locations").and_then(|v| v.as_array()).map(|arr| {
685        arr.iter()
686            .filter_map(|r| {
687                let rel_rid = r.get("resource_id").and_then(|v| v.as_str()).map(|s| s.to_string());
688                let rel_resource = rel_rid.as_ref().map(|rid| ResourceRef {
689                    id: Some(rid.clone()),
690                    resource_type: model.resources.get(rid.as_str()).map(|res| res.resource_type.clone()),
691                });
692                Some(RelatedResource {
693                    resource: rel_resource,
694                    location: Some(SourceSpan {
695                        start_line: r.get("start_line")?.as_u64()? as u32,
696                        start_column: r.get("start_column")?.as_u64()? as u32,
697                        end_line: r.get("end_line")?.as_u64()? as u32,
698                        end_column: r.get("end_column")?.as_u64()? as u32,
699                    }),
700                    message: r.get("message")?.as_str()?.to_string(),
701                })
702            })
703            .collect()
704    });
705    let condition_scenario = val
706        .get("condition_scenario")
707        .and_then(|v| v.as_object())
708        .map(|m| m.iter().filter_map(|(k, v)| v.as_bool().map(|b| (k.clone(), b))).collect());
709
710    if is_custom_or_guard {
711        if !is_valid_custom_rule_id(&rule_id) {
712            return Err(format!(
713                "Custom/Guard diagnostic '{}' has an invalid 'rule_id': only letters, digits, and the separators \
714                 '_', '.', '-' are allowed",
715                rule_id
716            ));
717        }
718        let severity_str = val
719            .get("severity")
720            .and_then(|v| v.as_str())
721            .ok_or_else(|| format!("Custom/Guard diagnostic '{}' missing required field 'severity'", rule_id))?;
722        // The declared severity is authoritative. A custom/Guard rule ID is arbitrary,
723        // so the built-in `F`-prefix→Fatal heuristic must NOT apply here
724        let severity = severity_str.parse::<Severity>()?;
725        let category = val.get("category").and_then(|v| v.as_str()).map(|c| c.to_string());
726        let source = *source_override.expect("custom/guard branch is only reached with a source override");
727        return Ok(Diagnostic {
728            rule_id,
729            severity,
730            message,
731            entity,
732            property_path,
733            suggested_fix,
734            documentation_url,
735            category,
736            location: span_to_option(span),
737            related_resources,
738            condition_scenario,
739            rule_description: None,
740            phase: None,
741            context: None,
742            source,
743        });
744    }
745
746    // The registry is the single source of truth for built-in severity,
747    // category, origin, and description. Any `severity` field present in the
748    // engine output is intentionally ignored, so it is not required here.
749    let mut diagnostic = RegisteredDiagnostic::new(rule_id, message)
750        .location(span)
751        .suggested_fix(suggested_fix)
752        .condition_scenario(condition_scenario)
753        .related_resources(related_resources)
754        .build();
755    diagnostic.entity = entity;
756    diagnostic.property_path = property_path;
757    diagnostic.documentation_url = documentation_url;
758    Ok(diagnostic)
759}
760
761pub fn extract_diagnostics(
762    json_str: &str,
763    model: &SemanticModel,
764    out: &mut Vec<Diagnostic>,
765    source_override: Option<&RuleOrigin>,
766) -> Result<(), String> {
767    let json_val: serde_json::Value =
768        serde_json::from_str(json_str).map_err(|e| format!("Failed to parse diagnostic JSON: {}", e))?;
769    let items = json_val.as_array().ok_or("Diagnostic output must be a JSON array")?;
770    for item in items {
771        out.push(parse_diagnostic(item, model, source_override)?);
772    }
773    Ok(())
774}
775
776pub(crate) fn build_report(
777    diagnostics: Vec<Diagnostic>,
778    model: &SemanticModel,
779    suppressed: u32,
780    rules_evaluated: Option<u32>,
781    strict: bool,
782    severity_level: Severity,
783    file_path: String,
784) -> ValidationReport {
785    build_report_with_versions(
786        diagnostics,
787        model,
788        suppressed,
789        rules_evaluated,
790        strict,
791        severity_level,
792        file_path,
793        DATA_SOURCE_VERSIONS.cfn_lint_version.to_owned(),
794        DATA_SOURCE_VERSIONS.resource_schema_version.to_owned(),
795    )
796}
797
798fn build_report_with_versions(
799    diagnostics: Vec<Diagnostic>,
800    model: &SemanticModel,
801    suppressed: u32,
802    rules_evaluated: Option<u32>,
803    strict: bool,
804    severity_level: Severity,
805    file_path: String,
806    cfn_lint_version: String,
807    resource_schema_version: String,
808) -> ValidationReport {
809    let fatal = diagnostics.iter().filter(|d| d.severity == Severity::Fatal).count() as u32;
810    let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count() as u32;
811    let warnings = diagnostics.iter().filter(|d| d.severity == Severity::Warn).count() as u32;
812    let debug = diagnostics.iter().filter(|d| d.severity == Severity::Debug).count() as u32;
813    let informational = diagnostics.len() as u32 - fatal - errors - warnings - debug;
814    ValidationReport {
815        file_path,
816        status: ReportStatus::Ok,
817        version: env!("CARGO_PKG_VERSION").to_string(),
818        diagnostics,
819        metadata: ReportMetadata {
820            rules_evaluated: rules_evaluated.unwrap_or(0),
821            cfn_lint_version,
822            resource_schema_version,
823            resources_scanned: model.resources.len() as u32,
824            counts: Summary { fatal, errors, warnings, informational, debug },
825            suppressed,
826            strict,
827            severity_level,
828            budget_exhaustions: None,
829        },
830        performance: PerformanceMetrics {
831            schema_init: PhaseMetric { duration_ms: 0.0 },
832            engine_init: PhaseMetric { duration_ms: 0.0 },
833            model_build: PhaseMetric { duration_ms: 0.0 },
834            schema_validate: PhaseMetric { duration_ms: 0.0 },
835            rule_evaluation: PhaseMetric { duration_ms: 0.0 },
836            diagnostic_finalize: PhaseMetric { duration_ms: 0.0 },
837            validate_total: PhaseMetric { duration_ms: 0.0 },
838        },
839    }
840}
841
842pub(crate) fn finalize_diagnostics(diagnostics: &mut Vec<Diagnostic>, config: &ValidateConfig) -> (u32, u32) {
843    let total_before = diagnostics.len() as u32;
844
845    if config.disable_builtin_rules {
846        diagnostics.retain(|d| matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard));
847    }
848
849    if config.strict {
850        for d in diagnostics.iter_mut() {
851            if d.severity == Severity::Warn {
852                d.severity = Severity::Error;
853            }
854        }
855    }
856
857    apply_filters(diagnostics, &config.filters);
858
859    // Filter by minimum severity. Debug is the lowest severity so it acts as "no filter".
860    diagnostics.retain(|d| d.severity >= config.severity_level);
861
862    // Order by severity first (Fatal, Error, Warn, Info, Debug), then by rule number,
863    // then by location and the remaining fields. The severity and rule-number keys are
864    // both functionally determined by `rule_id` (the prefix letter fixes the severity,
865    // the digits fix the number), so they add grouping without ever splitting two
866    // diagnostics that are otherwise identical.
867    //
868    // The sort key MUST stay a superset of the dedup key below. If two diagnostics share
869    // the dedup key but are separated by a sibling that compares equal on the sort key,
870    // stable sort leaves them non-adjacent and dedup_by silently misses them.
871    // Seen in the wild: the same rule fired twice for the same parameter (two distinct usages)
872    // with a sibling diagnostic for a different parameter at the same line/col in between -
873    // native HashMap iteration order put them in [A, B, A] layout, dedup skipped.
874    let line = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_line).unwrap_or(0);
875    let column = |d: &Diagnostic| d.location.as_ref().map(|l| l.start_column).unwrap_or(0);
876    // The resource id is part of both keys: two findings on *different* resources
877    // are distinct even when they share a span, message, and path. This happens
878    // with `Fn::ForEach`-expanded resources, which are separate resources built
879    // from one template body and therefore carry the same source span.
880    let resource_id = |d: &Diagnostic| d.entity.as_ref().map(|e| e.logical_id.clone()).unwrap_or_default();
881    diagnostics.sort_by(|a, b| {
882        b.severity
883            .cmp(&a.severity)
884            .then_with(|| rule_number(&a.rule_id).cmp(&rule_number(&b.rule_id)))
885            .then_with(|| a.rule_id.cmp(&b.rule_id))
886            .then_with(|| line(a).cmp(&line(b)))
887            .then_with(|| column(a).cmp(&column(b)))
888            .then_with(|| resource_id(a).cmp(&resource_id(b)))
889            .then_with(|| a.property_path.cmp(&b.property_path))
890            .then_with(|| a.message.cmp(&b.message))
891    });
892    diagnostics.dedup_by(|a, b| {
893        a.location.as_ref().map(|l| l.start_line).unwrap_or(0) == b.location.as_ref().map(|l| l.start_line).unwrap_or(0)
894            && a.location.as_ref().map(|l| l.start_column).unwrap_or(0)
895                == b.location.as_ref().map(|l| l.start_column).unwrap_or(0)
896            && a.rule_id == b.rule_id
897            && resource_id(a) == resource_id(b)
898            && a.message == b.message
899            && a.property_path == b.property_path
900    });
901
902    let suppressed = total_before.saturating_sub(diagnostics.len() as u32);
903    (total_before, suppressed)
904}
905
906pub(crate) fn build_context(
907    rule_id: &str,
908    resource_id: Option<&str>,
909    property_path: &str,
910    model: &SemanticModel,
911) -> Option<ViolationContext> {
912    let rid = resource_id?;
913
914    let resolve_val = |path: &str| -> Option<serde_json::Value> {
915        let scenarios = model.resolve_scenarios_json(rid, path);
916        scenarios.into_iter().next().map(|(v, _)| v)
917    };
918
919    // Resolves a value only when every condition scenario agrees on it. A property
920    // driven by `Fn::If` resolves to a different value per branch, and each branch
921    // yields its own diagnostic (with a branch-specific message); attaching the
922    // first branch's value to all of them would contradict the other messages. When
923    // the branches disagree this returns `None`, leaving the message as the sole,
924    // correct carrier of the offending value.
925    let unambiguous_val = |path: &str| -> Option<serde_json::Value> {
926        let mut scenarios = model.resolve_scenarios_json(rid, path).into_iter().map(|(v, _)| v);
927        let first = scenarios.next()?;
928        if scenarios.all(|v| v == first) { Some(first) } else { None }
929    };
930
931    let mut actual_value: Option<JsonValue> = None;
932    let mut property = None;
933    let mut lifecycle = None;
934    let mut extra: HashMap<String, JsonValue> = HashMap::new();
935
936    match rule_id {
937        "F3012" | "E3012" => {
938            if !property_path.is_empty() {
939                actual_value = resolve_val(property_path).map(Into::into);
940            }
941        }
942        "F3030" | "W3030" | "E3030" | "F3031" | "E3031" | "F3034" | "E3034" | "F3037" | "W3045" | "E1103" | "E1150"
943        | "E1151" | "E1152" | "E1153" | "E1154" | "E1155" | "E1156" => {
944            actual_value = resolve_val(property_path).map(Into::into);
945        }
946        "F3033" | "W9006" => {
947            if let Some(v) = resolve_val(property_path) {
948                if let Some(s) = v.as_str() {
949                    extra.insert("actual_length".into(), serde_json::json!(s.chars().count()).into());
950                }
951                actual_value = Some(v.into());
952            }
953        }
954        "F3032" | "E3032" => {
955            if let Some(v) = resolve_val(property_path)
956                && let Some(arr) = v.as_array()
957            {
958                extra.insert("actual_count".into(), serde_json::json!(arr.len()).into());
959            }
960        }
961        "F3002" | "E3002" | "F3020" => {
962            let prop = property_path.rsplit('.').next().unwrap_or("");
963            if !prop.is_empty() {
964                property = Some(prop.to_string());
965            }
966        }
967        "F3003" | "F3021" | "F3014" | "F3058" => {}
968        "E3047" => {
969            if let Some(v) = resolve_val("Properties.Cpu") {
970                extra.insert("cpu".into(), v.into());
971            }
972            if let Some(v) = resolve_val("Properties.Memory") {
973                extra.insert("memory".into(), v.into());
974            }
975        }
976        "E3060" => {
977            if let Some(v) = resolve_val("Properties.CidrBlock") {
978                extra.insert("cidr".into(), v.into());
979            }
980        }
981        "E9002" => {
982            actual_value = resolve_val("Properties.SecurityGroupIngress").map(Into::into);
983        }
984        "F3006" => {
985            if let Some(res) = model.resources.get(rid) {
986                extra.insert("resource_type".into(), serde_json::json!(res.resource_type).into());
987            }
988        }
989        "E3501" => {
990            if let Some(v) = resolve_val("Properties.QueueName") {
991                extra.insert("queue_name".into(), v.into());
992            }
993        }
994        "E3505" => {
995            if let Some(v) = resolve_val("Properties.VisibilityTimeout") {
996                extra.insert("visibility_timeout".into(), v.into());
997            }
998        }
999        "E3044" => {
1000            if let Some(v) = resolve_val("Properties.LaunchType") {
1001                extra.insert("launch_type".into(), v.into());
1002            }
1003            if let Some(v) = resolve_val("Properties.SchedulingStrategy") {
1004                extra.insert("scheduling_strategy".into(), v.into());
1005            }
1006        }
1007        "E3053" => {
1008            if !property_path.is_empty() {
1009                actual_value = resolve_val(property_path).map(Into::into);
1010            }
1011        }
1012        "W9009" => {
1013            lifecycle = Some("deprecated".into());
1014        }
1015        "I9001" => {
1016            lifecycle = Some("create-only".into());
1017        }
1018        "W9054" => {
1019            lifecycle = Some("write-only".into());
1020        }
1021        // Lambda runtime lifecycle: surface the offending runtime string and the
1022        // stage of its lifecycle, mirroring how resource-type deprecation (W9009)
1023        // and property lifecycle (I9001/W9054) carry a lifecycle marker. The runtime
1024        // is emitted once per condition branch, so only attach the value when every
1025        // branch agrees - otherwise the message alone names the branch's runtime.
1026        "E2533" => {
1027            actual_value = unambiguous_val(property_path).map(Into::into);
1028            lifecycle = Some("end-of-life".into());
1029        }
1030        "E2531" => {
1031            actual_value = unambiguous_val(property_path).map(Into::into);
1032            lifecycle = Some("create-blocked".into());
1033        }
1034        "W2531" => {
1035            actual_value = unambiguous_val(property_path).map(Into::into);
1036            lifecycle = Some("deprecated".into());
1037        }
1038        // RDS instance exposed to the public internet: surface the offending
1039        // PubliclyAccessible value against the expected safe setting.
1040        "W9011" => {
1041            actual_value = unambiguous_val(property_path).map(Into::into);
1042        }
1043        "W2503" | "W2502" => {
1044            if let Some(res) = model.resources.get(rid)
1045                && let Some(ref c) = res.condition
1046            {
1047                extra.insert("source_condition".into(), serde_json::json!(c).into());
1048            }
1049        }
1050        _ => {}
1051    }
1052
1053    if actual_value.is_none() && property.is_none() && lifecycle.is_none() && extra.is_empty() {
1054        return None;
1055    }
1056
1057    Some(ViolationContext {
1058        actual_value,
1059        expected_constraint: None,
1060        property,
1061        lifecycle,
1062        resolution_source: None,
1063        extra: if extra.is_empty() { None } else { Some(extra) },
1064    })
1065}
1066
1067/// Drops every non-transform diagnostic when a SAM transform error is present.
1068///
1069/// A failed SAM transform stops CloudFormation before resource validation, so
1070/// schema and lint findings on the untransformed template are noise. Retaining
1071/// only the transform errors mirrors that short-circuit.
1072fn gate_sam_transform_errors(diagnostics: &mut Vec<Diagnostic>) {
1073    let has_transform_error = diagnostics.iter().any(|d| is_sam_transform_error_message(&d.message));
1074    if has_transform_error {
1075        diagnostics.retain(|d| is_sam_transform_error_message(&d.message));
1076    }
1077}
1078
1079/// Rules suppressed on CDK-generated templates. CDK synthesizes templates from
1080/// higher-level code, so findings about how the template itself is written are
1081/// not actionable for the developer and would only add noise.
1082const CDK_SUPPRESSED_RULE_IDS: [&str; 2] = ["I1022", "W3010"];
1083
1084/// Suppresses non-actionable findings on CDK-generated templates (see
1085/// [`CDK_SUPPRESSED_RULE_IDS`]).
1086fn gate_cdk_suppressed_rules(diagnostics: &mut Vec<Diagnostic>, model: &SemanticModel) {
1087    if !model.is_cdk {
1088        return;
1089    }
1090    diagnostics.retain(|d| !CDK_SUPPRESSED_RULE_IDS.contains(&d.rule_id.as_str()));
1091}
1092
1093/// Attaches a source span to any diagnostic still missing one. Location is part of
1094/// both the standard and detailed reports, so this runs regardless of detail level
1095/// and independently of the engine.
1096///
1097/// Most diagnostics are located at construction (via `resource_span` /
1098/// `resolve_section_span`), but some emission paths cannot reach a span there:
1099/// parse-time findings built before byte spans are assigned, findings anchored on
1100/// an output/parameter path that `resource_span` cannot resolve (it only roots at
1101/// `Resources/`), and template-model findings that never pass through
1102/// `parse_diagnostic`'s section fallback. This is the single place that repairs
1103/// them. The resolution is best-effort and monotonic - it only ever fills a `None`
1104/// location, never overrides an existing one - so it cannot move a diagnostic that
1105/// already points at the right place.
1106fn backfill_locations(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
1107    for d in diagnostics.iter_mut() {
1108        if d.location.is_some() {
1109            continue;
1110        }
1111        let resource_id = d.resource_logical_id();
1112        let property_path = d.property_path.as_deref().unwrap_or("");
1113        let span = model
1114            .diagnostic_span(resource_id, property_path)
1115            .unwrap_or_else(|| resolve_section_span(&d.rule_id, model));
1116        d.location = span_to_option(span);
1117    }
1118}
1119
1120/// Fills the targeted entity of every diagnostic on a named template entity
1121/// that did not attach one at emission time, by deriving the entity type and
1122/// logical ID from its section-absolute property path (`Parameters/MyParam/…`).
1123/// Resource entities emitted before the model existed (parse- and
1124/// transform-time) carry no CloudFormation type, so it is filled in from the
1125/// model here. Like `backfill_locations`, the resolution is monotonic - an
1126/// already-set entity or resource type is never overridden.
1127fn backfill_entities(diagnostics: &mut [Diagnostic], model: &SemanticModel) {
1128    for d in diagnostics.iter_mut() {
1129        if d.entity.is_none() {
1130            d.entity = d.property_path.as_deref().and_then(entity_identity).map(|(entity_type, logical_id)| Entity {
1131                logical_id: logical_id.to_string(),
1132                entity_type,
1133                resource_type: None,
1134            });
1135        }
1136        if let Some(entity) = d.entity.as_mut()
1137            && entity.entity_type == EntityType::Resource
1138            && entity.resource_type.is_none()
1139        {
1140            entity.resource_type = model.resources.get(entity.logical_id.as_str()).map(|r| r.resource_type.clone());
1141        }
1142    }
1143}
1144
1145pub(crate) fn enrich_diagnostics(
1146    diagnostics: &mut [Diagnostic],
1147    model: &SemanticModel,
1148    registry_metadata: &HashMap<String, RuleMetadataEntry>,
1149    external_metadata: &HashMap<String, RuleMetadataEntry>,
1150    format: &DetailLevel,
1151) {
1152    if !format.needs_enrichment() {
1153        return;
1154    }
1155    let needs_context = format.needs_context();
1156
1157    for d in diagnostics.iter_mut() {
1158        // Custom and Guard rules are user-supplied and have no built-in evaluation
1159        // phase, so leave their phase unset rather than forcing one. Built-in rules
1160        // are Schema-phase when fatal and Lint-phase otherwise.
1161        if d.phase.is_none() && !matches!(d.source, RuleOrigin::Custom | RuleOrigin::Guard) {
1162            d.phase = Some(if is_fatal_rule(&d.rule_id) { Phase::Schema } else { Phase::Lint });
1163        }
1164        if d.rule_description.is_none() {
1165            d.rule_description = registry_metadata
1166                .get(&d.rule_id)
1167                .or_else(|| external_metadata.get(&d.rule_id))
1168                .map(|entry| entry.description.clone());
1169        }
1170        if needs_context && d.context.is_none() {
1171            d.context =
1172                build_context(&d.rule_id, d.resource_logical_id(), d.property_path.as_deref().unwrap_or(""), model);
1173        }
1174    }
1175}
1176
1177pub fn build_rule_list(
1178    registry: &HashMap<String, RuleMetadataEntry>,
1179    external: &HashMap<String, RuleMetadataEntry>,
1180) -> Vec<RuleInfo> {
1181    let mut rules: Vec<RuleInfo> = registry
1182        .iter()
1183        .chain(external.iter())
1184        .map(|(id, entry)| RuleInfo {
1185            id: id.clone(),
1186            severity: entry.severity,
1187            category: entry.category.clone(),
1188            description: entry.description.clone(),
1189            origin: entry.origin,
1190        })
1191        .collect();
1192    rules.sort_by(|a, b| a.id.cmp(&b.id));
1193    rules
1194}
1195
1196pub fn make_resource_diagnostic(
1197    rule_id: &str,
1198    message: &str,
1199    model: &SemanticModel,
1200    resource_id: &str,
1201    prop_path: &str,
1202    suggested_fix: Option<&str>,
1203) -> Diagnostic {
1204    make_resource_diagnostic_at_source(rule_id, message, model, resource_id, prop_path, prop_path, suggested_fix)
1205}
1206
1207/// Builds a resource diagnostic whose public property path and source-span anchor differ.
1208/// Scenario-aware rules use this when an effective path maps to an authored intrinsic branch.
1209pub fn make_resource_diagnostic_at_source(
1210    rule_id: &str,
1211    message: &str,
1212    model: &SemanticModel,
1213    resource_id: &str,
1214    prop_path: &str,
1215    source_path: &str,
1216    suggested_fix: Option<&str>,
1217) -> Diagnostic {
1218    // With no resource, a section-absolute path (`Parameters/MyParam/Type`)
1219    // anchors the finding at the named entity; without one the section span from
1220    // the rule-ID table is the closest known location.
1221    let span = if resource_id.is_empty() {
1222        match model.diagnostic_span(None, source_path) {
1223            Some(found) => found,
1224            None => resolve_section_span(rule_id, model),
1225        }
1226    } else {
1227        model.resource_span(resource_id, source_path)
1228    };
1229    RegisteredDiagnostic::new(rule_id, message)
1230        .property_path(prop_path)
1231        .location(span)
1232        .suggested_fix(suggested_fix)
1233        .resource(resource_id, model.resources.get(resource_id).map(|r| r.resource_type.clone()))
1234        .build()
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use diagnostics::Phase;
1241    use rules::{Category, RuleFilterConfig, build_rule_metadata_map, lookup_rule};
1242    use template_model::{SAM_TRANSFORM_ERROR_PREFIX, SAM_TRANSFORM_ERROR_RULE_ID};
1243
1244    const TEST_CFN_LINT_VERSION: &str = "https://github.com/aws-cloudformation/cfn-lint@1.54.0";
1245    const TEST_RESOURCE_SCHEMA_VERSION: &str =
1246        "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z";
1247    #[test]
1248    fn additional_schema_resolve_uses_explicit_type_name() {
1249        let src = AdditionalSchemaSource {
1250            type_name: Some("AWS::Lambda::Function".into()),
1251            schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
1252        };
1253        let (type_name, schema) = src.resolve().expect("valid schema resolves");
1254        assert_eq!(type_name, "AWS::Lambda::Function", "an explicit type_name names the target type");
1255        assert!(schema.is_object());
1256    }
1257
1258    #[test]
1259    fn additional_schema_resolve_accepts_matching_type_names() {
1260        let src = AdditionalSchemaSource {
1261            type_name: Some("AWS::Lambda::Function".into()),
1262            schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"P":{"type":"string"}}}"#.into(),
1263        };
1264        let (type_name, _) = src.resolve().expect("agreeing type names resolve");
1265        assert_eq!(type_name, "AWS::Lambda::Function");
1266    }
1267
1268    #[test]
1269    fn additional_schema_resolve_rejects_contradictory_type_names() {
1270        let src = AdditionalSchemaSource {
1271            type_name: Some("AWS::Lambda::Function".into()),
1272            schema: r#"{"typeName":"AWS::Other::Type","properties":{}}"#.into(),
1273        };
1274        let err = src.resolve().expect_err("contradictory type names must fail");
1275        assert!(
1276            err.0.contains("AWS::Lambda::Function") && err.0.contains("AWS::Other::Type"),
1277            "the error must name both types, got: {}",
1278            err.0
1279        );
1280    }
1281
1282    #[test]
1283    fn additional_schema_resolve_falls_back_to_schema_type_name() {
1284        let src = AdditionalSchemaSource {
1285            type_name: None,
1286            schema: r#"{"typeName":"AWS::Lambda::Function","properties":{}}"#.into(),
1287        };
1288        let (type_name, _) = src.resolve().expect("valid schema resolves");
1289        assert_eq!(type_name, "AWS::Lambda::Function", "an absent type_name falls back to the schema's typeName");
1290    }
1291
1292    #[test]
1293    fn additional_schema_resolve_rejects_invalid_json() {
1294        let src =
1295            AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "{ not json ".into() };
1296        let err = src.resolve().expect_err("invalid JSON must fail");
1297        assert!(err.0.contains("Invalid additional schema"), "unexpected error: {}", err.0);
1298    }
1299
1300    #[test]
1301    fn additional_schema_resolve_rejects_non_object() {
1302        let src = AdditionalSchemaSource { type_name: Some("AWS::Lambda::Function".into()), schema: "42".into() };
1303        let err = src.resolve().expect_err("non-object schema must fail");
1304        assert!(err.0.contains("expected a JSON object"), "unexpected error: {}", err.0);
1305    }
1306
1307    #[test]
1308    fn additional_schema_resolve_rejects_missing_type_name() {
1309        let src = AdditionalSchemaSource { type_name: None, schema: r#"{"properties":{}}"#.into() };
1310        let err = src.resolve().expect_err("missing type name must fail");
1311        assert!(err.0.contains("missing a resource type name"), "unexpected error: {}", err.0);
1312    }
1313
1314    #[test]
1315    fn additional_schema_resolve_error_names_an_unnamed_source() {
1316        let src = AdditionalSchemaSource { type_name: None, schema: "{ not json ".into() };
1317        let err = src.resolve().expect_err("invalid JSON must fail");
1318        assert!(err.0.contains("<unnamed>"), "unexpected error: {}", err.0);
1319    }
1320
1321    #[test]
1322    fn engine_config_build_overlay_catalog_applies_schemas() {
1323        let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1324            additional_schemas: vec![AdditionalSchemaSource {
1325                type_name: None,
1326                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1327                    .into(),
1328            }],
1329        });
1330        let catalog = config.build_overlay_catalog().expect("valid overlay builds catalog");
1331        assert!(!catalog.is_empty());
1332        assert!(catalog.type_names.contains(&"AWS::Lambda::Function".to_string()));
1333    }
1334
1335    #[test]
1336    fn engine_config_build_overlay_catalog_empty_is_noop() {
1337        let config = EngineConfig::default();
1338        let catalog = config.build_overlay_catalog().expect("empty config builds empty catalog");
1339        assert!(catalog.is_empty());
1340    }
1341
1342    #[test]
1343    fn engine_config_builder_composes_without_a_struct_literal() {
1344        let config = EngineConfig::new()
1345            .with_custom_rules([ExternalRuleSource { name: "a.rego".into(), content: "package a".into() }])
1346            .with_guard_rules([ExternalRuleSource { name: "b.guard".into(), content: "rule x {}".into() }])
1347            .with_schema_validator_config(SchemaValidatorConfig {
1348                additional_schemas: vec![AdditionalSchemaSource {
1349                    type_name: Some("AWS::Test::One".into()),
1350                    schema: r#"{"properties":{"P":{"type":"string"}}}"#.into(),
1351                }],
1352            });
1353        assert_eq!(config.custom_rules.len(), 1);
1354        assert_eq!(config.guard_rules.len(), 1);
1355        let sv_config = config.schema_validator_config.as_ref().expect("schema_validator config must be set");
1356        assert_eq!(sv_config.additional_schemas.len(), 1);
1357        assert_eq!(sv_config.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::One"));
1358    }
1359
1360    #[test]
1361    fn default_engine_config_serializes_without_the_schema_validator_config_field() {
1362        let json = serde_json::to_value(EngineConfig::default()).expect("EngineConfig serializes");
1363        assert!(
1364            json.get("schemaValidatorConfig").is_none(),
1365            "an empty schema_validator_config must not change the serialized form, got: {json}"
1366        );
1367    }
1368
1369    #[test]
1370    fn engine_config_nested_schema_validator_serialization_roundtrip() {
1371        let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1372            additional_schemas: vec![AdditionalSchemaSource {
1373                type_name: Some("AWS::Test::RT".into()),
1374                schema: r#"{"properties":{}}"#.into(),
1375            }],
1376        });
1377        let json = serde_json::to_value(&config).expect("serializes");
1378        assert!(json.get("schemaValidatorConfig").is_some(), "nested config must appear in JSON: {json}");
1379        assert!(json.get("schemaValidator").is_none(), "the old config field must not be serialized: {json}");
1380        let deserialized: EngineConfig = serde_json::from_value(json).expect("deserializes");
1381        let sv = deserialized.schema_validator_config.expect("nested config must roundtrip");
1382        assert_eq!(sv.additional_schemas.len(), 1);
1383        assert_eq!(sv.additional_schemas[0].type_name.as_deref(), Some("AWS::Test::RT"));
1384    }
1385
1386    #[test]
1387    fn standalone_engine_with_nested_schema_config_derives_metadata() {
1388        // A standalone engine built from EngineConfig with a nested
1389        // SchemaValidatorConfig derives overlay-aware metadata from it. This
1390        // verifies the internal build_overlay_catalog path is exercised.
1391        let config = EngineConfig::new().with_schema_validator_config(SchemaValidatorConfig {
1392            additional_schemas: vec![AdditionalSchemaSource {
1393                type_name: None,
1394                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1395                    .into(),
1396            }],
1397        });
1398        // The engine builds successfully - the nested schema config was resolved.
1399        let catalog = config.build_overlay_catalog().expect("nested config builds");
1400        assert!(!catalog.is_empty(), "the catalog must be populated from nested config");
1401    }
1402
1403    #[test]
1404    fn combined_wrapper_style_path_constructs_validator_and_engine_once() {
1405        // Simulates the binding/CLI pattern: build SchemaValidator once from the
1406        // nested config, then pass it to the engine via new_with_schema_validator.
1407        // This ensures the combined path does not re-resolve overlays.
1408        let schema_config = SchemaValidatorConfig {
1409            additional_schemas: vec![AdditionalSchemaSource {
1410                type_name: None,
1411                schema: r#"{"typeName":"AWS::Lambda::Function","properties":{"TestForOverride":{"type":"string"}}}"#
1412                    .into(),
1413            }],
1414        };
1415        let validator = schema_validator::SchemaValidator::new(schema_config).expect("validator builds");
1416        // Verify the catalog is populated from the validator
1417        assert!(!validator.overlay_catalog().is_empty());
1418        assert!(validator.overlay_catalog().type_names.contains(&"AWS::Lambda::Function".to_string()));
1419    }
1420
1421    #[test]
1422    fn standalone_schema_validator_config() {
1423        // SchemaValidator can be built standalone from SchemaValidatorConfig.
1424        let config = SchemaValidatorConfig {
1425            additional_schemas: vec![AdditionalSchemaSource {
1426                type_name: None,
1427                schema: r#"{"typeName":"AWS::Test::Standalone","properties":{"Name":{"type":"string"}}}"#.into(),
1428            }],
1429        };
1430        let validator = schema_validator::SchemaValidator::new(config).expect("standalone validator builds");
1431        assert!(validator.overlay_catalog().type_names.contains(&"AWS::Test::Standalone".to_string()));
1432    }
1433
1434    fn minimal_model() -> SemanticModel {
1435        let yaml = br#"
1436AWSTemplateFormatVersion: "2010-09-09"
1437Resources:
1438  Bucket:
1439    Type: AWS::S3::Bucket
1440    Properties:
1441      BucketName: my-bucket
1442"#;
1443        SemanticModel::from_bytes(yaml).expect("minimal model should parse")
1444    }
1445
1446    fn meta_map() -> HashMap<String, RuleMetadataEntry> {
1447        build_rule_metadata_map()
1448    }
1449
1450    fn default_diag() -> Diagnostic {
1451        Diagnostic {
1452            rule_id: String::new(),
1453            severity: Severity::Info,
1454            message: String::new(),
1455            entity: None,
1456            property_path: None,
1457            suggested_fix: None,
1458            documentation_url: None,
1459            category: None,
1460            location: None,
1461            related_resources: None,
1462            condition_scenario: None,
1463            rule_description: None,
1464            phase: None,
1465            context: None,
1466            source: RuleOrigin::Engine,
1467        }
1468    }
1469
1470    #[test]
1471    fn parse_diagnostic_minimal_valid() {
1472        let model = minimal_model();
1473        let val: serde_json::Value = serde_json::json!({
1474            "rule_id": "E3012",
1475            "severity": Severity::Error.as_str(),
1476            "message": "Type mismatch",
1477            "resource_id": "Bucket",
1478            "resource_path": "Properties.BucketName"
1479        });
1480        let diag = parse_diagnostic(&val, &model, None).expect("should parse");
1481        assert_eq!(diag.rule_id, "E3012");
1482        assert_eq!(diag.severity, Severity::Error);
1483        assert_eq!(diag.message, "Type mismatch");
1484        assert_eq!(diag.resource_logical_id(), Some("Bucket"));
1485        assert_eq!(diag.entity.as_ref().and_then(|e| e.resource_type.as_deref()), Some("AWS::S3::Bucket"));
1486    }
1487
1488    #[test]
1489    fn parse_diagnostic_missing_rule_id_returns_error() {
1490        let model = minimal_model();
1491        let val = serde_json::json!({"severity": Severity::Error.as_str(), "message": "x"});
1492        parse_diagnostic(&val, &model, None).unwrap_err();
1493    }
1494
1495    #[test]
1496    fn parse_diagnostic_missing_severity_for_builtin_is_accepted() {
1497        let model = minimal_model();
1498        let val = serde_json::json!({"rule_id": "E3012", "message": "x"});
1499        let diag = parse_diagnostic(&val, &model, None).expect("built-in diagnostic must not require severity in JSON");
1500        assert_eq!(diag.severity, Severity::Error, "registry-derived severity must win for built-in rules");
1501    }
1502
1503    #[test]
1504    fn parse_diagnostic_missing_severity_for_custom_returns_error() {
1505        let model = minimal_model();
1506        let val = serde_json::json!({"rule_id": "XCUSTOM", "message": "x"});
1507        parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1508            .expect_err("custom/guard diagnostics without severity must error");
1509    }
1510
1511    #[test]
1512    fn parse_diagnostic_builtin_ignores_json_severity() {
1513        let model = minimal_model();
1514        let val = serde_json::json!({
1515            "rule_id": "E3012",
1516            "severity": Severity::Warn.as_str(),
1517            "message": "x"
1518        });
1519        let diag = parse_diagnostic(&val, &model, None).unwrap();
1520        assert_eq!(
1521            diag.severity,
1522            Severity::Error,
1523            "the registry's Error severity for E3012 must override any JSON severity"
1524        );
1525    }
1526
1527    #[test]
1528    fn parse_diagnostic_missing_message_returns_error() {
1529        let model = minimal_model();
1530        let val = serde_json::json!({"rule_id": "E3012", "severity": Severity::Error.as_str()});
1531        parse_diagnostic(&val, &model, None).unwrap_err();
1532    }
1533
1534    #[test]
1535    fn parse_diagnostic_empty_rule_id_returns_error() {
1536        let model = minimal_model();
1537        let val = serde_json::json!({"rule_id": "", "severity": Severity::Error.as_str(), "message": "x"});
1538        let err = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1539            .expect_err("an empty rule_id must be rejected, not accepted as a blank-ID diagnostic");
1540        assert!(err.contains("empty 'rule_id'"), "got: {err}");
1541    }
1542
1543    #[test]
1544    fn parse_diagnostic_empty_message_returns_error() {
1545        let model = minimal_model();
1546        let val = serde_json::json!({"rule_id": "CUSTOM1", "severity": Severity::Error.as_str(), "message": "   "});
1547        let err =
1548            parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).expect_err("a blank message must be rejected");
1549        assert!(err.contains("CUSTOM1") && err.contains("empty 'message'"), "got: {err}");
1550    }
1551
1552    #[test]
1553    fn parse_diagnostic_custom_severity_is_authoritative_even_for_f_prefixed_id() {
1554        // A custom rule chooses its own ID freely, so the built-in `F`-prefix→Fatal
1555        // heuristic must NOT apply: the declared severity is preserved verbatim. This
1556        // also keeps the Rego/Guard boundary at parity with the CEL engine, which emits
1557        // the declared severity directly.
1558        let model = minimal_model();
1559        let val = serde_json::json!({
1560            "rule_id": "FCUSTOM",
1561            "severity": Severity::Error.as_str(),
1562            "message": "type mismatch"
1563        });
1564        let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
1565        assert_eq!(diag.severity, Severity::Error, "declared severity must survive an F-prefixed custom ID");
1566    }
1567
1568    #[test]
1569    fn parse_diagnostic_custom_rule_id_allows_separators_but_rejects_punctuation() {
1570        let model = minimal_model();
1571        let ok = serde_json::json!({
1572            "rule_id": "s3.encryption-required_1",
1573            "severity": Severity::Warn.as_str(),
1574            "message": "ok"
1575        });
1576        assert!(parse_diagnostic(&ok, &model, Some(&RuleOrigin::Custom)).is_ok());
1577
1578        let bad = serde_json::json!({
1579            "rule_id": "bad id/with space",
1580            "severity": Severity::Warn.as_str(),
1581            "message": "bad"
1582        });
1583        let err = parse_diagnostic(&bad, &model, Some(&RuleOrigin::Custom))
1584            .expect_err("a rule_id with whitespace/punctuation must be rejected");
1585        assert!(err.contains("invalid 'rule_id'"), "got: {err}");
1586    }
1587
1588    #[test]
1589    fn parse_diagnostic_warning_severity() {
1590        let model = minimal_model();
1591        let val = serde_json::json!({
1592            "rule_id": "W3045",
1593            "severity": Severity::Warn.as_str(),
1594            "message": "warn"
1595        });
1596        let diag = parse_diagnostic(&val, &model, None).unwrap();
1597        assert_eq!(diag.severity, Severity::Warn);
1598    }
1599
1600    #[test]
1601    fn parse_diagnostic_with_suggested_fix_and_doc_url() {
1602        let model = minimal_model();
1603        let val = serde_json::json!({
1604            "rule_id": "E3012",
1605            "severity": Severity::Error.as_str(),
1606            "message": "bad",
1607            "suggested_fix": "fix it",
1608            "documentation_url": "https://example.com"
1609        });
1610        let diag = parse_diagnostic(&val, &model, None).unwrap();
1611        assert_eq!(diag.suggested_fix.as_deref(), Some("fix it"));
1612        assert_eq!(diag.documentation_url.as_deref(), Some("https://example.com"));
1613    }
1614
1615    #[test]
1616    fn parse_diagnostic_empty_resource_id_treated_as_none() {
1617        let model = minimal_model();
1618        let val = serde_json::json!({
1619            "rule_id": "E3012",
1620            "severity": Severity::Error.as_str(),
1621            "message": "x",
1622            "resource_id": ""
1623        });
1624        let diag = parse_diagnostic(&val, &model, None).unwrap();
1625        assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
1626    }
1627
1628    #[test]
1629    fn parse_diagnostic_accepts_model_matching_explicit_location() {
1630        let model = minimal_model();
1631        let expected = model.resource_span("Bucket", "Properties.BucketName");
1632        let val = serde_json::json!({
1633            "rule_id": "E3012",
1634            "severity": Severity::Error.as_str(),
1635            "message": "x",
1636            "resource_id": "Bucket",
1637            "resource_path": "Properties.BucketName",
1638            "start_line": expected.start_line,
1639            "start_column": expected.start_column,
1640            "end_line": expected.end_line,
1641            "end_column": expected.end_column
1642        });
1643        let diag = parse_diagnostic(&val, &model, None).expect("model-matching coordinates should be accepted");
1644        assert_eq!(diag.location, Some(expected));
1645    }
1646
1647    #[test]
1648    fn parse_diagnostic_rejects_unverified_explicit_location() {
1649        let model = minimal_model();
1650        let val = serde_json::json!({
1651            "rule_id": "XCUSTOM",
1652            "severity": Severity::Error.as_str(),
1653            "message": "x",
1654            "resource_id": "Bucket",
1655            "resource_path": "Properties.BucketName",
1656            "start_line": 42,
1657            "start_column": 7,
1658            "end_line": 43,
1659            "end_column": 9
1660        });
1661        let error = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom))
1662            .expect_err("custom coordinates outside the declared property must be rejected");
1663        assert!(error.contains("does not match model-derived span"), "got: {error}");
1664    }
1665
1666    #[test]
1667    fn parse_diagnostic_accepts_verified_conditional_source_path() {
1668        let model = SemanticModel::from_bytes(
1669            br#"
1670Parameters:
1671  Mode: {Type: String}
1672Conditions:
1673  UseFirst: !Equals [!Ref Mode, first]
1674Resources:
1675  Record:
1676    Type: Custom::Record
1677    Properties:
1678      Values: !If
1679        - UseFirst
1680        - [invalid]
1681        - [valid]
1682"#,
1683        )
1684        .expect("conditional model should parse");
1685        let source_path = "Properties.Values.Fn::If.1.0";
1686        let expected = model.resource_span("Record", source_path);
1687        let val = serde_json::json!({
1688            "rule_id": "E3012",
1689            "severity": Severity::Error.as_str(),
1690            "message": "x",
1691            "resource_id": "Record",
1692            "resource_path": "Properties.Values.0",
1693            (DIAGNOSTIC_SOURCE_PATH_FIELD): source_path,
1694            "start_line": expected.start_line,
1695            "start_column": expected.start_column,
1696            "end_line": expected.end_line,
1697            "end_column": expected.end_column
1698        });
1699        let diag = parse_diagnostic(&val, &model, None).expect("verified conditional source path should be accepted");
1700        assert_eq!(diag.location, Some(expected));
1701    }
1702
1703    #[test]
1704    fn parse_diagnostic_rejects_source_path_for_another_property() {
1705        let model = minimal_model();
1706        let expected = model.resource_span("Bucket", "Properties.BucketName");
1707        let val = serde_json::json!({
1708            "rule_id": "E3012",
1709            "severity": Severity::Error.as_str(),
1710            "message": "x",
1711            "resource_id": "Bucket",
1712            "resource_path": "Properties.BucketName",
1713            (DIAGNOSTIC_SOURCE_PATH_FIELD): "Properties.Tags.Fn::If.1.0",
1714            "start_line": expected.start_line,
1715            "start_column": expected.start_column,
1716            "end_line": expected.end_line,
1717            "end_column": expected.end_column
1718        });
1719        let error = parse_diagnostic(&val, &model, None)
1720            .expect_err("a source path for another property must not relocate the diagnostic");
1721        assert!(error.contains("instead of resource path 'Properties.BucketName'"), "got: {error}");
1722    }
1723
1724    #[test]
1725    fn parse_diagnostic_unknown_rule_uses_json_category() {
1726        let model = minimal_model();
1727        let val = serde_json::json!({
1728            "rule_id": "XUNKNOWN",
1729            "severity": Severity::Error.as_str(),
1730            "message": "x",
1731            "category": "my-category"
1732        });
1733        let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
1734        assert_eq!(diag.category.as_deref(), Some("my-category"));
1735    }
1736
1737    #[test]
1738    fn parse_diagnostic_builtin_rule_ignores_json_category_and_uses_registry() {
1739        let model = minimal_model();
1740        let val = serde_json::json!({
1741            "rule_id": "E3012",
1742            "severity": Severity::Error.as_str(),
1743            "message": "x",
1744            "category": "general"
1745        });
1746        let diag = parse_diagnostic(&val, &model, None).unwrap();
1747        let expected = lookup_rule("E3012").unwrap().category.as_str();
1748        assert_eq!(
1749            diag.category.as_deref(),
1750            Some(expected),
1751            "a built-in rule takes its category from the registry, ignoring any category in the engine JSON"
1752        );
1753    }
1754
1755    #[test]
1756    fn parse_diagnostic_with_related_locations() {
1757        let model = minimal_model();
1758        let val = serde_json::json!({
1759            "rule_id": "E3012",
1760            "severity": Severity::Error.as_str(),
1761            "message": "x",
1762            "related_locations": [{
1763                "resource_id": "Bucket",
1764                "start_line": 1,
1765                "start_column": 2,
1766                "end_line": 3,
1767                "end_column": 4,
1768                "message": "related"
1769            }]
1770        });
1771        let diag = parse_diagnostic(&val, &model, None).unwrap();
1772        assert_eq!(diag.related_resources.as_ref().unwrap().len(), 1);
1773        assert_eq!(diag.related_resources.as_ref().unwrap()[0].message, "related");
1774    }
1775
1776    #[test]
1777    fn parse_diagnostic_with_condition_scenario() {
1778        let model = minimal_model();
1779        let val = serde_json::json!({
1780            "rule_id": "E3012",
1781            "severity": Severity::Error.as_str(),
1782            "message": "x",
1783            "condition_scenario": {"IsProd": true}
1784        });
1785        let diag = parse_diagnostic(&val, &model, None).unwrap();
1786        let scenario = diag.condition_scenario.unwrap();
1787        assert_eq!(scenario.get("IsProd"), Some(&true));
1788    }
1789
1790    #[test]
1791    fn extract_diagnostics_valid_array() {
1792        let model = minimal_model();
1793        let json = serde_json::json!([
1794            {"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "a"},
1795            {"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "b"}
1796        ]);
1797        let mut out = Vec::new();
1798        extract_diagnostics(&json.to_string(), &model, &mut out, None).unwrap();
1799        assert_eq!(out.len(), 2);
1800        assert_eq!(out[0].rule_id, "E3012");
1801        assert_eq!(out[1].rule_id, "W3045");
1802    }
1803
1804    #[test]
1805    fn extract_diagnostics_invalid_json_is_error() {
1806        let model = minimal_model();
1807        let mut out = Vec::new();
1808        let result = extract_diagnostics("not json", &model, &mut out, None);
1809        result.unwrap_err();
1810    }
1811
1812    #[test]
1813    fn extract_diagnostics_non_array_json_is_error() {
1814        let model = minimal_model();
1815        let mut out = Vec::new();
1816        let result = extract_diagnostics(r#"{"key": "value"}"#, &model, &mut out, None);
1817        result.unwrap_err();
1818    }
1819
1820    #[test]
1821    fn extract_diagnostics_fails_on_missing_required_fields() {
1822        let model = minimal_model();
1823        let json = serde_json::json!([
1824            {"rule_id": "E3012", "severity": Severity::Error.as_str(), "message": "ok"},
1825            {"bad": "item"},
1826            {"rule_id": "W3045", "severity": Severity::Warn.as_str(), "message": "ok2"}
1827        ]);
1828        let mut out = Vec::new();
1829        let result = extract_diagnostics(&json.to_string(), &model, &mut out, None);
1830        result.unwrap_err();
1831        assert_eq!(out.len(), 1, "first valid item should have been added before failure");
1832    }
1833
1834    #[test]
1835    fn build_report_counts_severities() {
1836        let model = minimal_model();
1837        let diags = vec![
1838            Diagnostic {
1839                rule_id: "F3012".into(),
1840                severity: Severity::Fatal,
1841                message: "fatal".into(),
1842                ..default_diag()
1843            },
1844            Diagnostic {
1845                rule_id: "E3012".into(),
1846                severity: Severity::Error,
1847                message: "error".into(),
1848                ..default_diag()
1849            },
1850            Diagnostic {
1851                rule_id: "E3013".into(),
1852                severity: Severity::Error,
1853                message: "error2".into(),
1854                ..default_diag()
1855            },
1856            Diagnostic { rule_id: "W3045".into(), severity: Severity::Warn, message: "warn".into(), ..default_diag() },
1857            Diagnostic { severity: Severity::Info, message: "info".into(), ..default_diag() },
1858        ];
1859        let report = build_report_with_versions(
1860            diags,
1861            &model,
1862            3,
1863            Some(50),
1864            false,
1865            Severity::Info,
1866            String::new(),
1867            TEST_CFN_LINT_VERSION.to_string(),
1868            TEST_RESOURCE_SCHEMA_VERSION.to_string(),
1869        );
1870        assert_eq!(report.metadata.counts.fatal, 1);
1871        assert_eq!(report.metadata.counts.errors, 2);
1872        assert_eq!(report.metadata.counts.warnings, 1);
1873        assert_eq!(report.metadata.counts.informational, 1);
1874        assert_eq!(report.metadata.suppressed, 3);
1875        assert_eq!(report.metadata.rules_evaluated, 50);
1876        assert_eq!(report.metadata.cfn_lint_version, TEST_CFN_LINT_VERSION);
1877        assert_eq!(report.metadata.resource_schema_version, TEST_RESOURCE_SCHEMA_VERSION);
1878        assert!(!report.metadata.strict, "default mode should not be strict");
1879        assert_eq!(report.metadata.severity_level, Severity::Info);
1880        assert_eq!(report.metadata.resources_scanned, 1);
1881        assert_eq!(report.diagnostics.len(), 5);
1882    }
1883
1884    #[test]
1885    fn build_report_empty_diagnostics() {
1886        let model = minimal_model();
1887        let report = build_report_with_versions(
1888            vec![],
1889            &model,
1890            0,
1891            None,
1892            true,
1893            Severity::Error,
1894            String::new(),
1895            TEST_CFN_LINT_VERSION.to_string(),
1896            TEST_RESOURCE_SCHEMA_VERSION.to_string(),
1897        );
1898        assert_eq!(report.metadata.rules_evaluated, 0);
1899        assert_eq!(report.metadata.counts.fatal, 0);
1900        assert_eq!(report.metadata.counts.errors, 0);
1901        assert_eq!(report.metadata.counts.warnings, 0);
1902        assert!(report.metadata.strict, "strict mode should be enabled");
1903        assert_eq!(report.metadata.severity_level, Severity::Error);
1904        assert!(report.diagnostics.is_empty());
1905    }
1906
1907    #[test]
1908    fn build_report_debug_severity_counted() {
1909        let model = minimal_model();
1910        let diags = vec![Diagnostic { severity: Severity::Debug, message: "dbg".into(), ..default_diag() }];
1911        let report = build_report_with_versions(
1912            diags,
1913            &model,
1914            0,
1915            None,
1916            false,
1917            Severity::Debug,
1918            String::new(),
1919            TEST_CFN_LINT_VERSION.to_string(),
1920            TEST_RESOURCE_SCHEMA_VERSION.to_string(),
1921        );
1922        assert_eq!(report.metadata.counts.debug, 1);
1923        assert_eq!(report.metadata.counts.informational, 0);
1924    }
1925
1926    #[test]
1927    fn build_rule_list_sorted_by_id() {
1928        let mut meta = HashMap::new();
1929        meta.insert(
1930            "E3012".into(),
1931            RuleMetadataEntry {
1932                category: Some("schema".into()),
1933                description: "Type check".into(),
1934                severity: Severity::Error,
1935                origin: RuleOrigin::Engine,
1936            },
1937        );
1938        meta.insert(
1939            "E0001".into(),
1940            RuleMetadataEntry {
1941                category: Some("structure".into()),
1942                description: "Resources".into(),
1943                severity: Severity::Error,
1944                origin: RuleOrigin::Engine,
1945            },
1946        );
1947        meta.insert(
1948            "W3045".into(),
1949            RuleMetadataEntry {
1950                category: Some("schema".into()),
1951                description: "Enum".into(),
1952                severity: Severity::Warn,
1953                origin: RuleOrigin::Engine,
1954            },
1955        );
1956        let rules = build_rule_list(&meta, &HashMap::new());
1957        assert_eq!(rules.len(), 3);
1958        assert_eq!(rules[0].id, "E0001");
1959        assert_eq!(rules[1].id, "E3012");
1960        assert_eq!(rules[2].id, "W3045");
1961    }
1962
1963    #[test]
1964    fn build_rule_list_uses_stored_severity() {
1965        let mut meta = HashMap::new();
1966        meta.insert(
1967            "F3012".into(),
1968            RuleMetadataEntry {
1969                category: Some("schema".into()),
1970                description: "desc".into(),
1971                severity: Severity::Fatal,
1972                origin: RuleOrigin::Engine,
1973            },
1974        );
1975        meta.insert(
1976            "E3012".into(),
1977            RuleMetadataEntry {
1978                category: Some("schema".into()),
1979                description: "desc".into(),
1980                severity: Severity::Error,
1981                origin: RuleOrigin::Engine,
1982            },
1983        );
1984        meta.insert(
1985            "W3045".into(),
1986            RuleMetadataEntry {
1987                category: Some("schema".into()),
1988                description: "desc".into(),
1989                severity: Severity::Warn,
1990                origin: RuleOrigin::Engine,
1991            },
1992        );
1993        meta.insert(
1994            "I9001".into(),
1995            RuleMetadataEntry {
1996                category: Some("best-practice".into()),
1997                description: "desc".into(),
1998                severity: Severity::Info,
1999                origin: RuleOrigin::Engine,
2000            },
2001        );
2002        let rules = build_rule_list(&meta, &HashMap::new());
2003        let find = |id: &str| rules.iter().find(|r| r.id == id).unwrap();
2004        assert_eq!(find("F3012").severity, Severity::Fatal);
2005        assert_eq!(find("E3012").severity, Severity::Error);
2006        assert_eq!(find("W3045").severity, Severity::Warn);
2007        assert_eq!(find("I9001").severity, Severity::Info);
2008    }
2009
2010    #[test]
2011    fn build_rule_list_schema_category_gets_schema_origin() {
2012        let mut meta = HashMap::new();
2013        meta.insert(
2014            "E3012".into(),
2015            RuleMetadataEntry {
2016                category: Some("schema".into()),
2017                description: "desc".into(),
2018                severity: Severity::Error,
2019                origin: RuleOrigin::Schema,
2020            },
2021        );
2022        meta.insert(
2023            "E0001".into(),
2024            RuleMetadataEntry {
2025                category: Some("structure".into()),
2026                description: "desc".into(),
2027                severity: Severity::Error,
2028                origin: RuleOrigin::Engine,
2029            },
2030        );
2031        let rules = build_rule_list(&meta, &HashMap::new());
2032        let schema_rule = rules.iter().find(|r| r.id == "E3012").unwrap();
2033        assert!(matches!(schema_rule.origin, RuleOrigin::Schema));
2034        let struct_rule = rules.iter().find(|r| r.id == "E0001").unwrap();
2035        assert!(matches!(struct_rule.origin, RuleOrigin::Engine));
2036    }
2037
2038    fn make_diag(rule_id: &str, severity: Severity, line: u32, col: u32) -> Diagnostic {
2039        Diagnostic {
2040            rule_id: rule_id.into(),
2041            severity,
2042            message: format!("msg for {}", rule_id),
2043            location: Some(SourceSpan { start_line: line, start_column: col, end_line: line, end_column: col }),
2044            ..default_diag()
2045        }
2046    }
2047
2048    fn make_transform_error_diag() -> Diagnostic {
2049        Diagnostic {
2050            message: format!(
2051                "{} Resource with id [Fn] is invalid. 'AutoPublishAlias' must be a string or a Ref to a template parameter",
2052                SAM_TRANSFORM_ERROR_PREFIX
2053            ),
2054            ..make_diag(SAM_TRANSFORM_ERROR_RULE_ID, Severity::Error, 1, 1)
2055        }
2056    }
2057
2058    #[test]
2059    fn gate_drops_non_transform_diagnostics_when_transform_error_present() {
2060        let mut diags = vec![
2061            make_diag("E3012", Severity::Error, 5, 1),
2062            make_transform_error_diag(),
2063            make_diag("I9040", Severity::Info, 7, 1),
2064        ];
2065        gate_sam_transform_errors(&mut diags);
2066        assert_eq!(diags.len(), 1);
2067        assert!(is_sam_transform_error_message(&diags[0].message));
2068    }
2069
2070    #[test]
2071    fn gate_keeps_all_diagnostics_when_no_transform_error() {
2072        let mut diags = vec![make_diag("E3012", Severity::Error, 5, 1), make_diag("I9040", Severity::Info, 7, 1)];
2073        gate_sam_transform_errors(&mut diags);
2074        assert_eq!(diags.len(), 2);
2075    }
2076
2077    #[test]
2078    fn finalize_sorts_by_severity_then_rule_number_then_location() {
2079        let config = ValidateConfig::default();
2080        let mut diags = vec![
2081            make_diag("W3045", Severity::Warn, 10, 1),
2082            make_diag("E3012", Severity::Error, 5, 1),
2083            make_diag("F3012", Severity::Fatal, 5, 1),
2084        ];
2085        finalize_diagnostics(&mut diags, &config);
2086        assert_eq!(diags.len(), 3);
2087        // Severity orders first: Fatal, then Error, then Warn - regardless of location.
2088        assert_eq!(diags[0].rule_id, "F3012");
2089        assert_eq!(diags[1].rule_id, "E3012");
2090        assert_eq!(diags[2].rule_id, "W3045");
2091    }
2092
2093    #[test]
2094    fn finalize_orders_same_severity_by_rule_number() {
2095        let config = ValidateConfig::default();
2096        // Higher-numbered rule appears earlier in the file; rule number must still win.
2097        let mut diags = vec![make_diag("E3999", Severity::Error, 1, 1), make_diag("E3012", Severity::Error, 50, 1)];
2098        finalize_diagnostics(&mut diags, &config);
2099        assert_eq!(diags[0].rule_id, "E3012", "lower rule number sorts first within a severity");
2100        assert_eq!(diags[1].rule_id, "E3999");
2101    }
2102
2103    #[test]
2104    fn finalize_deduplicates_same_span_rule_message() {
2105        let config = ValidateConfig::default();
2106        let d = make_diag("E3012", Severity::Error, 5, 1);
2107        let mut diags = vec![d.clone(), d];
2108        let (total_before, suppressed) = finalize_diagnostics(&mut diags, &config);
2109        assert_eq!(total_before, 2);
2110        assert_eq!(suppressed, 1);
2111        assert_eq!(diags.len(), 1);
2112    }
2113
2114    #[test]
2115    fn finalize_keeps_f_and_e_as_separate_diagnostics() {
2116        let config = ValidateConfig::default();
2117        let mut diags = vec![make_diag("F3012", Severity::Fatal, 5, 1), make_diag("E3012", Severity::Error, 5, 1)];
2118        let (_, suppressed) = finalize_diagnostics(&mut diags, &config);
2119        assert_eq!(suppressed, 0);
2120        assert_eq!(diags.len(), 2);
2121        assert_eq!(diags[0].rule_id, "F3012");
2122        assert_eq!(diags[1].rule_id, "E3012");
2123    }
2124
2125    #[test]
2126    fn finalize_severity_filter_retains_fatal_always() {
2127        let config = ValidateConfig { severity_level: Severity::Error, strict: false, ..Default::default() };
2128        let mut diags = vec![
2129            make_diag("F3012", Severity::Fatal, 1, 1),
2130            make_diag("E3012", Severity::Error, 2, 1),
2131            make_diag("W3045", Severity::Warn, 3, 1),
2132            Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
2133        ];
2134        finalize_diagnostics(&mut diags, &config);
2135        assert!(
2136            diags.iter().all(|d| d.severity >= Severity::Error),
2137            "all diagnostics should be Error or above after filtering"
2138        );
2139        assert_eq!(diags.len(), 2);
2140    }
2141
2142    #[test]
2143    fn finalize_different_messages_not_deduped() {
2144        let config = ValidateConfig::default();
2145        let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
2146        let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
2147        d1.message = "message A".into();
2148        d2.message = "message B".into();
2149        let mut diags = vec![d1, d2];
2150        finalize_diagnostics(&mut diags, &config);
2151        assert_eq!(diags.len(), 2);
2152    }
2153
2154    /// Regression for a dedup bug where the same rule/message/line was duplicated
2155    /// across native and WASM builds, separated by a sibling diagnostic.
2156    ///
2157    /// Two diagnostics for rule X message M at the same line/col, separated by a sibling
2158    /// for the same rule but a different message M', must still be deduped. Before the fix
2159    /// the sort key was (line, col, severity, rule_id) which treated all three as equal;
2160    /// stable sort preserved insertion order [M, M', M] and `dedup_by` (consecutive-only)
2161    /// skipped the outer pair. HashMap iteration order means insertion order is random,
2162    /// so the dedup worked or failed per-process - native fired twice, WASM once.
2163    #[test]
2164    fn finalize_dedups_same_rule_message_across_sibling_with_different_message() {
2165        let config = ValidateConfig::default();
2166        let mut a1 = make_diag("W2506", Severity::Warn, 125, 1);
2167        let mut sib = make_diag("W2506", Severity::Warn, 125, 1);
2168        let mut a2 = make_diag("W2506", Severity::Warn, 125, 1);
2169        a1.message = "param pWebServerAMI".into();
2170        sib.message = "param pAppAmi".into();
2171        a2.message = "param pWebServerAMI".into();
2172        let mut diags = vec![a1, sib, a2];
2173        finalize_diagnostics(&mut diags, &config);
2174        assert_eq!(diags.len(), 2, "W2506 pWebServerAMI must dedup across sibling");
2175        let msgs: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
2176        assert!(msgs.contains(&"param pAppAmi"));
2177        assert!(msgs.contains(&"param pWebServerAMI"));
2178    }
2179
2180    #[test]
2181    fn finalize_strict_upgrades_warnings_to_errors() {
2182        let config = ValidateConfig { strict: true, ..Default::default() };
2183        let mut diags = vec![
2184            make_diag("W3045", Severity::Warn, 1, 1),
2185            make_diag("E3012", Severity::Error, 2, 1),
2186            make_diag("F3012", Severity::Fatal, 3, 1),
2187        ];
2188        finalize_diagnostics(&mut diags, &config);
2189        let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
2190        assert_eq!(severity_of("W3045"), Some(Severity::Error), "Warn should be upgraded to Error");
2191        assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
2192        assert_eq!(severity_of("F3012"), Some(Severity::Fatal), "Fatal should stay Fatal");
2193    }
2194
2195    #[test]
2196    fn finalize_non_strict_preserves_warning_severity() {
2197        let config = ValidateConfig { strict: false, ..Default::default() };
2198        let mut diags = vec![make_diag("W3045", Severity::Warn, 1, 1), make_diag("E3012", Severity::Error, 2, 1)];
2199        finalize_diagnostics(&mut diags, &config);
2200        let severity_of = |id: &str| diags.iter().find(|d| d.rule_id == id).map(|d| d.severity);
2201        assert_eq!(severity_of("W3045"), Some(Severity::Warn), "Warn should be preserved");
2202        assert_eq!(severity_of("E3012"), Some(Severity::Error), "Error should stay Error");
2203    }
2204
2205    #[test]
2206    fn finalize_disable_builtin_rules_keeps_only_custom_and_guard() {
2207        let config = ValidateConfig { disable_builtin_rules: true, ..Default::default() };
2208        let mut diags = vec![
2209            make_diag("F3006", Severity::Fatal, 1, 1),
2210            Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
2211            Diagnostic { source: RuleOrigin::Guard, ..make_diag("GUARD001", Severity::Warn, 3, 1) },
2212            make_diag("E3012", Severity::Error, 4, 1),
2213        ];
2214        finalize_diagnostics(&mut diags, &config);
2215        assert_eq!(diags.len(), 2);
2216        assert_eq!(diags[0].rule_id, "CUSTOM001");
2217        assert_eq!(diags[1].rule_id, "GUARD001");
2218    }
2219
2220    #[test]
2221    fn finalize_default_config_keeps_all_origins() {
2222        let config = ValidateConfig::default();
2223        let mut diags = vec![
2224            make_diag("F3006", Severity::Fatal, 1, 1),
2225            Diagnostic { source: RuleOrigin::Custom, ..make_diag("CUSTOM001", Severity::Warn, 2, 1) },
2226        ];
2227        finalize_diagnostics(&mut diags, &config);
2228        assert_eq!(diags.len(), 2);
2229    }
2230
2231    #[test]
2232    fn enrich_adds_phase_and_description() {
2233        let model = minimal_model();
2234        let meta = meta_map();
2235        let mut diags = vec![Diagnostic {
2236            rule_id: "E3012".into(),
2237            severity: Severity::Error,
2238            message: "x".into(),
2239            entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2240            category: Some(Category::Schema.as_str().into()),
2241            ..default_diag()
2242        }];
2243        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2244        assert_eq!(diags[0].phase, Some(Phase::Lint));
2245        assert!(diags[0].rule_description.is_some(), "enriched diagnostic should have rule_description");
2246    }
2247
2248    #[test]
2249    fn enrich_parse_category_gets_parse_phase() {
2250        let model = minimal_model();
2251        let meta = meta_map();
2252        let mut diags = vec![Diagnostic {
2253            rule_id: "F0000".into(),
2254            severity: Severity::Fatal,
2255            message: "dup".into(),
2256            category: Some(Category::Structure.as_str().into()),
2257            phase: Some(Phase::Parse),
2258            ..default_diag()
2259        }];
2260        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2261        assert_eq!(diags[0].phase, Some(Phase::Parse));
2262    }
2263
2264    #[test]
2265    fn enrich_fatal_rule_gets_schema_phase() {
2266        let model = minimal_model();
2267        let meta = meta_map();
2268        let mut diags = vec![Diagnostic {
2269            rule_id: "F3012".into(),
2270            severity: Severity::Fatal,
2271            message: "x".into(),
2272            ..default_diag()
2273        }];
2274        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2275        assert_eq!(diags[0].phase, Some(Phase::Schema));
2276    }
2277
2278    #[test]
2279    fn enrich_custom_rule_gets_no_phase() {
2280        let model = minimal_model();
2281        let meta = meta_map();
2282        let mut diags = vec![Diagnostic {
2283            rule_id: "Firewall".into(),
2284            severity: Severity::Error,
2285            message: "x".into(),
2286            source: RuleOrigin::Custom,
2287            ..default_diag()
2288        }];
2289        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2290        assert_eq!(diags[0].phase, None, "custom rules must not be assigned a phase");
2291    }
2292
2293    #[test]
2294    fn enrich_full_format_builds_context() {
2295        let model = minimal_model();
2296        let meta = meta_map();
2297        let mut diags = vec![Diagnostic {
2298            rule_id: "E3012".into(),
2299            severity: Severity::Error,
2300            message: "x".into(),
2301            entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2302            property_path: Some("Properties.BucketName".into()),
2303            ..default_diag()
2304        }];
2305        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Detailed);
2306        assert!(diags[0].context.is_some(), "enriched diagnostic should have context");
2307    }
2308
2309    #[test]
2310    fn enrich_standard_format_skips_enrichment() {
2311        let model = minimal_model();
2312        let meta = meta_map();
2313        let mut diags = vec![Diagnostic {
2314            rule_id: "E3012".into(),
2315            severity: Severity::Error,
2316            message: "x".into(),
2317            entity: Entity::resource("Bucket", Some("AWS::S3::Bucket".into())),
2318            property_path: Some("Properties.BucketName".into()),
2319            ..default_diag()
2320        }];
2321        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
2322        assert!(diags[0].phase.is_none(), "unenriched diagnostic should have no phase");
2323        assert!(diags[0].rule_description.is_none(), "unenriched diagnostic should have no rule_description");
2324        assert!(diags[0].context.is_none(), "unenriched diagnostic should have no context");
2325    }
2326
2327    #[test]
2328    fn make_resource_diagnostic_known_rule() {
2329        let model = minimal_model();
2330        let diag = make_resource_diagnostic(
2331            "E3012",
2332            "Type mismatch on BucketName",
2333            &model,
2334            "Bucket",
2335            "Properties.BucketName",
2336            Some("Use a string"),
2337        );
2338        assert_eq!(diag.rule_id, "E3012");
2339        assert_eq!(diag.severity, Severity::Error);
2340        assert_eq!(diag.resource_logical_id(), Some("Bucket"));
2341        assert_eq!(diag.suggested_fix.as_deref(), Some("Use a string"));
2342    }
2343
2344    #[test]
2345    #[should_panic(expected = "Rule 'XBOGUS' not found in RULE_REGISTRY")]
2346    fn make_resource_diagnostic_unknown_rule_panics() {
2347        let model = minimal_model();
2348        make_resource_diagnostic("XBOGUS", "msg", &model, "Bucket", "", None);
2349    }
2350
2351    #[test]
2352    fn make_resource_diagnostic_empty_resource_id() {
2353        let model = minimal_model();
2354        let diag = make_resource_diagnostic("E3012", "msg", &model, "", "", None);
2355        assert!(diag.entity.is_none(), "diagnostic without resource_id should have no entity");
2356    }
2357
2358    #[test]
2359    fn build_context_no_resource_returns_none() {
2360        let model = minimal_model();
2361        assert!(build_context("E3012", None, "Properties.X", &model).is_none(), "no resource should return None");
2362    }
2363
2364    #[test]
2365    fn build_context_unknown_rule_returns_none() {
2366        let model = minimal_model();
2367        assert!(
2368            build_context("XUNKNOWN", Some("Bucket"), "Properties.X", &model).is_none(),
2369            "unknown rule should return None"
2370        );
2371    }
2372
2373    #[test]
2374    fn build_context_e3012_with_property_path() {
2375        let model = minimal_model();
2376        let ctx = build_context("E3012", Some("Bucket"), "Properties.BucketName", &model)
2377            .expect("E3012 with property path should return context");
2378        assert!(ctx.actual_value.is_some(), "context should have actual_value");
2379    }
2380
2381    #[test]
2382    fn build_context_runtime_rules_carry_actual_value_and_lifecycle() {
2383        let yaml = br#"
2384Resources:
2385  Fn:
2386    Type: AWS::Lambda::Function
2387    Properties:
2388      Runtime: nodejs4.3
2389"#;
2390        let model = SemanticModel::from_bytes(yaml).expect("model with lambda runtime");
2391        for (rule_id, expected_lifecycle) in
2392            [("E2533", "end-of-life"), ("E2531", "create-blocked"), ("W2531", "deprecated")]
2393        {
2394            let ctx = build_context(rule_id, Some("Fn"), "Properties.Runtime", &model)
2395                .unwrap_or_else(|| panic!("{rule_id} should return context"));
2396            assert_eq!(
2397                ctx.actual_value.as_ref().and_then(|v| v.as_str()),
2398                Some("nodejs4.3"),
2399                "{rule_id} context should carry the offending runtime string"
2400            );
2401            assert_eq!(ctx.lifecycle.as_deref(), Some(expected_lifecycle), "{rule_id} lifecycle marker");
2402        }
2403    }
2404
2405    #[test]
2406    fn build_context_runtime_omits_actual_value_when_branches_disagree() {
2407        // Runtime driven by Fn::If resolves to a different value per branch, and the
2408        // emitter produces one diagnostic per branch. Attaching one branch's value to
2409        // all would contradict the sibling messages, so no actual_value is set; the
2410        // lifecycle marker is still safe to attach.
2411        let yaml = br#"
2412Parameters:
2413  Env:
2414    Type: String
2415Conditions:
2416  IsProd: !Equals [!Ref Env, prod]
2417Resources:
2418  Fn:
2419    Type: AWS::Lambda::Function
2420    Properties:
2421      Runtime: !If [IsProd, dotnetcore2.1, nodejs4.3]
2422"#;
2423        let model = SemanticModel::from_bytes(yaml).expect("model with conditional runtime");
2424        let ctx = build_context("E2533", Some("Fn"), "Properties.Runtime", &model)
2425            .expect("E2533 should still return context (lifecycle)");
2426        assert_eq!(ctx.lifecycle.as_deref(), Some("end-of-life"), "lifecycle is unconditionally safe");
2427        assert!(
2428            ctx.actual_value.is_none(),
2429            "actual_value must be omitted when branches resolve to different runtimes, got {:?}",
2430            ctx.actual_value
2431        );
2432    }
2433
2434    #[test]
2435    fn backfill_locations_only_fills_missing_and_resolves_resource_property() {
2436        let yaml = br#"
2437Resources:
2438  Bucket:
2439    Type: AWS::S3::Bucket
2440    Properties:
2441      BucketName: my-bucket
2442"#;
2443        let model = SemanticModel::from_bytes(yaml).expect("model");
2444        let existing = SourceSpan { start_line: 999, start_column: 1, end_line: 999, end_column: 2 };
2445        let mut diags = vec![
2446            // Missing location: should be backfilled from the resource property span.
2447            Diagnostic {
2448                rule_id: "E3012".into(),
2449                severity: Severity::Error,
2450                message: "x".into(),
2451                entity: Entity::resource("Bucket", None),
2452                property_path: Some("Properties.BucketName".into()),
2453                ..default_diag()
2454            },
2455            // Already located: must be left untouched.
2456            Diagnostic {
2457                rule_id: "E3013".into(),
2458                severity: Severity::Error,
2459                message: "y".into(),
2460                location: Some(existing),
2461                ..default_diag()
2462            },
2463        ];
2464        backfill_locations(&mut diags, &model);
2465        assert!(diags[0].location.is_some(), "missing location should be backfilled");
2466        assert_ne!(diags[0].location, Some(UNKNOWN_SPAN), "backfilled location must be a real span");
2467        assert_eq!(diags[1].location, Some(existing), "existing location must be preserved");
2468    }
2469
2470    #[test]
2471    fn backfill_entities_derives_from_entity_paths_without_overriding() {
2472        let model = minimal_model();
2473        let mut diags = vec![
2474            // Resource finding: entity was attached at emission and must be kept.
2475            Diagnostic {
2476                rule_id: "E3012".into(),
2477                severity: Severity::Error,
2478                message: "x".into(),
2479                entity: Entity::resource("Bucket", None),
2480                ..default_diag()
2481            },
2482            // Non-resource entity finding: derived from the section-absolute path.
2483            Diagnostic {
2484                rule_id: "W2001".into(),
2485                severity: Severity::Warn,
2486                message: "y".into(),
2487                property_path: Some("Parameters/MyParam".into()),
2488                ..default_diag()
2489            },
2490            // Template-level finding: no entity.
2491            Diagnostic { rule_id: "F0001".into(), severity: Severity::Fatal, message: "z".into(), ..default_diag() },
2492        ];
2493        backfill_entities(&mut diags, &model);
2494        let resource = diags[0].entity.as_ref().expect("resource entity preserved");
2495        assert_eq!(resource.logical_id, "Bucket");
2496        assert_eq!(resource.entity_type, EntityType::Resource);
2497        assert_eq!(
2498            resource.resource_type.as_deref(),
2499            Some("AWS::S3::Bucket"),
2500            "missing resource type is filled from the model"
2501        );
2502        let parameter = diags[1].entity.as_ref().expect("parameter entity derived from path");
2503        assert_eq!(parameter.logical_id, "MyParam");
2504        assert_eq!(parameter.entity_type, EntityType::Parameter);
2505        assert_eq!(parameter.resource_type, None);
2506        assert!(diags[2].entity.is_none(), "template-level finding has no entity");
2507    }
2508
2509    #[test]
2510    fn build_context_e3012_empty_path_returns_none() {
2511        let model = minimal_model();
2512        let ctx = build_context("E3012", Some("Bucket"), "", &model);
2513        assert!(ctx.is_none(), "E3012 with empty path should return None");
2514    }
2515
2516    #[test]
2517    fn build_context_f3002_extracts_property_name() {
2518        let model = minimal_model();
2519        let ctx = build_context("F3002", Some("Bucket"), "Properties.BucketName", &model)
2520            .expect("F3002 should return context");
2521        assert_eq!(ctx.property.as_deref(), Some("BucketName"));
2522    }
2523
2524    #[test]
2525    fn build_context_w3042_sets_deprecated_lifecycle() {
2526        let model = minimal_model();
2527        let ctx = build_context("W9009", Some("Bucket"), "", &model).expect("W9009 should return context");
2528        assert_eq!(ctx.lifecycle.as_deref(), Some("deprecated"));
2529    }
2530
2531    #[test]
2532    fn build_context_i3043_sets_create_only_lifecycle() {
2533        let model = minimal_model();
2534        let ctx = build_context("I9001", Some("Bucket"), "", &model).expect("I9001 should return context");
2535        assert_eq!(ctx.lifecycle.as_deref(), Some("create-only"));
2536    }
2537
2538    #[test]
2539    fn build_context_w9054_sets_write_only_lifecycle() {
2540        let model = minimal_model();
2541        let ctx = build_context("W9054", Some("Bucket"), "", &model).expect("W9054 should return context");
2542        assert_eq!(ctx.lifecycle.as_deref(), Some("write-only"));
2543    }
2544
2545    #[test]
2546    fn parse_diagnostic_debug_severity() {
2547        let model = minimal_model();
2548        let val = serde_json::json!({
2549            "rule_id": "D9999",
2550            "severity": Severity::Debug.as_str(),
2551            "message": "dbg"
2552        });
2553        let diag = parse_diagnostic(&val, &model, Some(&RuleOrigin::Custom)).unwrap();
2554        assert_eq!(diag.severity, Severity::Debug);
2555    }
2556
2557    #[test]
2558    fn parse_diagnostic_no_resource_no_location_resolves_via_section_span() {
2559        let model = minimal_model();
2560        let val = serde_json::json!({
2561            "rule_id": "F0001",
2562            "severity": Severity::Fatal.as_str(),
2563            "message": "no resources"
2564        });
2565        let diag = parse_diagnostic(&val, &model, None).unwrap();
2566        assert_eq!(diag.rule_id, "F0001");
2567    }
2568
2569    #[test]
2570    fn parse_diagnostic_builtin_rule_category_comes_from_registry() {
2571        let model = minimal_model();
2572        let val = serde_json::json!({
2573            "rule_id": "E3012",
2574            "severity": Severity::Error.as_str(),
2575            "message": "x"
2576        });
2577        let diag = parse_diagnostic(&val, &model, None).unwrap();
2578        let expected = lookup_rule("E3012").unwrap().category.as_str();
2579        assert_eq!(diag.category.as_deref(), Some(expected));
2580    }
2581
2582    #[test]
2583    fn build_context_e3030_resolves_actual_value() {
2584        let model = minimal_model();
2585        let ctx = build_context("E3030", Some("Bucket"), "Properties.BucketName", &model)
2586            .expect("E3030 should return context");
2587        assert!(ctx.actual_value.is_some(), "E3030 context should have actual_value");
2588    }
2589
2590    #[test]
2591    fn build_context_f3006_adds_resource_type() {
2592        let model = minimal_model();
2593        let ctx = build_context("F3006", Some("Bucket"), "", &model).expect("F3006 should return context");
2594        let extra = ctx.extra.unwrap();
2595        assert_eq!(extra.get("resource_type").and_then(|v| v.as_str()), Some("AWS::S3::Bucket"));
2596    }
2597
2598    #[test]
2599    fn build_context_f3003_noop_returns_none() {
2600        let model = minimal_model();
2601        assert!(build_context("F3003", Some("Bucket"), "", &model).is_none(), "F3003 should return None");
2602    }
2603
2604    #[test]
2605    fn build_context_e3053_with_property_path() {
2606        let model = minimal_model();
2607        let ctx = build_context("E3053", Some("Bucket"), "Properties.BucketName", &model)
2608            .expect("E3053 should return context");
2609        assert!(ctx.actual_value.is_some(), "E3053 context should have actual_value");
2610    }
2611
2612    #[test]
2613    fn build_context_e3053_empty_path_returns_none() {
2614        let model = minimal_model();
2615        assert!(
2616            build_context("E3053", Some("Bucket"), "", &model).is_none(),
2617            "E3053 with empty path should return None"
2618        );
2619    }
2620
2621    #[test]
2622    fn build_context_e2501_with_condition() {
2623        let yaml = br#"
2624AWSTemplateFormatVersion: "2010-09-09"
2625Conditions:
2626  IsProd:
2627    !Equals [!Ref "AWS::Region", "us-east-1"]
2628Resources:
2629  Bucket:
2630    Type: AWS::S3::Bucket
2631    Condition: IsProd
2632"#;
2633        let model = SemanticModel::from_bytes(yaml).expect("model with condition");
2634        let ctx =
2635            build_context("W2503", Some("Bucket"), "", &model).expect("W2503 with condition should return context");
2636        let extra = ctx.extra.unwrap();
2637        assert!(extra.contains_key("source_condition"));
2638    }
2639
2640    #[test]
2641    fn build_context_e2501_without_condition_returns_none() {
2642        let model = minimal_model();
2643        assert!(
2644            build_context("W2503", Some("Bucket"), "", &model).is_none(),
2645            "W2503 without condition should return None"
2646        );
2647    }
2648
2649    #[test]
2650    fn enrich_preserves_existing_phase_and_description() {
2651        let model = minimal_model();
2652        let meta = meta_map();
2653        let mut diags = vec![Diagnostic {
2654            rule_id: "E3012".into(),
2655            severity: Severity::Error,
2656            message: "x".into(),
2657            phase: Some(Phase::Lint),
2658            rule_description: Some("custom desc".into()),
2659            ..default_diag()
2660        }];
2661        enrich_diagnostics(&mut diags, &model, &meta, &HashMap::new(), &DetailLevel::Standard);
2662        assert_eq!(diags[0].phase, Some(Phase::Lint));
2663        assert_eq!(diags[0].rule_description.as_deref(), Some("custom desc"));
2664    }
2665
2666    #[test]
2667    fn finalize_warning_level_filters_info() {
2668        let config = ValidateConfig { severity_level: Severity::Warn, ..Default::default() };
2669        let mut diags = vec![
2670            make_diag("F3012", Severity::Fatal, 1, 1),
2671            make_diag("E3012", Severity::Error, 2, 1),
2672            make_diag("W3045", Severity::Warn, 3, 1),
2673            Diagnostic { severity: Severity::Info, message: "info".into(), location: None, ..default_diag() },
2674        ];
2675        finalize_diagnostics(&mut diags, &config);
2676        assert!(
2677            diags.iter().all(|d| d.severity >= Severity::Warn),
2678            "all diagnostics should be Warn or above after filtering"
2679        );
2680        assert_eq!(diags.len(), 3);
2681    }
2682
2683    #[test]
2684    fn finalize_different_property_paths_not_deduped() {
2685        let config = ValidateConfig::default();
2686        let mut d1 = make_diag("E3012", Severity::Error, 5, 1);
2687        let mut d2 = make_diag("E3012", Severity::Error, 5, 1);
2688        d1.property_path = Some("Properties.A".into());
2689        d2.property_path = Some("Properties.B".into());
2690        let mut diags = vec![d1, d2];
2691        finalize_diagnostics(&mut diags, &config);
2692        assert_eq!(diags.len(), 2);
2693    }
2694
2695    #[test]
2696    fn build_rule_list_uses_stored_severity_for_any_rule_id() {
2697        let mut meta = HashMap::new();
2698        meta.insert(
2699            "X9999".into(),
2700            RuleMetadataEntry {
2701                category: Some("custom".into()),
2702                description: "desc".into(),
2703                severity: Severity::Error,
2704                origin: RuleOrigin::Engine,
2705            },
2706        );
2707        let rules = build_rule_list(&meta, &HashMap::new());
2708        assert_eq!(rules[0].severity, Severity::Error);
2709    }
2710
2711    #[test]
2712    fn engine_type_default_is_rego() {
2713        assert_eq!(EngineType::default(), EngineType::Rego);
2714    }
2715
2716    #[test]
2717    fn engine_type_as_str_returns_lowercase() {
2718        assert_eq!(EngineType::Rego.as_str(), "Rego");
2719        assert_eq!(EngineType::Cel.as_str(), "CEL");
2720    }
2721
2722    #[test]
2723    fn engine_type_parse_accepts_case_insensitive_input() {
2724        assert_eq!(EngineType::parse("rego"), Ok(EngineType::Rego));
2725        assert_eq!(EngineType::parse("REGO"), Ok(EngineType::Rego));
2726        assert_eq!(EngineType::parse("Cel"), Ok(EngineType::Cel));
2727        assert_eq!(EngineType::parse("CEL"), Ok(EngineType::Cel));
2728    }
2729
2730    #[test]
2731    fn engine_type_parse_returns_error_for_unknown_value() {
2732        let error =
2733            EngineType::parse("unknown").expect_err("an unknown engine selector must return an error, not a default");
2734        assert!(
2735            error.contains("Unknown engine type 'unknown'") && error.contains("rego"),
2736            "the error must name the bad selector and the valid options, got: {error}"
2737        );
2738    }
2739
2740    #[test]
2741    fn catch_panics_maps_panic_to_caller_error_type() {
2742        // The bindings rely on this to keep a panic during engine construction from
2743        // unwinding across the FFI boundary: it must become the caller's own error type.
2744        #[derive(Debug, PartialEq)]
2745        struct BindingError(String);
2746        let result: Result<(), BindingError> = catch_panics(|| panic!("engine init blew up"), BindingError);
2747        let error = result.expect_err("a panic must be converted to the caller's error type");
2748        assert!(error.0.contains("engine init blew up"), "the panic payload must be carried, got: {}", error.0);
2749    }
2750
2751    #[test]
2752    fn catch_panics_passes_through_ok_and_err_without_wrapping() {
2753        let ok: Result<i32, String> = catch_panics(|| Ok(7), |m| m);
2754        assert_eq!(ok, Ok(7), "a non-panicking Ok must pass through unchanged");
2755        let err: Result<i32, String> = catch_panics(|| Err("plain error".to_string()), |_| "panic".to_string());
2756        assert_eq!(err, Err("plain error".to_string()), "a returned Err must pass through, not be treated as a panic");
2757    }
2758
2759    #[test]
2760    fn validate_catching_panics_converts_panic_to_structured_error() {
2761        let error = validate_catching_panics(|| panic!("simulated invariant violation"))
2762            .expect_err("a panic must be converted into a structured error, not propagated");
2763        match error {
2764            ValidationError::Engine(message) => assert!(
2765                message.contains("Internal validation error") && message.contains("simulated invariant violation"),
2766                "error must wrap the panic payload as a structured engine error, got: {message}"
2767            ),
2768            other => panic!("expected ValidationError::Engine, got {other:?}"),
2769        }
2770    }
2771
2772    #[test]
2773    fn validate_catching_panics_passes_success_through_unchanged() {
2774        let model = minimal_model();
2775        let report = validate_catching_panics(|| {
2776            Ok(build_report_with_versions(
2777                vec![],
2778                &model,
2779                0,
2780                Some(7),
2781                false,
2782                Severity::Info,
2783                "inline".to_string(),
2784                TEST_CFN_LINT_VERSION.to_string(),
2785                TEST_RESOURCE_SCHEMA_VERSION.to_string(),
2786            ))
2787        })
2788        .expect("a non-panicking Ok result must pass through the guard unchanged");
2789        assert_eq!(report.metadata.rules_evaluated, 7, "the guard must return the closure's report verbatim");
2790    }
2791
2792    /// A test engine that fails on demand, to prove that engine failures surface
2793    /// as errors (exceptions) and are never absorbed into the diagnostics list.
2794    enum Explosion {
2795        Panic,
2796        ReturnErr,
2797    }
2798
2799    struct ExplodingEngine {
2800        mode: Explosion,
2801        metadata: HashMap<String, RuleMetadataEntry>,
2802        metric: PhaseMetric,
2803    }
2804
2805    impl ExplodingEngine {
2806        fn new(mode: Explosion) -> Self {
2807            Self { mode, metadata: HashMap::new(), metric: PhaseMetric { duration_ms: 0.0 } }
2808        }
2809    }
2810
2811    impl ValidationEngine for ExplodingEngine {
2812        fn engine_name(&self) -> &str {
2813            "exploding-test-engine"
2814        }
2815
2816        fn evaluate_rules(
2817            &self,
2818            _model: &Arc<SemanticModel>,
2819            _config: &ValidateConfig,
2820        ) -> Result<Vec<Diagnostic>, ValidationError> {
2821            match self.mode {
2822                Explosion::Panic => panic!("simulated engine invariant violation"),
2823                Explosion::ReturnErr => Err(ValidationError::Engine("Simulated engine evaluation failure".to_string())),
2824            }
2825        }
2826
2827        fn list_rules(&self) -> Vec<RuleInfo> {
2828            Vec::new()
2829        }
2830
2831        fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
2832            &self.metadata
2833        }
2834
2835        fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
2836            HashMap::new()
2837        }
2838
2839        fn init_metric(&self) -> &PhaseMetric {
2840            &self.metric
2841        }
2842    }
2843
2844    const WELL_FORMED_TEMPLATE: &[u8] = b"Resources:\n  Bucket:\n    Type: AWS::S3::Bucket\n";
2845
2846    /// A test engine that records specified budget kinds during evaluation.
2847    struct BudgetExhaustingEngine {
2848        kinds: Vec<template_model::BudgetKind>,
2849        metadata: HashMap<String, RuleMetadataEntry>,
2850        metric: PhaseMetric,
2851    }
2852
2853    impl BudgetExhaustingEngine {
2854        fn new(kinds: Vec<template_model::BudgetKind>) -> Self {
2855            Self { kinds, metadata: build_rule_metadata_map(), metric: PhaseMetric { duration_ms: 0.0 } }
2856        }
2857    }
2858
2859    impl ValidationEngine for BudgetExhaustingEngine {
2860        fn engine_name(&self) -> &str {
2861            "budget-test-engine"
2862        }
2863
2864        fn evaluate_rules(
2865            &self,
2866            model: &Arc<SemanticModel>,
2867            _config: &ValidateConfig,
2868        ) -> Result<Vec<Diagnostic>, ValidationError> {
2869            for kind in &self.kinds {
2870                model.record_budget_exhaustion(*kind);
2871            }
2872            Ok(Vec::new())
2873        }
2874
2875        fn list_rules(&self) -> Vec<RuleInfo> {
2876            Vec::new()
2877        }
2878
2879        fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
2880            &self.metadata
2881        }
2882
2883        fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
2884            HashMap::new()
2885        }
2886
2887        fn init_metric(&self) -> &PhaseMetric {
2888            &self.metric
2889        }
2890    }
2891
2892    #[test]
2893    fn budget_exhaustion_emits_one_warning_with_metadata() {
2894        use template_model::BudgetKind;
2895        let engine = BudgetExhaustingEngine::new(vec![
2896            BudgetKind::ResolverDepth,
2897            BudgetKind::EnumExpansion,
2898            BudgetKind::ResolverDepth, // duplicate — must not create a second entry
2899        ]);
2900        let schema_validator = SchemaValidator::default();
2901        let report = validate_bytes(
2902            &engine,
2903            &schema_validator,
2904            WELL_FORMED_TEMPLATE,
2905            ValidateConfig { detail_level: DetailLevel::Detailed, ..Default::default() },
2906        )
2907        .expect("validation must succeed");
2908
2909        let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
2910        assert_eq!(budget_warnings.len(), 1, "exactly one aggregate budget warning must be emitted");
2911        let w = budget_warnings[0];
2912        assert_eq!(w.severity, Severity::Warn);
2913        assert_eq!(
2914            w.rule_description.as_deref(),
2915            Some("Deterministic validation budgets were exhausted; see report metadata for completeness impact")
2916        );
2917        assert_eq!(w.category.as_deref(), Some(Category::Structure.as_str()));
2918        assert_eq!(w.source, RuleOrigin::Engine);
2919        assert_eq!(w.phase, Some(Phase::Lint));
2920
2921        assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
2922        let budget_exhaustions =
2923            report.metadata.budget_exhaustions.as_deref().expect("exhausted budgets must be present in metadata");
2924        assert_eq!(budget_exhaustions.len(), 2, "two distinct kinds, not three");
2925        // Deterministic order (BTreeSet)
2926        assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
2927        assert_eq!(budget_exhaustions[0].description, BudgetKind::ResolverDepth.description());
2928        assert_eq!(budget_exhaustions[0].limit, BudgetKind::ResolverDepth.limit());
2929        assert_eq!(budget_exhaustions[1].kind, "enumExpansion");
2930        assert_eq!(budget_exhaustions[1].description, BudgetKind::EnumExpansion.description());
2931
2932        // Context must be present in detailed format
2933        assert!(w.context.is_some(), "budget warning context must be attached in detailed format");
2934        let ctx = w.context.as_ref().unwrap();
2935        let extra = ctx.extra.as_ref().expect("context.extra must be populated");
2936        assert!(extra.contains_key("budgetExhaustions"));
2937    }
2938
2939    #[test]
2940    fn context_only_required_property_combinations_keeps_report_status_ok() {
2941        use template_model::BudgetKind;
2942
2943        let choice_count = BudgetKind::RequiredPropertyCombinations.limit() as usize + 1;
2944        let choices: Vec<String> = (0..choice_count).map(|index| format!("Choice{index:03}")).collect();
2945        let properties: serde_json::Map<String, serde_json::Value> =
2946            choices.iter().map(|choice| (choice.clone(), serde_json::json!({"type": "string"}))).collect();
2947        let schema_validator = SchemaValidator::try_with_additional_schemas(vec![(
2948            "AWS::Test::RequiredCombinationBudget",
2949            serde_json::json!({
2950                "properties": properties,
2951                "anyOf": [{"requiredOr": choices}],
2952                "additionalProperties": false
2953            }),
2954        )])
2955        .expect("test schema must compile");
2956        let template = b"Resources:\n  R:\n    Type: AWS::Test::RequiredCombinationBudget\n    Properties: {}\n";
2957        let engine = BudgetExhaustingEngine::new(Vec::new());
2958
2959        let report = validate_bytes(&engine, &schema_validator, template, ValidateConfig::default())
2960            .expect("validation must succeed");
2961
2962        let budget_warnings: Vec<_> =
2963            report.diagnostics.iter().filter(|diagnostic| diagnostic.rule_id == "W9052").collect();
2964        assert_eq!(budget_warnings.len(), 1);
2965        assert_eq!(report.status, ReportStatus::Ok);
2966        let budget_exhaustions =
2967            report.metadata.budget_exhaustions.as_deref().expect("context-only exhaustion must be present in metadata");
2968        assert_eq!(budget_exhaustions.len(), 1);
2969        assert_eq!(budget_exhaustions[0].kind, "requiredPropertyCombinations");
2970        assert_eq!(budget_exhaustions[0].description, BudgetKind::RequiredPropertyCombinations.description());
2971        assert_eq!(budget_exhaustions[0].limit, BudgetKind::RequiredPropertyCombinations.limit());
2972        assert!(!budget_exhaustions[0].analysis_incomplete);
2973    }
2974
2975    #[test]
2976    fn severity_filtering_removes_warning_but_metadata_persists() {
2977        use template_model::BudgetKind;
2978        let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
2979        let schema_validator = SchemaValidator::default();
2980        let report = validate_bytes(
2981            &engine,
2982            &schema_validator,
2983            WELL_FORMED_TEMPLATE,
2984            ValidateConfig { severity_level: Severity::Error, ..Default::default() },
2985        )
2986        .expect("validation must succeed");
2987
2988        let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
2989        assert!(budget_warnings.is_empty(), "severity filter must remove the warning");
2990        assert_eq!(
2991            report.status,
2992            ReportStatus::AnalysisIncomplete,
2993            "status reflects correctness-affecting exhaustion regardless of filter"
2994        );
2995        assert!(report.metadata.budget_exhaustions.is_some());
2996    }
2997
2998    #[test]
2999    fn parse_error_sets_error_status_without_budget_warning() {
3000        let schema_validator = SchemaValidator::default();
3001        let engine = BudgetExhaustingEngine::new(vec![]);
3002        let report = validate_bytes_with_path(
3003            &engine,
3004            &schema_validator,
3005            b"{ not valid yaml or json <<<",
3006            ValidateConfig::default(),
3007            "bad.yaml".to_string(),
3008        )
3009        .expect("parse errors return Ok with error diagnostics");
3010
3011        assert_eq!(report.status, ReportStatus::Error);
3012        assert!(report.metadata.budget_exhaustions.is_none());
3013        let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3014        assert!(budget_warnings.is_empty(), "parse errors must not emit a budget warning");
3015    }
3016
3017    #[test]
3018    fn category_filtering_removes_warning_but_metadata_persists() {
3019        use template_model::BudgetKind;
3020        let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3021        let schema_validator = SchemaValidator::default();
3022        let filters = FilterConfig::new(
3023            RuleFilterConfig::default(),
3024            RuleFilterConfig {
3025                categories: vec![Category::Structure.as_str().to_string()],
3026                ..RuleFilterConfig::default()
3027            },
3028        );
3029        let report = validate_bytes(
3030            &engine,
3031            &schema_validator,
3032            WELL_FORMED_TEMPLATE,
3033            ValidateConfig { filters, ..ValidateConfig::default() },
3034        )
3035        .expect("validation must succeed");
3036
3037        assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
3038        assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
3039        let budget_exhaustions = report
3040            .metadata
3041            .budget_exhaustions
3042            .as_deref()
3043            .expect("exhausted budget must be present despite warning suppression");
3044        assert_eq!(budget_exhaustions.len(), 1);
3045        assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3046    }
3047
3048    #[test]
3049    fn disabling_builtins_suppresses_warning_but_metadata_persists() {
3050        use template_model::BudgetKind;
3051        let engine = BudgetExhaustingEngine::new(vec![BudgetKind::ResolverDepth]);
3052        let schema_validator = SchemaValidator::default();
3053        let report = validate_bytes(
3054            &engine,
3055            &schema_validator,
3056            WELL_FORMED_TEMPLATE,
3057            ValidateConfig { disable_builtin_rules: true, ..ValidateConfig::default() },
3058        )
3059        .expect("validation must succeed");
3060
3061        assert!(report.diagnostics.iter().all(|diagnostic| diagnostic.rule_id != "W9052"));
3062        assert_eq!(report.status, ReportStatus::AnalysisIncomplete);
3063        let budget_exhaustions = report
3064            .metadata
3065            .budget_exhaustions
3066            .as_deref()
3067            .expect("exhausted budget must be present despite warning suppression");
3068        assert_eq!(budget_exhaustions.len(), 1);
3069        assert_eq!(budget_exhaustions[0].kind, "resolverDepth");
3070    }
3071
3072    #[test]
3073    fn non_curtailed_fixture_omits_exhaustions_and_has_ok_status() {
3074        let engine = BudgetExhaustingEngine::new(vec![]); // no exhaustions
3075        let schema_validator = SchemaValidator::default();
3076        let report = validate_bytes(&engine, &schema_validator, WELL_FORMED_TEMPLATE, ValidateConfig::default())
3077            .expect("validation must succeed");
3078
3079        assert_eq!(report.status, ReportStatus::Ok);
3080        assert!(report.metadata.budget_exhaustions.is_none());
3081        let budget_warnings: Vec<_> = report.diagnostics.iter().filter(|d| d.rule_id == "W9052").collect();
3082        assert!(budget_warnings.is_empty());
3083    }
3084
3085    #[test]
3086    fn engine_exception_surfaces_as_error_never_as_diagnostic() {
3087        let schema_validator = SchemaValidator::default();
3088        let engine = ExplodingEngine::new(Explosion::ReturnErr);
3089        let result = validate_bytes_with_path(
3090            &engine,
3091            &schema_validator,
3092            WELL_FORMED_TEMPLATE,
3093            ValidateConfig::default(),
3094            "inline".to_string(),
3095        );
3096        match result {
3097            Err(ValidationError::Engine(message)) => assert!(
3098                message.contains("Simulated engine evaluation failure"),
3099                "an engine exception must surface verbatim as an error, got: {message}"
3100            ),
3101            Ok(report) => panic!(
3102                "an engine exception must surface as Err, never as an Ok report with \
3103                 diagnostics; got {} diagnostics",
3104                report.diagnostics.len()
3105            ),
3106            Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
3107        }
3108    }
3109
3110    #[test]
3111    fn engine_panic_is_caught_as_error_never_as_diagnostic() {
3112        let schema_validator = SchemaValidator::default();
3113        let engine = ExplodingEngine::new(Explosion::Panic);
3114        let result = validate_catching_panics(|| {
3115            validate_bytes_with_path(
3116                &engine,
3117                &schema_validator,
3118                WELL_FORMED_TEMPLATE,
3119                ValidateConfig::default(),
3120                "inline".to_string(),
3121            )
3122        });
3123        match result {
3124            Err(ValidationError::Engine(message)) => assert!(
3125                message.contains("Internal validation error")
3126                    && message.contains("simulated engine invariant violation"),
3127                "a panic must surface as a structured internal error, got: {message}"
3128            ),
3129            Ok(report) => panic!(
3130                "a panic must surface as Err, never as an Ok report with diagnostics; got {} \
3131                 diagnostics",
3132                report.diagnostics.len()
3133            ),
3134            Err(other) => panic!("expected ValidationError::Engine, got {other:?}"),
3135        }
3136    }
3137}