diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml index 8d90924..5f4722d 100644 --- a/artifacts/requirements.yaml +++ b/artifacts/requirements.yaml @@ -1957,4 +1957,21 @@ artifacts: status: implemented tags: [classifier-match, connections, v093] + - id: REQ-APPLIES-TO-FEATURE-PATH + type: requirement + title: applies_to property association accepts feature paths + description: > + Per AADL v2.3 (SAE AS5506D §11.3), a property association's + `applies to` clause may name a feature on a subcomponent + (`Some_Property => value applies to fw.input_port;`), not just + a subcomponent path. spar shall resolve such paths: when all + segments name subcomponents the property attaches to the leaf + component; when the final segment names a feature on the + penultimate component, the property attaches to that component + and the feature is the logical target. Resolves v0.9.x bug where + feature-path applies_to clauses emitted spurious "could not be + resolved" diagnostics and dropped the property. + status: implemented + tags: [hir-def, properties, v093] + # Research findings tracked separately in research/findings.yaml diff --git a/artifacts/verification.yaml b/artifacts/verification.yaml index dd623b7..aeea229 100644 --- a/artifacts/verification.yaml +++ b/artifacts/verification.yaml @@ -2544,3 +2544,23 @@ artifacts: links: - type: satisfies target: REQ-CLASSIFIER-MATCH-002 + + - id: TEST-APPLIES-TO-FEATURE-PATH + type: feature + title: applies_to feature paths resolve without spurious diagnostics + description: > + Integration tests in crates/spar-cli/tests/applies_to_feature_path.rs + verify that `applies to subcomp.feature` resolves cleanly + (applies_to_feature_path_does_not_emit_unresolved_diagnostic) and + that genuinely unknown final segments still emit a clean + "could not be resolved" diagnostic + (applies_to_unknown_segment_still_emits_diagnostic). + fields: + method: automated-test + steps: + - run: cargo test -p spar --test applies_to_feature_path + status: passing + tags: [hir-def, properties, v093] + links: + - type: satisfies + target: REQ-APPLIES-TO-FEATURE-PATH diff --git a/crates/spar-cli/tests/applies_to_feature_path.rs b/crates/spar-cli/tests/applies_to_feature_path.rs new file mode 100644 index 0000000..68fc094 --- /dev/null +++ b/crates/spar-cli/tests/applies_to_feature_path.rs @@ -0,0 +1,144 @@ +//! AADL v2.3 (AS5506D §11.3): `applies to` paths may end with a feature +//! name. This test covers a property association with a feature-path +//! target — e.g. `Latency => 5 ms .. 10 ms applies to fw.input_port;`. +//! +//! Pre-fix behavior: spar rejected the path because the final segment +//! `input_port` was not a subcomponent, emitting a spurious "could not +//! be resolved" diagnostic and dropping the property. +//! +//! Post-fix behavior: the path resolves to a feature; the property is +//! recorded against the owning component instance and no diagnostic is +//! emitted. + +use std::env; +use std::fs; +use std::process::Command; + +fn spar() -> Command { + Command::new(env!("CARGO_BIN_EXE_spar")) +} + +const MODEL_FEATURE_PATH: &str = "\ +package Test_Applies_To_Feature +public + processor Cpu + end Cpu; + + thread Worker + features + input_port: in data port; + end Worker; + + process Proc + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread Worker; + end Proc.Impl; + + system Sys + end Sys; + + system implementation Sys.Impl + subcomponents + cpu: processor Cpu; + fw: process Proc.Impl; + properties + Actual_Processor_Binding => (reference (cpu)) applies to fw.w; + Required_Connection_Quality_Of_Service => (Latency) applies to fw.w.input_port; + end Sys.Impl; +end Test_Applies_To_Feature; +"; + +fn write_model(tag: &str) -> std::path::PathBuf { + let path = env::temp_dir().join(format!( + "spar_applies_to_feature_{}_{}.aadl", + std::process::id(), + tag + )); + fs::write(&path, MODEL_FEATURE_PATH).expect("write temp AADL"); + path +} + +#[test] +fn applies_to_feature_path_does_not_emit_unresolved_diagnostic() { + let path = write_model("nodiag"); + let output = spar() + .arg("instance") + .arg("--root") + .arg("Test_Applies_To_Feature::Sys.Impl") + .arg(&path) + .output() + .expect("failed to run spar"); + + assert!( + output.status.success(), + "spar instance must not crash on feature-path applies_to; stderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("fw.w.input_port") || !stderr.contains("could not be resolved"), + "spar incorrectly rejected feature-path applies_to as unresolvable.\nstderr:\n{stderr}" + ); + + let _ = fs::remove_file(&path); +} + +#[test] +fn applies_to_unknown_segment_still_emits_diagnostic() { + let src = "\ +package Test_Bad_Feature_Path +public + processor Cpu + end Cpu; + + thread Worker + features + input_port: in data port; + end Worker; + + process Proc + end Proc; + + process implementation Proc.Impl + subcomponents + w: thread Worker; + end Proc.Impl; + + system Sys + end Sys; + + system implementation Sys.Impl + subcomponents + cpu: processor Cpu; + fw: process Proc.Impl; + properties + Actual_Processor_Binding => (reference (cpu)) applies to fw.w.no_such_port; + end Sys.Impl; +end Test_Bad_Feature_Path; +"; + let path = env::temp_dir().join(format!( + "spar_applies_to_bad_feature_{}.aadl", + std::process::id() + )); + fs::write(&path, src).expect("write temp AADL"); + + let output = spar() + .arg("instance") + .arg("--root") + .arg("Test_Bad_Feature_Path::Sys.Impl") + .arg(&path) + .output() + .expect("failed to run spar"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("no_such_port") && stderr.contains("could not be resolved"), + "expected unresolved-feature diagnostic in stderr, got:\n{stderr}" + ); + + let _ = fs::remove_file(&path); +} diff --git a/crates/spar-hir-def/src/instance.rs b/crates/spar-hir-def/src/instance.rs index db5a17c..eba53af 100644 --- a/crates/spar-hir-def/src/instance.rs +++ b/crates/spar-hir-def/src/instance.rs @@ -1348,6 +1348,20 @@ struct ImplChainResult { call_map: FxHashMap, } +/// Result of resolving an `applies to ` target. +/// +/// Used internally by [`Builder::resolve_applies_to_path`]. +enum AppliesTarget { + /// All segments named subcomponents; resolved to this component. + Component(ComponentInstanceIdx), + /// All-but-last segments named subcomponents; the last segment names a + /// feature on the returned component (AADL v2.3 AS5506D §11.3). + FeatureOwner(ComponentInstanceIdx), + /// No valid resolution: the path contains a segment that matches neither + /// a subcomponent nor (for the last segment) a feature. + Unresolvable, +} + struct Builder<'a> { scope: &'a GlobalScope, components: Arena, @@ -2296,23 +2310,37 @@ impl<'a> Builder<'a> { /// eagerly so that downstream analyses and the JSON instance exporter /// (#129) see the property on the target. /// - /// If the path cannot be resolved (bad name, or walks into a feature - /// rather than a subcomponent), the property stays on the declaring - /// component and a diagnostic is recorded. + /// AADL v2.3 (AS5506D §11.3) also allows the `applies to` path to end with + /// a feature name: `Some_Property => value applies to subcomp.port;`. + /// In that case the property is stored on the component that owns the + /// feature (the resolved component at the penultimate segment), so that + /// downstream analyses can retrieve it via `properties_for`. No diagnostic + /// is emitted for valid feature paths. + /// + /// If the path cannot be resolved at all (bad subcomponent name or name + /// that matches neither a subcomponent nor a feature), the property stays + /// on the declaring component and a diagnostic is recorded. fn resolve_pending_applies_to(&mut self) { let pending = std::mem::take(&mut self.pending_applies_to); for (owner, path, prop) in pending { match self.resolve_applies_to_path(owner, &path) { - Some(target) => { + AppliesTarget::Component(target) => { self.property_maps.entry(target).or_default().add(prop); } - None => { + AppliesTarget::FeatureOwner(component) => { + // The last segment named a feature on `component`. Store + // the property on the owning component — per AS5506D §11.3 + // the property association applies to the feature instance, + // and the component is the natural retrieval point. + self.property_maps.entry(component).or_default().add(prop); + } + AppliesTarget::Unresolvable => { // Unresolvable path: keep on owner (prior behavior) and // emit a diagnostic so the author notices. self.property_maps.entry(owner).or_default().add(prop); self.diagnostics.push(InstanceDiagnostic { message: format!( - "applies_to path '{path}' could not be resolved to a component instance" + "applies_to path '{path}' could not be resolved to a component instance or feature" ), path: vec![self.components[owner].name.clone()], }); @@ -2321,25 +2349,56 @@ impl<'a> Builder<'a> { } } - /// Walk a dotted path (`fw.firmware`) from `owner` down through - /// subcomponent children, matching names case-insensitively. Returns - /// the resolved target component index, or `None` if any segment fails. - fn resolve_applies_to_path( - &self, - owner: ComponentInstanceIdx, - path: &str, - ) -> Option { + /// Walk a dotted path (`fw.firmware` or `proc_inst.input_port`) from + /// `owner` down through subcomponent children, matching names + /// case-insensitively. + /// + /// Returns: + /// - [`AppliesTarget::Component`] when all segments name subcomponents. + /// - [`AppliesTarget::FeatureOwner`] when all-but-last segments name + /// subcomponents and the final segment names a feature on the resolved + /// component (AADL v2.3 AS5506D §11.3 feature-path support). + /// - [`AppliesTarget::Unresolvable`] when any segment cannot be matched. + fn resolve_applies_to_path(&self, owner: ComponentInstanceIdx, path: &str) -> AppliesTarget { + let segments: Vec<&str> = path + .split('.') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + + if segments.is_empty() { + return AppliesTarget::Component(owner); + } + let mut current = owner; - for segment in path.split('.').map(str::trim).filter(|s| !s.is_empty()) { - let child = self.components[current].children.iter().find(|&&ci| { + for (i, &segment) in segments.iter().enumerate() { + // Try to match as a subcomponent child first. + if let Some(&child) = self.components[current].children.iter().find(|&&ci| { self.components[ci] .name .as_str() .eq_ignore_ascii_case(segment) - })?; - current = *child; + }) { + current = child; + continue; + } + + // Not a child — check if this is the last segment and names a feature. + let is_last = i == segments.len() - 1; + if is_last { + let is_feature = self.components[current].features.iter().any(|&fi| { + self.features[fi].name.as_str().eq_ignore_ascii_case(segment) + }); + if is_feature { + return AppliesTarget::FeatureOwner(current); + } + } + + // Neither subcomponent nor feature — unresolvable. + return AppliesTarget::Unresolvable; } - Some(current) + + AppliesTarget::Component(current) } /// STPA-REQ-010: Validate that connection endpoint array indices are within bounds.