diff --git a/Cargo.lock b/Cargo.lock index c5105b3..22eb4bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "etch", "la-arena", "petgraph 0.7.1", + "rustc-hash 2.1.2", "spar-hir-def", ] diff --git a/crates/spar-analysis/src/connectivity.rs b/crates/spar-analysis/src/connectivity.rs index 92e85db..5ae60e3 100644 --- a/crates/spar-analysis/src/connectivity.rs +++ b/crates/spar-analysis/src/connectivity.rs @@ -8,7 +8,9 @@ use rustc_hash::FxHashSet; use spar_hir_def::instance::{ComponentInstanceIdx, FeatureInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::Direction; -use crate::{Analysis, AnalysisDiagnostic, Severity, component_path}; +use spar_hir_def::instance::SystemOperationMode; + +use crate::{Analysis, AnalysisDiagnostic, ModalAnalysis, Severity, component_path}; /// Analyzes connection completeness across the instance model. /// @@ -23,6 +25,10 @@ impl Analysis for ConnectivityAnalysis { "connectivity" } + fn as_modal(&self) -> Option<&dyn ModalAnalysis> { + Some(self) + } + fn analyze(&self, instance: &SystemInstance) -> Vec { // Severity rationale (STPA-REQ-016): // Warning — unconnected required/provided port, or featureless component with connections @@ -140,6 +146,125 @@ impl Analysis for ConnectivityAnalysis { } } +impl ModalAnalysis for ConnectivityAnalysis { + fn analyze_in_mode( + &self, + instance: &SystemInstance, + som: &SystemOperationMode, + ) -> Vec { + use crate::modal::is_component_active_in_som; + + let mut diags = Vec::new(); + + // Build set of connected features considering only connections active in this SOM. + let connected_features = collect_connected_features_in_som(instance, som); + + for (comp_idx, comp) in instance.all_components() { + // Skip components that are not active in this SOM. + if !is_component_active_in_som(instance, comp_idx, som) { + continue; + } + + for &feat_idx in &comp.features { + let feat = &instance.features[feat_idx]; + + if !is_port_feature(feat_idx, instance) { + continue; + } + + let feat_name = feat.name.as_str(); + let is_connected = connected_features.contains(&(comp_idx, feat_name.to_string())); + + if !is_connected { + if is_intentionally_unconnected(instance, comp_idx, feat_name) { + continue; + } + + let path = component_path(instance, comp_idx); + match feat.direction { + Some(Direction::In) | Some(Direction::InOut) => { + diags.push(AnalysisDiagnostic { + severity: Severity::Warning, + message: format!( + "input port '{}' has no incoming connection", + feat.name + ), + path, + analysis: self.name().to_string(), + }); + } + Some(Direction::Out) => { + diags.push(AnalysisDiagnostic { + severity: Severity::Warning, + message: format!( + "output port '{}' has no outgoing connection", + feat.name + ), + path, + analysis: self.name().to_string(), + }); + } + None => { + diags.push(AnalysisDiagnostic { + severity: Severity::Info, + message: format!( + "feature '{}' has no direction and no connections", + feat.name + ), + path, + analysis: self.name().to_string(), + }); + } + } + } + } + } + + diags + } +} + +/// Build a set of connected features considering only connections active in a SOM. +fn collect_connected_features_in_som( + instance: &SystemInstance, + som: &SystemOperationMode, +) -> FxHashSet<(ComponentInstanceIdx, String)> { + use crate::modal::is_connection_active_in_som; + + let mut connected: FxHashSet<(ComponentInstanceIdx, String)> = FxHashSet::default(); + + for (_idx, conn) in instance.connections.iter() { + // Skip connections not active in this SOM. + if !is_connection_active_in_som(instance, conn.owner, &conn.in_modes, som) { + continue; + } + if let Some(ref src) = conn.src + && let Some(comp_idx) = resolve_subcomponent(instance, conn.owner, &src.subcomponent) + { + connected.insert((comp_idx, src.feature.as_str().to_string())); + } + if let Some(ref dst) = conn.dst + && let Some(comp_idx) = resolve_subcomponent(instance, conn.owner, &dst.subcomponent) + { + connected.insert((comp_idx, dst.feature.as_str().to_string())); + } + } + + // Semantic connections are not mode-filtered (they are already traced). + for sc in &instance.semantic_connections { + connected.insert(( + sc.ultimate_source.0, + sc.ultimate_source.1.as_str().to_string(), + )); + connected.insert(( + sc.ultimate_destination.0, + sc.ultimate_destination.1.as_str().to_string(), + )); + } + + connected +} + /// Check if a feature is a port-like feature (data port, event port, event data port). fn is_port_feature(_feat_idx: FeatureInstanceIdx, instance: &SystemInstance) -> bool { use spar_hir_def::item_tree::FeatureKind; diff --git a/crates/spar-analysis/src/lib.rs b/crates/spar-analysis/src/lib.rs index a2650f9..39c52f6 100644 --- a/crates/spar-analysis/src/lib.rs +++ b/crates/spar-analysis/src/lib.rs @@ -52,7 +52,7 @@ pub mod weight_power; pub mod wrpc_binding; use serde::Serialize; -use spar_hir_def::instance::SystemInstance; +use spar_hir_def::instance::{SystemInstance, SystemOperationMode}; /// A single analysis that can be run on an AADL system instance. pub trait Analysis { @@ -61,6 +61,26 @@ pub trait Analysis { /// Run the analysis on a system instance. Returns diagnostics. fn analyze(&self, instance: &SystemInstance) -> Vec; + + /// Return this analysis as a `ModalAnalysis` if it supports per-SOM analysis. + /// + /// The default returns `None`, meaning the analysis is mode-independent. + fn as_modal(&self) -> Option<&dyn ModalAnalysis> { + None + } +} + +/// A mode-dependent analysis that can be run per System Operation Mode (SOM). +/// +/// Analyses implementing this trait will be invoked once per SOM by +/// [`AnalysisRunner::run_all_per_som`], receiving the specific SOM context. +pub trait ModalAnalysis: Analysis { + /// Run the analysis on a system instance within a specific SOM context. + fn analyze_in_mode( + &self, + instance: &SystemInstance, + som: &SystemOperationMode, + ) -> Vec; } /// A diagnostic produced by an analysis pass. @@ -187,6 +207,33 @@ impl AnalysisRunner { } all_diagnostics } + + /// Run mode-independent analyses once, then run mode-dependent analyses + /// once per System Operation Mode (SOM). + /// + /// Diagnostics from per-SOM analyses are prefixed with `[mode: ]`. + pub fn run_all_per_som(&self, instance: &SystemInstance) -> Vec { + let mut all = Vec::new(); + + // Run mode-independent analyses once (the normal path). + all.extend(self.run_all(instance)); + + // Run mode-dependent analyses per SOM. + for som in &instance.system_operation_modes { + let som_name = &som.name; + for analysis in &self.analyses { + if let Some(modal) = analysis.as_modal() { + let diags = modal.analyze_in_mode(instance, som); + for mut d in diags { + d.message = format!("[mode: {som_name}] {}", d.message); + all.push(d); + } + } + } + } + + all + } } impl Default for AnalysisRunner { diff --git a/crates/spar-analysis/src/modal.rs b/crates/spar-analysis/src/modal.rs index 87066f8..0063c9f 100644 --- a/crates/spar-analysis/src/modal.rs +++ b/crates/spar-analysis/src/modal.rs @@ -5,7 +5,7 @@ //! use these helpers to note when they used default (non-modal) property //! values despite SOMs being defined. -use spar_hir_def::instance::SystemInstance; +use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance, SystemOperationMode}; use spar_hir_def::name::Name; /// Check if an instance has any system operation modes. @@ -29,6 +29,81 @@ pub fn mode_names(instance: &SystemInstance) -> Vec { /// - If `current_mode` is `None` (no mode context) the element is always active. /// - Otherwise the element is active when `current_mode` matches any entry in /// `in_modes` (case-insensitive comparison). +/// +/// Check whether a component instance is active in a given System Operation Mode (SOM). +/// +/// A component is active in a SOM when: +/// 1. Its `in_modes` list is empty (non-modal — active in all modes), or +/// 2. The SOM selects a mode on the component's parent, and that mode name +/// appears in the component's `in_modes` list. +/// +/// If the component's parent has no mode selection in the SOM (i.e. the parent +/// is not a modal component), the component is treated as active. +pub fn is_component_active_in_som( + instance: &SystemInstance, + comp_idx: ComponentInstanceIdx, + som: &SystemOperationMode, +) -> bool { + let comp = instance.component(comp_idx); + + // Non-modal component: always active. + if comp.in_modes.is_empty() { + return true; + } + + // Find the mode that the SOM selects for this component's parent. + let parent_idx = match comp.parent { + Some(p) => p, + // Root component with in_modes set — unusual, treat as active. + None => return true, + }; + + // Look through the SOM's mode selections for one owned by the parent. + for &(_sel_comp, mode_inst_idx) in &som.mode_selections { + let mode_inst = &instance.mode_instances[mode_inst_idx]; + if mode_inst.owner == parent_idx { + // Found the mode selected on the parent — check if it matches. + return comp + .in_modes + .iter() + .any(|m| m.as_str().eq_ignore_ascii_case(mode_inst.name.as_str())); + } + } + + // Parent has no mode selection in this SOM — component is active. + true +} + +/// Check whether a connection is active in a given System Operation Mode (SOM). +/// +/// A connection is active when its `in_modes` list is empty (non-modal) or +/// the SOM selects a mode on its owner that matches one of the connection's +/// mode names. +pub fn is_connection_active_in_som( + instance: &SystemInstance, + conn_owner: ComponentInstanceIdx, + conn_in_modes: &[Name], + som: &SystemOperationMode, +) -> bool { + // Non-modal connection: always active. + if conn_in_modes.is_empty() { + return true; + } + + // Find the mode that the SOM selects for the connection's owner. + for &(_sel_comp, mode_inst_idx) in &som.mode_selections { + let mode_inst = &instance.mode_instances[mode_inst_idx]; + if mode_inst.owner == conn_owner { + return conn_in_modes + .iter() + .any(|m| m.as_str().eq_ignore_ascii_case(mode_inst.name.as_str())); + } + } + + // Owner has no mode selection in this SOM — connection is active. + true +} + pub fn is_active_in_mode(in_modes: &[Name], current_mode: Option<&str>) -> bool { // Non-modal element: always active. if in_modes.is_empty() { diff --git a/crates/spar-analysis/src/scheduling.rs b/crates/spar-analysis/src/scheduling.rs index 77fddd4..7714096 100644 --- a/crates/spar-analysis/src/scheduling.rs +++ b/crates/spar-analysis/src/scheduling.rs @@ -17,10 +17,13 @@ use rustc_hash::FxHashMap; use spar_hir_def::instance::{ComponentInstanceIdx, SystemInstance}; use spar_hir_def::item_tree::ComponentCategory; +use spar_hir_def::instance::SystemOperationMode; + +use crate::modal::is_component_active_in_som; use crate::property_accessors::{ get_execution_time, get_execution_time_range, get_processor_binding, get_timing_property, }; -use crate::{Analysis, AnalysisDiagnostic, Severity, component_path}; +use crate::{Analysis, AnalysisDiagnostic, ModalAnalysis, Severity, component_path}; /// Rate Monotonic scheduling analysis. pub struct SchedulingAnalysis; @@ -30,6 +33,10 @@ impl Analysis for SchedulingAnalysis { "scheduling" } + fn as_modal(&self) -> Option<&dyn ModalAnalysis> { + Some(self) + } + fn analyze(&self, instance: &SystemInstance) -> Vec { // Severity rationale (STPA-REQ-016): // Error — processor utilization exceeds 100% @@ -399,6 +406,106 @@ impl Analysis for SchedulingAnalysis { } } +impl ModalAnalysis for SchedulingAnalysis { + fn analyze_in_mode( + &self, + instance: &SystemInstance, + som: &SystemOperationMode, + ) -> Vec { + let mut diags = Vec::new(); + + // Collect threads active in this SOM, grouped by processor binding. + let mut processor_threads: FxHashMap> = FxHashMap::default(); + + for (comp_idx, comp) in instance.all_components() { + if comp.category != ComponentCategory::Thread { + continue; + } + + // Only include threads that are active in this SOM. + if !is_component_active_in_som(instance, comp_idx, som) { + continue; + } + + let props = instance.properties_for(comp_idx); + let period_ps = get_timing_property(props, "Period"); + let exec_ps = get_execution_time(props); + let binding = get_processor_binding(props); + let proc_key = binding.unwrap_or_else(|| "__unbound__".to_string()); + + if let (Some(period), Some(exec)) = (period_ps, exec_ps) { + processor_threads + .entry(proc_key) + .or_default() + .push(ThreadInfo { + name: comp.name.as_str().to_string(), + period_ps: period, + exec_ps: exec, + comp_idx, + }); + } + } + + // Run RMA per processor for the threads active in this SOM. + for (proc_name, threads) in &processor_threads { + if proc_name == "__unbound__" || threads.is_empty() { + continue; + } + + let n = threads.len(); + let utilization: f64 = threads + .iter() + .map(|t| t.exec_ps as f64 / t.period_ps as f64) + .sum(); + let rma_bound = rma_utilization_bound(n); + let proc_path = find_processor_path(instance, proc_name); + + if utilization > 1.0 { + diags.push(AnalysisDiagnostic { + severity: Severity::Error, + message: format!( + "processor '{}' is overloaded: utilization {:.1}% ({} threads, bound is 100%)", + proc_name, + utilization * 100.0, + n + ), + path: proc_path.clone(), + analysis: self.name().to_string(), + }); + } else if utilization > rma_bound { + diags.push(AnalysisDiagnostic { + severity: Severity::Warning, + message: format!( + "processor '{}' utilization {:.1}% exceeds RMA bound {:.1}% for {} tasks \ + (may miss deadlines under rate monotonic scheduling)", + proc_name, + utilization * 100.0, + rma_bound * 100.0, + n + ), + path: proc_path.clone(), + analysis: self.name().to_string(), + }); + } + + diags.push(AnalysisDiagnostic { + severity: Severity::Info, + message: format!( + "processor '{}' utilization: {:.1}% ({} threads, RMA bound: {:.1}%)", + proc_name, + utilization * 100.0, + n, + rma_bound * 100.0 + ), + path: proc_path, + analysis: self.name().to_string(), + }); + } + + diags + } +} + /// Thread timing information extracted from properties. struct ThreadInfo { #[allow(dead_code)] @@ -834,6 +941,213 @@ mod tests { ); } + // ── Per-SOM scheduling tests ────────────────────────────────── + + #[test] + fn per_som_scheduling_different_utilization_per_mode() { + // Two threads bound to the same processor, but each active in a different mode. + // In "fast" mode: only t_fast is active -> U = 8/10 = 80% + // In "slow" mode: only t_slow is active -> U = 2/10 = 20% + use crate::ModalAnalysis; + + let mut b = TestBuilder::new(); + let root = b.add_component("root", ComponentCategory::System, None); + let cpu = b.add_component("cpu1", ComponentCategory::Processor, Some(root)); + let proc = b.add_component("proc", ComponentCategory::Process, Some(root)); + let t_fast = b.add_component("t_fast", ComponentCategory::Thread, Some(proc)); + let t_slow = b.add_component("t_slow", ComponentCategory::Thread, Some(proc)); + b.set_children(root, vec![cpu, proc]); + b.set_children(proc, vec![t_fast, t_slow]); + + // Mark t_fast as active only in "fast" mode. + b.components[t_fast].in_modes = vec![Name::new("fast")]; + // Mark t_slow as active only in "slow" mode. + b.components[t_slow].in_modes = vec![Name::new("slow")]; + + // t_fast: period=10ms, exec=8ms -> U=0.8 + b.set_property(t_fast, "Timing_Properties", "Period", "10 ms"); + b.set_property( + t_fast, + "Timing_Properties", + "Compute_Execution_Time", + "8 ms", + ); + b.set_property( + t_fast, + "Deployment_Properties", + "Actual_Processor_Binding", + "reference (cpu1)", + ); + + // t_slow: period=10ms, exec=2ms -> U=0.2 + b.set_property(t_slow, "Timing_Properties", "Period", "10 ms"); + b.set_property( + t_slow, + "Timing_Properties", + "Compute_Execution_Time", + "2 ms", + ); + b.set_property( + t_slow, + "Deployment_Properties", + "Actual_Processor_Binding", + "reference (cpu1)", + ); + + // Create mode instances on `proc` (the parent of the threads). + let mut inst = b.build(root); + let fast_mode = inst.mode_instances.alloc(ModeInstance { + name: Name::new("fast"), + is_initial: true, + owner: proc, + }); + let slow_mode = inst.mode_instances.alloc(ModeInstance { + name: Name::new("slow"), + is_initial: false, + owner: proc, + }); + inst.components[proc].modes = vec![fast_mode, slow_mode]; + + // Create SOMs. + let som_fast = SystemOperationMode { + name: "fast".to_string(), + mode_selections: vec![(proc, fast_mode)], + }; + let som_slow = SystemOperationMode { + name: "slow".to_string(), + mode_selections: vec![(proc, slow_mode)], + }; + + // In "fast" mode: t_fast is active with U=80%. + let diags_fast = SchedulingAnalysis.analyze_in_mode(&inst, &som_fast); + let util_fast: Vec<_> = diags_fast + .iter() + .filter(|d| d.severity == Severity::Info && d.message.contains("utilization")) + .collect(); + assert!( + !util_fast.is_empty(), + "should report utilization in fast mode: {:?}", + diags_fast + ); + assert!( + util_fast[0].message.contains("80.0%"), + "fast mode should be 80%: {}", + util_fast[0].message + ); + + // In "slow" mode: t_slow is active with U=20%. + let diags_slow = SchedulingAnalysis.analyze_in_mode(&inst, &som_slow); + let util_slow: Vec<_> = diags_slow + .iter() + .filter(|d| d.severity == Severity::Info && d.message.contains("utilization")) + .collect(); + assert!( + !util_slow.is_empty(), + "should report utilization in slow mode: {:?}", + diags_slow + ); + assert!( + util_slow[0].message.contains("20.0%"), + "slow mode should be 20%: {}", + util_slow[0].message + ); + } + + #[test] + fn per_som_non_modal_thread_included_in_all_soms() { + // A non-modal thread (empty in_modes) should be included in every SOM. + use crate::ModalAnalysis; + + let mut b = TestBuilder::new(); + let root = b.add_component("root", ComponentCategory::System, None); + let cpu = b.add_component("cpu1", ComponentCategory::Processor, Some(root)); + let proc = b.add_component("proc", ComponentCategory::Process, Some(root)); + let t_always = b.add_component("t_always", ComponentCategory::Thread, Some(proc)); + let t_fast = b.add_component("t_fast", ComponentCategory::Thread, Some(proc)); + b.set_children(root, vec![cpu, proc]); + b.set_children(proc, vec![t_always, t_fast]); + + // t_always is non-modal (empty in_modes). + // t_fast is active only in "fast" mode. + b.components[t_fast].in_modes = vec![Name::new("fast")]; + + // t_always: period=10ms, exec=1ms -> U=0.1 + b.set_property(t_always, "Timing_Properties", "Period", "10 ms"); + b.set_property( + t_always, + "Timing_Properties", + "Compute_Execution_Time", + "1 ms", + ); + b.set_property( + t_always, + "Deployment_Properties", + "Actual_Processor_Binding", + "reference (cpu1)", + ); + + // t_fast: period=10ms, exec=4ms -> U=0.4 + b.set_property(t_fast, "Timing_Properties", "Period", "10 ms"); + b.set_property( + t_fast, + "Timing_Properties", + "Compute_Execution_Time", + "4 ms", + ); + b.set_property( + t_fast, + "Deployment_Properties", + "Actual_Processor_Binding", + "reference (cpu1)", + ); + + let mut inst = b.build(root); + let fast_mode = inst.mode_instances.alloc(ModeInstance { + name: Name::new("fast"), + is_initial: true, + owner: proc, + }); + let slow_mode = inst.mode_instances.alloc(ModeInstance { + name: Name::new("slow"), + is_initial: false, + owner: proc, + }); + inst.components[proc].modes = vec![fast_mode, slow_mode]; + + let som_fast = SystemOperationMode { + name: "fast".to_string(), + mode_selections: vec![(proc, fast_mode)], + }; + let som_slow = SystemOperationMode { + name: "slow".to_string(), + mode_selections: vec![(proc, slow_mode)], + }; + + // In "fast" mode: t_always (0.1) + t_fast (0.4) = 50%. + let diags_fast = SchedulingAnalysis.analyze_in_mode(&inst, &som_fast); + let util_fast: Vec<_> = diags_fast + .iter() + .filter(|d| d.severity == Severity::Info && d.message.contains("utilization")) + .collect(); + assert!( + !util_fast.is_empty() && util_fast[0].message.contains("50.0%"), + "fast mode should be 50%: {:?}", + diags_fast + ); + + // In "slow" mode: only t_always (0.1) = 10%. + let diags_slow = SchedulingAnalysis.analyze_in_mode(&inst, &som_slow); + let util_slow: Vec<_> = diags_slow + .iter() + .filter(|d| d.severity == Severity::Info && d.message.contains("utilization")) + .collect(); + assert!( + !util_slow.is_empty() && util_slow[0].message.contains("10.0%"), + "slow mode should be 10%: {:?}", + diags_slow + ); + } + // ── STPA-REQ-013: Execution time range validation ───────────── #[test] diff --git a/crates/spar-cli/src/main.rs b/crates/spar-cli/src/main.rs index ca92202..e99a3d1 100644 --- a/crates/spar-cli/src/main.rs +++ b/crates/spar-cli/src/main.rs @@ -382,6 +382,7 @@ fn cmd_analyze(args: &[String]) { let mut root = None; let mut files = Vec::new(); let mut format = None; + let mut per_som = false; let mut i = 0; while i < args.len() { @@ -404,6 +405,9 @@ fn cmd_analyze(args: &[String]) { process::exit(1); } } + "--per-som" => { + per_som = true; + } s if s.starts_with('-') => { eprintln!("Unknown option: {s}"); process::exit(1); @@ -472,7 +476,11 @@ fn cmd_analyze(args: &[String]) { eprintln!(); // Run instance-level analyses - diagnostics.extend(run_all_analyses(&inst)); + if per_som { + diagnostics.extend(run_all_analyses_per_som(&inst)); + } else { + diagnostics.extend(run_all_analyses(&inst)); + } // JSON output path if format.as_deref() == Some("json") { @@ -1367,6 +1375,16 @@ fn run_all_analyses( runner.run_all(inst) } +/// Create an AnalysisRunner and run mode-independent analyses once plus +/// mode-dependent analyses per System Operation Mode. +fn run_all_analyses_per_som( + inst: &spar_hir_def::instance::SystemInstance, +) -> Vec { + let mut runner = spar_analysis::AnalysisRunner::new(); + runner.register_all(); + runner.run_all_per_som(inst) +} + /// Print diagnostics grouped by severity with colored output. /// Returns `true` if any errors were found. fn print_diagnostics(diagnostics: &[spar_analysis::AnalysisDiagnostic]) -> bool { diff --git a/crates/spar-hir-def/src/resolver.rs b/crates/spar-hir-def/src/resolver.rs index d619384..659c204 100644 --- a/crates/spar-hir-def/src/resolver.rs +++ b/crates/spar-hir-def/src/resolver.rs @@ -77,6 +77,10 @@ pub struct PackageScope { pub private_fgts: rustc_hash::FxHashSet, /// Package renames: alias (ci_name) → original package name. pub package_renames: FxHashMap, + /// Classifier renames: alias (ci_name) → (package, type_name). + pub classifier_renames: FxHashMap, + /// Feature group renames: alias (ci_name) → (package, fgt_name). + pub feature_group_renames: FxHashMap, } /// Stored property set info for resolution. @@ -134,10 +138,31 @@ impl GlobalScope { // Process renames declarations for &renames_idx in &pkg.renames { let ri = &tree.renames[renames_idx]; - if ri.kind == crate::item_tree::RenamesKind::Package { - pkg_scope - .package_renames - .insert(CiName::new(&ri.alias), ri.original.clone()); + match ri.kind { + crate::item_tree::RenamesKind::Package => { + pkg_scope + .package_renames + .insert(CiName::new(&ri.alias), ri.original.clone()); + } + crate::item_tree::RenamesKind::Classifier + | crate::item_tree::RenamesKind::FeatureGroup => { + // Original is stored as "Pkg::TypeName"; split on "::" + let orig_str = ri.original.as_str(); + if let Some((pkg_part, type_part)) = orig_str.split_once("::") { + let pkg_name = Name::new(pkg_part.trim()); + let type_name = Name::new(type_part.trim()); + let alias_key = CiName::new(&ri.alias); + if ri.kind == crate::item_tree::RenamesKind::Classifier { + pkg_scope + .classifier_renames + .insert(alias_key, (pkg_name, type_name)); + } else { + pkg_scope + .feature_group_renames + .insert(alias_key, (pkg_name, type_name)); + } + } + } } } @@ -331,6 +356,26 @@ impl GlobalScope { return result; } + // Check classifier and feature group renames in the originating package + if reference.package.is_none() + && reference.impl_name.is_none() + && let Some(from_scope) = self.packages.get(&from_key) + { + let type_key = CiName::new(&reference.type_name); + + // Classifier renames: alias → (package, type_name) + if let Some((orig_pkg, orig_type)) = from_scope.classifier_renames.get(&type_key) { + let aliased_ref = ClassifierRef::qualified(orig_pkg.clone(), orig_type.clone()); + return self.resolve_classifier(from_package, &aliased_ref); + } + + // Feature group renames: alias → (package, fgt_name) + if let Some((orig_pkg, orig_fgt)) = from_scope.feature_group_renames.get(&type_key) { + let aliased_ref = ClassifierRef::qualified(orig_pkg.clone(), orig_fgt.clone()); + return self.resolve_classifier(from_package, &aliased_ref); + } + } + // If no explicit package, search imports if reference.package.is_none() && let Some(from_scope) = self.packages.get(&from_key) @@ -980,4 +1025,233 @@ mod tests { result ); } + + #[test] + fn package_renames_resolves_qualified_reference() { + // Package Pkg1 has type "Sensor". + // Package Pkg2 has `Pkg1Alias renames package Pkg1;`. + // Reference Pkg1Alias::Sensor from Pkg2 should resolve to Pkg1::Sensor. + let mut tree = ItemTree::default(); + + let ct = tree.component_types.alloc(ComponentTypeItem { + name: Name::new("Sensor"), + category: ComponentCategory::Device, + is_public: true, + extends: None, + features: Vec::new(), + flow_specs: Vec::new(), + modes: Vec::new(), + mode_transitions: Vec::new(), + prototypes: Vec::new(), + property_associations: Vec::new(), + requires_modes: false, + }); + tree.packages.alloc(Package { + name: Name::new("Pkg1"), + with_clauses: Vec::new(), + public_items: vec![ItemRef::ComponentType(ct)], + private_items: Vec::new(), + renames: Vec::new(), + }); + + let renames_idx = tree.renames.alloc(RenamesItem { + alias: Name::new("Pkg1Alias"), + original: Name::new("Pkg1"), + kind: RenamesKind::Package, + }); + tree.packages.alloc(Package { + name: Name::new("Pkg2"), + with_clauses: vec![Name::new("Pkg1")], + public_items: Vec::new(), + private_items: Vec::new(), + renames: vec![renames_idx], + }); + + let scope = GlobalScope::from_trees(vec![Arc::new(tree)]); + + // Pkg1Alias::Sensor from Pkg2 should resolve + let reference = ClassifierRef::qualified(Name::new("Pkg1Alias"), Name::new("Sensor")); + let result = scope.resolve_classifier(&Name::new("Pkg2"), &reference); + assert!( + matches!( + result, + ResolvedClassifier::ComponentType { + ref package, + .. + } if package.as_str() == "Pkg1" + ), + "package rename should resolve Pkg1Alias::Sensor to Pkg1::Sensor: {:?}", + result + ); + } + + #[test] + fn classifier_renames_resolves_unqualified_reference() { + // Package A has type "OriginalSensor". + // Package B has `MySensor renames system A::OriginalSensor;`. + // Unqualified reference to "MySensor" from B should resolve to A::OriginalSensor. + let mut tree = ItemTree::default(); + + let ct = tree.component_types.alloc(ComponentTypeItem { + name: Name::new("OriginalSensor"), + category: ComponentCategory::System, + is_public: true, + extends: None, + features: Vec::new(), + flow_specs: Vec::new(), + modes: Vec::new(), + mode_transitions: Vec::new(), + prototypes: Vec::new(), + property_associations: Vec::new(), + requires_modes: false, + }); + tree.packages.alloc(Package { + name: Name::new("A"), + with_clauses: Vec::new(), + public_items: vec![ItemRef::ComponentType(ct)], + private_items: Vec::new(), + renames: Vec::new(), + }); + + let renames_idx = tree.renames.alloc(RenamesItem { + alias: Name::new("MySensor"), + original: Name::new("A::OriginalSensor"), + kind: RenamesKind::Classifier, + }); + tree.packages.alloc(Package { + name: Name::new("B"), + with_clauses: vec![Name::new("A")], + public_items: Vec::new(), + private_items: Vec::new(), + renames: vec![renames_idx], + }); + + let scope = GlobalScope::from_trees(vec![Arc::new(tree)]); + + // Unqualified "MySensor" from B should resolve to A::OriginalSensor + let reference = ClassifierRef::type_only(Name::new("MySensor")); + let result = scope.resolve_classifier(&Name::new("B"), &reference); + assert!( + matches!( + result, + ResolvedClassifier::ComponentType { + ref package, + .. + } if package.as_str() == "A" + ), + "classifier rename should resolve MySensor to A::OriginalSensor: {:?}", + result + ); + } + + #[test] + fn feature_group_renames_resolves_unqualified_reference() { + // Package A has feature group type "OriginalBusIface". + // Package B has `MyBus renames feature group A::OriginalBusIface;`. + // Unqualified reference to "MyBus" from B should resolve to A::OriginalBusIface. + let mut tree = ItemTree::default(); + + let fgt = tree.feature_group_types.alloc(FeatureGroupTypeItem { + name: Name::new("OriginalBusIface"), + is_public: true, + extends: None, + inverse_of: None, + features: Vec::new(), + prototypes: Vec::new(), + }); + tree.packages.alloc(Package { + name: Name::new("A"), + with_clauses: Vec::new(), + public_items: vec![ItemRef::FeatureGroupType(fgt)], + private_items: Vec::new(), + renames: Vec::new(), + }); + + let renames_idx = tree.renames.alloc(RenamesItem { + alias: Name::new("MyBus"), + original: Name::new("A::OriginalBusIface"), + kind: RenamesKind::FeatureGroup, + }); + tree.packages.alloc(Package { + name: Name::new("B"), + with_clauses: vec![Name::new("A")], + public_items: Vec::new(), + private_items: Vec::new(), + renames: vec![renames_idx], + }); + + let scope = GlobalScope::from_trees(vec![Arc::new(tree)]); + + // Unqualified "MyBus" from B should resolve to A::OriginalBusIface + let reference = ClassifierRef::type_only(Name::new("MyBus")); + let result = scope.resolve_classifier(&Name::new("B"), &reference); + assert!( + matches!( + result, + ResolvedClassifier::FeatureGroupType { + ref package, + .. + } if package.as_str() == "A" + ), + "feature group rename should resolve MyBus to A::OriginalBusIface: {:?}", + result + ); + } + + #[test] + fn classifier_renames_case_insensitive() { + // Verify that classifier renames work case-insensitively. + let mut tree = ItemTree::default(); + + let ct = tree.component_types.alloc(ComponentTypeItem { + name: Name::new("OrigType"), + category: ComponentCategory::Data, + is_public: true, + extends: None, + features: Vec::new(), + flow_specs: Vec::new(), + modes: Vec::new(), + mode_transitions: Vec::new(), + prototypes: Vec::new(), + property_associations: Vec::new(), + requires_modes: false, + }); + tree.packages.alloc(Package { + name: Name::new("Lib"), + with_clauses: Vec::new(), + public_items: vec![ItemRef::ComponentType(ct)], + private_items: Vec::new(), + renames: Vec::new(), + }); + + let renames_idx = tree.renames.alloc(RenamesItem { + alias: Name::new("MyAlias"), + original: Name::new("Lib::OrigType"), + kind: RenamesKind::Classifier, + }); + tree.packages.alloc(Package { + name: Name::new("Consumer"), + with_clauses: vec![Name::new("Lib")], + public_items: Vec::new(), + private_items: Vec::new(), + renames: vec![renames_idx], + }); + + let scope = GlobalScope::from_trees(vec![Arc::new(tree)]); + + // Reference with different casing should still resolve + let reference = ClassifierRef::type_only(Name::new("myalias")); + let result = scope.resolve_classifier(&Name::new("Consumer"), &reference); + assert!( + matches!( + result, + ResolvedClassifier::ComponentType { + ref package, + .. + } if package.as_str() == "Lib" + ), + "case-insensitive classifier rename should resolve: {:?}", + result + ); + } }