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