Skip to content

Commit 9809b92

Browse files
avrabeclaude
andauthored
fix(hir-def): applies_to accepts feature paths per AADL v2.3 (v0.9.x bug closeout) (#219)
* fix(hir-def): applies_to accepts feature paths per AADL v2.3 (v0.9.x bug closeout) AADL v2.3 (SAE AS5506D §11.3) permits a contained property association's `applies to` clause to end with a feature name: Some_Property => value applies to subcomp.input_port; spar's resolver previously walked the dotted path matching only subcomponents, so when the final segment named a feature it returned None and emitted a spurious "could not be resolved" diagnostic, dropping the property. Introduces AppliesTarget { Component, FeatureOwner, Unresolvable } and extends resolve_applies_to_path: each non-final segment must name a subcomponent (as before); the final segment may name either a subcomponent or a feature on the resolved component. When it names a feature, the property attaches to the component that owns the feature. Genuinely unresolvable paths (no matching subcomponent or feature on the final segment) still emit the existing diagnostic, now with the text "could not be resolved to a component instance or feature". 2 integration tests in crates/spar-cli/tests/applies_to_feature_path.rs cover both the happy path and the unresolved-feature diagnostic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * style(hir-def): cargo fmt No behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 33fad21 commit 9809b92

4 files changed

Lines changed: 262 additions & 19 deletions

File tree

artifacts/requirements.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,4 +1985,21 @@ artifacts:
19851985
status: implemented
19861986
tags: [assertions, v093]
19871987

1988+
- id: REQ-APPLIES-TO-FEATURE-PATH
1989+
type: requirement
1990+
title: applies_to property association accepts feature paths
1991+
description: >
1992+
Per AADL v2.3 (SAE AS5506D §11.3), a property association's
1993+
`applies to` clause may name a feature on a subcomponent
1994+
(`Some_Property => value applies to fw.input_port;`), not just
1995+
a subcomponent path. spar shall resolve such paths: when all
1996+
segments name subcomponents the property attaches to the leaf
1997+
component; when the final segment names a feature on the
1998+
penultimate component, the property attaches to that component
1999+
and the feature is the logical target. Resolves v0.9.x bug where
2000+
feature-path applies_to clauses emitted spurious "could not be
2001+
resolved" diagnostics and dropped the property.
2002+
status: implemented
2003+
tags: [hir-def, properties, v093]
2004+
19882005
# Research findings tracked separately in research/findings.yaml

artifacts/verification.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2584,3 +2584,23 @@ artifacts:
25842584
links:
25852585
- type: satisfies
25862586
target: REQ-HAS-FEATURES
2587+
2588+
- id: TEST-APPLIES-TO-FEATURE-PATH
2589+
type: feature
2590+
title: applies_to feature paths resolve without spurious diagnostics
2591+
description: >
2592+
Integration tests in crates/spar-cli/tests/applies_to_feature_path.rs
2593+
verify that `applies to subcomp.feature` resolves cleanly
2594+
(applies_to_feature_path_does_not_emit_unresolved_diagnostic) and
2595+
that genuinely unknown final segments still emit a clean
2596+
"could not be resolved" diagnostic
2597+
(applies_to_unknown_segment_still_emits_diagnostic).
2598+
fields:
2599+
method: automated-test
2600+
steps:
2601+
- run: cargo test -p spar --test applies_to_feature_path
2602+
status: passing
2603+
tags: [hir-def, properties, v093]
2604+
links:
2605+
- type: satisfies
2606+
target: REQ-APPLIES-TO-FEATURE-PATH
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! AADL v2.3 (AS5506D §11.3): `applies to` paths may end with a feature
2+
//! name. This test covers a property association with a feature-path
3+
//! target — e.g. `Latency => 5 ms .. 10 ms applies to fw.input_port;`.
4+
//!
5+
//! Pre-fix behavior: spar rejected the path because the final segment
6+
//! `input_port` was not a subcomponent, emitting a spurious "could not
7+
//! be resolved" diagnostic and dropping the property.
8+
//!
9+
//! Post-fix behavior: the path resolves to a feature; the property is
10+
//! recorded against the owning component instance and no diagnostic is
11+
//! emitted.
12+
13+
use std::env;
14+
use std::fs;
15+
use std::process::Command;
16+
17+
fn spar() -> Command {
18+
Command::new(env!("CARGO_BIN_EXE_spar"))
19+
}
20+
21+
const MODEL_FEATURE_PATH: &str = "\
22+
package Test_Applies_To_Feature
23+
public
24+
processor Cpu
25+
end Cpu;
26+
27+
thread Worker
28+
features
29+
input_port: in data port;
30+
end Worker;
31+
32+
process Proc
33+
end Proc;
34+
35+
process implementation Proc.Impl
36+
subcomponents
37+
w: thread Worker;
38+
end Proc.Impl;
39+
40+
system Sys
41+
end Sys;
42+
43+
system implementation Sys.Impl
44+
subcomponents
45+
cpu: processor Cpu;
46+
fw: process Proc.Impl;
47+
properties
48+
Actual_Processor_Binding => (reference (cpu)) applies to fw.w;
49+
Required_Connection_Quality_Of_Service => (Latency) applies to fw.w.input_port;
50+
end Sys.Impl;
51+
end Test_Applies_To_Feature;
52+
";
53+
54+
fn write_model(tag: &str) -> std::path::PathBuf {
55+
let path = env::temp_dir().join(format!(
56+
"spar_applies_to_feature_{}_{}.aadl",
57+
std::process::id(),
58+
tag
59+
));
60+
fs::write(&path, MODEL_FEATURE_PATH).expect("write temp AADL");
61+
path
62+
}
63+
64+
#[test]
65+
fn applies_to_feature_path_does_not_emit_unresolved_diagnostic() {
66+
let path = write_model("nodiag");
67+
let output = spar()
68+
.arg("instance")
69+
.arg("--root")
70+
.arg("Test_Applies_To_Feature::Sys.Impl")
71+
.arg(&path)
72+
.output()
73+
.expect("failed to run spar");
74+
75+
assert!(
76+
output.status.success(),
77+
"spar instance must not crash on feature-path applies_to; stderr:\n{}",
78+
String::from_utf8_lossy(&output.stderr)
79+
);
80+
81+
let stderr = String::from_utf8_lossy(&output.stderr);
82+
assert!(
83+
!stderr.contains("fw.w.input_port") || !stderr.contains("could not be resolved"),
84+
"spar incorrectly rejected feature-path applies_to as unresolvable.\nstderr:\n{stderr}"
85+
);
86+
87+
let _ = fs::remove_file(&path);
88+
}
89+
90+
#[test]
91+
fn applies_to_unknown_segment_still_emits_diagnostic() {
92+
let src = "\
93+
package Test_Bad_Feature_Path
94+
public
95+
processor Cpu
96+
end Cpu;
97+
98+
thread Worker
99+
features
100+
input_port: in data port;
101+
end Worker;
102+
103+
process Proc
104+
end Proc;
105+
106+
process implementation Proc.Impl
107+
subcomponents
108+
w: thread Worker;
109+
end Proc.Impl;
110+
111+
system Sys
112+
end Sys;
113+
114+
system implementation Sys.Impl
115+
subcomponents
116+
cpu: processor Cpu;
117+
fw: process Proc.Impl;
118+
properties
119+
Actual_Processor_Binding => (reference (cpu)) applies to fw.w.no_such_port;
120+
end Sys.Impl;
121+
end Test_Bad_Feature_Path;
122+
";
123+
let path = env::temp_dir().join(format!(
124+
"spar_applies_to_bad_feature_{}.aadl",
125+
std::process::id()
126+
));
127+
fs::write(&path, src).expect("write temp AADL");
128+
129+
let output = spar()
130+
.arg("instance")
131+
.arg("--root")
132+
.arg("Test_Bad_Feature_Path::Sys.Impl")
133+
.arg(&path)
134+
.output()
135+
.expect("failed to run spar");
136+
137+
let stderr = String::from_utf8_lossy(&output.stderr);
138+
assert!(
139+
stderr.contains("no_such_port") && stderr.contains("could not be resolved"),
140+
"expected unresolved-feature diagnostic in stderr, got:\n{stderr}"
141+
);
142+
143+
let _ = fs::remove_file(&path);
144+
}

crates/spar-hir-def/src/instance.rs

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,20 @@ struct ImplChainResult {
13481348
call_map: FxHashMap<String, Name>,
13491349
}
13501350

1351+
/// Result of resolving an `applies to <dotted-path>` target.
1352+
///
1353+
/// Used internally by [`Builder::resolve_applies_to_path`].
1354+
enum AppliesTarget {
1355+
/// All segments named subcomponents; resolved to this component.
1356+
Component(ComponentInstanceIdx),
1357+
/// All-but-last segments named subcomponents; the last segment names a
1358+
/// feature on the returned component (AADL v2.3 AS5506D §11.3).
1359+
FeatureOwner(ComponentInstanceIdx),
1360+
/// No valid resolution: the path contains a segment that matches neither
1361+
/// a subcomponent nor (for the last segment) a feature.
1362+
Unresolvable,
1363+
}
1364+
13511365
struct Builder<'a> {
13521366
scope: &'a GlobalScope,
13531367
components: Arena<ComponentInstance>,
@@ -2296,23 +2310,37 @@ impl<'a> Builder<'a> {
22962310
/// eagerly so that downstream analyses and the JSON instance exporter
22972311
/// (#129) see the property on the target.
22982312
///
2299-
/// If the path cannot be resolved (bad name, or walks into a feature
2300-
/// rather than a subcomponent), the property stays on the declaring
2301-
/// component and a diagnostic is recorded.
2313+
/// AADL v2.3 (AS5506D §11.3) also allows the `applies to` path to end with
2314+
/// a feature name: `Some_Property => value applies to subcomp.port;`.
2315+
/// In that case the property is stored on the component that owns the
2316+
/// feature (the resolved component at the penultimate segment), so that
2317+
/// downstream analyses can retrieve it via `properties_for`. No diagnostic
2318+
/// is emitted for valid feature paths.
2319+
///
2320+
/// If the path cannot be resolved at all (bad subcomponent name or name
2321+
/// that matches neither a subcomponent nor a feature), the property stays
2322+
/// on the declaring component and a diagnostic is recorded.
23022323
fn resolve_pending_applies_to(&mut self) {
23032324
let pending = std::mem::take(&mut self.pending_applies_to);
23042325
for (owner, path, prop) in pending {
23052326
match self.resolve_applies_to_path(owner, &path) {
2306-
Some(target) => {
2327+
AppliesTarget::Component(target) => {
23072328
self.property_maps.entry(target).or_default().add(prop);
23082329
}
2309-
None => {
2330+
AppliesTarget::FeatureOwner(component) => {
2331+
// The last segment named a feature on `component`. Store
2332+
// the property on the owning component — per AS5506D §11.3
2333+
// the property association applies to the feature instance,
2334+
// and the component is the natural retrieval point.
2335+
self.property_maps.entry(component).or_default().add(prop);
2336+
}
2337+
AppliesTarget::Unresolvable => {
23102338
// Unresolvable path: keep on owner (prior behavior) and
23112339
// emit a diagnostic so the author notices.
23122340
self.property_maps.entry(owner).or_default().add(prop);
23132341
self.diagnostics.push(InstanceDiagnostic {
23142342
message: format!(
2315-
"applies_to path '{path}' could not be resolved to a component instance"
2343+
"applies_to path '{path}' could not be resolved to a component instance or feature"
23162344
),
23172345
path: vec![self.components[owner].name.clone()],
23182346
});
@@ -2321,25 +2349,59 @@ impl<'a> Builder<'a> {
23212349
}
23222350
}
23232351

2324-
/// Walk a dotted path (`fw.firmware`) from `owner` down through
2325-
/// subcomponent children, matching names case-insensitively. Returns
2326-
/// the resolved target component index, or `None` if any segment fails.
2327-
fn resolve_applies_to_path(
2328-
&self,
2329-
owner: ComponentInstanceIdx,
2330-
path: &str,
2331-
) -> Option<ComponentInstanceIdx> {
2352+
/// Walk a dotted path (`fw.firmware` or `proc_inst.input_port`) from
2353+
/// `owner` down through subcomponent children, matching names
2354+
/// case-insensitively.
2355+
///
2356+
/// Returns:
2357+
/// - [`AppliesTarget::Component`] when all segments name subcomponents.
2358+
/// - [`AppliesTarget::FeatureOwner`] when all-but-last segments name
2359+
/// subcomponents and the final segment names a feature on the resolved
2360+
/// component (AADL v2.3 AS5506D §11.3 feature-path support).
2361+
/// - [`AppliesTarget::Unresolvable`] when any segment cannot be matched.
2362+
fn resolve_applies_to_path(&self, owner: ComponentInstanceIdx, path: &str) -> AppliesTarget {
2363+
let segments: Vec<&str> = path
2364+
.split('.')
2365+
.map(str::trim)
2366+
.filter(|s| !s.is_empty())
2367+
.collect();
2368+
2369+
if segments.is_empty() {
2370+
return AppliesTarget::Component(owner);
2371+
}
2372+
23322373
let mut current = owner;
2333-
for segment in path.split('.').map(str::trim).filter(|s| !s.is_empty()) {
2334-
let child = self.components[current].children.iter().find(|&&ci| {
2374+
for (i, &segment) in segments.iter().enumerate() {
2375+
// Try to match as a subcomponent child first.
2376+
if let Some(&child) = self.components[current].children.iter().find(|&&ci| {
23352377
self.components[ci]
23362378
.name
23372379
.as_str()
23382380
.eq_ignore_ascii_case(segment)
2339-
})?;
2340-
current = *child;
2381+
}) {
2382+
current = child;
2383+
continue;
2384+
}
2385+
2386+
// Not a child — check if this is the last segment and names a feature.
2387+
let is_last = i == segments.len() - 1;
2388+
if is_last {
2389+
let is_feature = self.components[current].features.iter().any(|&fi| {
2390+
self.features[fi]
2391+
.name
2392+
.as_str()
2393+
.eq_ignore_ascii_case(segment)
2394+
});
2395+
if is_feature {
2396+
return AppliesTarget::FeatureOwner(current);
2397+
}
2398+
}
2399+
2400+
// Neither subcomponent nor feature — unresolvable.
2401+
return AppliesTarget::Unresolvable;
23412402
}
2342-
Some(current)
2403+
2404+
AppliesTarget::Component(current)
23432405
}
23442406

23452407
/// STPA-REQ-010: Validate that connection endpoint array indices are within bounds.

0 commit comments

Comments
 (0)