Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Python bindings via PyO3 (`aml-python`)
- AML language specification in `docs/spec/`
- Conformance test suite in `tests/conformance/`
- `extends=` attribute on `<skill define="interface">` nodes for interface inheritance
(ADR-011). Enables semantically correct multi-level AML hierarchies where one
interface specialises another. The attribute is metadata and validation only
in this release — resolution remains unchanged.

### Deprecated

- Using `implements=` on `<skill define="interface">` nodes. This now produces a
validation warning. Migrate to `extends=`. The attribute will become a hard
error in a future release.
7 changes: 7 additions & 0 deletions crates/aml-core/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ pub enum NodeKind {
/// An interface definition — registered but not executed.
InterfaceDefinition {
name: String,
/// Parent interface name for interface inheritance (specialisation).
/// An interface with `extends` narrows the parent contract; it is still
/// abstract and cannot be invoked directly.
extends: Option<String>,
/// Captured from a legacy `implements=` attribute on an interface node.
/// The validator emits a deprecation warning when this is `Some`.
legacy_implements: Option<String>,
description: Option<String>,
/// Typed parameter declarations (empty for legacy text-only interfaces).
params: Vec<ParamDecl>,
Expand Down
6 changes: 6 additions & 0 deletions crates/aml-core/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ mod tests {
.register_interface(
"testing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -388,6 +389,7 @@ mod tests {
.register_interface(
"failing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -428,6 +430,7 @@ mod tests {
.register_interface(
"failing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -493,6 +496,7 @@ mod tests {
.register_interface(
"failing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -534,6 +538,7 @@ mod tests {
.register_interface(
"failing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -578,6 +583,7 @@ mod tests {
.register_interface(
"failing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down
2 changes: 2 additions & 0 deletions crates/aml-core/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,8 @@ fn build_node_kind(attrs: &HashMap<String, String>, offset: usize) -> Result<Nod
})?;
Ok(NodeKind::InterfaceDefinition {
name,
extends: attrs.get("extends").cloned(),
legacy_implements: attrs.get("implements").cloned(),
description: attrs.get("description").cloned(),
params: Vec::new(),
returns: Vec::new(),
Expand Down
219 changes: 218 additions & 1 deletion crates/aml-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use crate::ast::{IoDecl, NodeDecl, NodeKind, ParamDecl, ReturnDecl, SkillRef, To
#[derive(Debug, Clone)]
pub struct InterfaceEntry {
pub name: String,
/// Parent interface name for interface inheritance (`extends=`).
/// This is metadata only — resolution does NOT traverse the hierarchy.
pub extends: Option<String>,
pub description: Option<String>,
/// Typed parameter declarations (empty for legacy text-only interfaces).
pub params: Vec<ParamDecl>,
Expand Down Expand Up @@ -44,6 +47,14 @@ pub enum RegistryError {
implementation: String,
interface: String,
},
ExtendsUnknownInterface {
child: String,
parent: String,
},
ExtendsInterfaceCycle {
/// The cycle path, e.g. `"A -> B -> C -> A"`.
cycle: String,
},
}

impl std::fmt::Display for RegistryError {
Expand All @@ -64,6 +75,15 @@ impl std::fmt::Display for RegistryError {
"implementation '{implementation}' references unknown interface '{interface}'"
)
}
Self::ExtendsUnknownInterface { child, parent } => {
write!(
f,
"interface '{child}' extends unknown interface '{parent}'"
)
}
Self::ExtendsInterfaceCycle { cycle } => {
write!(f, "interface extends cycle detected: {cycle}")
}
}
}
}
Expand All @@ -90,6 +110,7 @@ impl SkillRegistry {
pub fn register_interface(
&mut self,
name: String,
extends: Option<String>,
description: Option<String>,
params: Vec<ParamDecl>,
returns: Vec<ReturnDecl>,
Expand All @@ -105,6 +126,7 @@ impl SkillRegistry {
name.clone(),
InterfaceEntry {
name,
extends,
description,
params,
returns,
Expand Down Expand Up @@ -156,15 +178,18 @@ impl SkillRegistry {
match kind {
NodeKind::InterfaceDefinition {
name,
extends,
description,
params,
returns,
reads,
writes,
skill_refs,
tool_constraints,
..
} => self.register_interface(
name.clone(),
extends.clone(),
description.clone(),
params.clone(),
returns.clone(),
Expand Down Expand Up @@ -220,9 +245,12 @@ impl SkillRegistry {
.unwrap_or_default()
}

/// Validate that all implementations reference known interfaces.
/// Validate that all implementations reference known interfaces, and that
/// the interface `extends` hierarchy is acyclic and references known interfaces.
pub fn validate(&self) -> Vec<RegistryError> {
let mut errors = Vec::new();

// Check implementation → interface references.
for entry in self.implementations.values() {
if !self.interfaces.contains_key(&entry.implements) {
errors.push(RegistryError::ImplementsUnknownInterface {
Expand All @@ -231,6 +259,62 @@ impl SkillRegistry {
});
}
}

// Check interface → parent references and detect cycles.
for entry in self.interfaces.values() {
if let Some(ref parent) = entry.extends {
if !self.interfaces.contains_key(parent) {
errors.push(RegistryError::ExtendsUnknownInterface {
child: entry.name.clone(),
parent: parent.clone(),
});
}
}
}

// Cycle detection: walk the extends chain for each interface.
// We only report a cycle once (anchored at the first node in alphabetical
// order within the cycle to keep output deterministic).
let mut reported_cycles: Vec<String> = Vec::new();
let mut interface_names: Vec<String> =
self.interfaces.keys().cloned().collect();
interface_names.sort();

for start in &interface_names {
if reported_cycles.contains(start) {
continue;
}
let mut path: Vec<String> = Vec::new();
let mut current = start.clone();
loop {
if let Some(pos) = path.iter().position(|n| n == &current) {
// Cycle found — extract the cyclic portion.
let cycle_nodes = &path[pos..];
let cycle_str = {
let mut s = cycle_nodes.join(" -> ");
s.push_str(" -> ");
s.push_str(&current);
s
};
// Mark all nodes in the cycle so we don't re-report.
for node in cycle_nodes {
reported_cycles.push(node.clone());
}
errors.push(RegistryError::ExtendsInterfaceCycle { cycle: cycle_str });
break;
}
path.push(current.clone());
match self
.interfaces
.get(&current)
.and_then(|e| e.extends.as_deref())
{
Some(parent) => current = parent.to_string(),
None => break,
}
}
}

errors
}
}
Expand All @@ -244,6 +328,7 @@ mod tests {
let mut reg = SkillRegistry::new();
reg.register_interface(
"testing".into(),
None,
Some("Run tests".into()),
Vec::new(),
Vec::new(),
Expand Down Expand Up @@ -275,6 +360,7 @@ mod tests {
reg.register_interface(
"testing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand All @@ -287,6 +373,7 @@ mod tests {
.register_interface(
"testing".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
Expand Down Expand Up @@ -314,4 +401,134 @@ mod tests {
let errors = reg.validate();
assert_eq!(errors.len(), 1);
}

#[test]
fn test_extends_unknown_parent_is_error() {
let mut reg = SkillRegistry::new();
reg.register_interface(
"child".into(),
Some("nonexistent-parent".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let errors = reg.validate();
assert!(
errors
.iter()
.any(|e| matches!(e, RegistryError::ExtendsUnknownInterface { child, .. } if child == "child")),
"expected ExtendsUnknownInterface; got: {errors:?}"
);
}

#[test]
fn test_extends_self_cycle_is_error() {
let mut reg = SkillRegistry::new();
reg.register_interface(
"self-loop".into(),
Some("self-loop".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let errors = reg.validate();
assert!(
errors
.iter()
.any(|e| matches!(e, RegistryError::ExtendsInterfaceCycle { .. })),
"expected cycle error for self-extension; got: {errors:?}"
);
}

#[test]
fn test_extends_two_node_cycle_is_error() {
let mut reg = SkillRegistry::new();
reg.register_interface(
"a".into(),
Some("b".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
reg.register_interface(
"b".into(),
Some("a".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let errors = reg.validate();
assert!(
errors
.iter()
.any(|e| matches!(e, RegistryError::ExtendsInterfaceCycle { .. })),
"expected cycle error for A→B→A; got: {errors:?}"
);
}

#[test]
fn test_valid_extends_hierarchy_no_errors() {
let mut reg = SkillRegistry::new();
// root
reg.register_interface(
"root".into(),
None,
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
// child extends root
reg.register_interface(
"child".into(),
Some("root".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
// grandchild extends child
reg.register_interface(
"grandchild".into(),
Some("child".into()),
None,
Vec::new(),
Vec::new(),
None,
None,
Vec::new(),
Vec::new(),
)
.unwrap();
let errors = reg.validate();
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
}
}
Loading
Loading