Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions artifacts/requirements.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions artifacts/verification.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 144 additions & 0 deletions crates/spar-cli/tests/applies_to_feature_path.rs
Original file line number Diff line number Diff line change
@@ -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);
}
97 changes: 78 additions & 19 deletions crates/spar-hir-def/src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,20 @@ struct ImplChainResult {
call_map: FxHashMap<String, Name>,
}

/// Result of resolving an `applies to <dotted-path>` 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<ComponentInstance>,
Expand Down Expand Up @@ -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()],
});
Expand All @@ -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<ComponentInstanceIdx> {
/// 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.
Expand Down
Loading