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