use std::collections::{HashMap, HashSet};
use crate::ast::*;
use crate::diagnostic::{Diagnostic, Finding};
use crate::lexer::SourceMap;
use crate::Span;
pub fn analyze(module: &Module, source: &str) -> Vec<Diagnostic> {
let empty = HashSet::new();
analyze_with_external_refs(module, source, &empty)
}
pub fn analyze_with_external_refs(
module: &Module,
source: &str,
external_refs: &HashSet<String>,
) -> Vec<Diagnostic> {
run_checks(Ctx::new(module, external_refs, None, None, None), source)
}
#[derive(Debug, Default)]
pub struct AmbiguousImports {
pub names: HashMap<String, Vec<String>>,
pub triggers: HashMap<String, Vec<String>>,
}
#[derive(Debug, Default, Clone)]
pub struct ReverseContributions {
pub provided_triggers: HashSet<String>,
pub assigned_statuses: HashMap<String, HashSet<String>>,
pub witnessed_transitions: HashMap<String, HashSet<(String, String)>>,
pub referenced_fields: HashSet<String>,
}
impl ReverseContributions {
pub fn is_empty(&self) -> bool {
self.provided_triggers.is_empty()
&& self.assigned_statuses.is_empty()
&& self.witnessed_transitions.is_empty()
&& self.referenced_fields.is_empty()
}
pub fn merge(&mut self, other: ReverseContributions) {
self.provided_triggers.extend(other.provided_triggers);
for (entity, statuses) in other.assigned_statuses {
self.assigned_statuses.entry(entity).or_default().extend(statuses);
}
for (entity, edges) in other.witnessed_transitions {
self.witnessed_transitions.entry(entity).or_default().extend(edges);
}
self.referenced_fields.extend(other.referenced_fields);
}
}
#[allow(clippy::too_many_arguments)] pub fn analyze_with_cross_module(
module: &Module,
source: &str,
external_refs: &HashSet<String>,
resolved_use_paths: &HashSet<String>,
imported_triggers: &HashMap<String, HashSet<String>>,
imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
ambiguous_imports: &AmbiguousImports,
reverse: &ReverseContributions,
imported_referenced_triggers: &HashMap<String, HashSet<String>>,
) -> Vec<Diagnostic> {
let mut ctx = Ctx::new(
module,
external_refs,
Some(resolved_use_paths),
Some(imported_triggers),
Some(ambiguous_imports),
);
ctx.imported_entity_fields = Some(imported_entity_fields);
ctx.reverse_contributions = Some(reverse);
ctx.imported_referenced_triggers = Some(imported_referenced_triggers);
run_checks(ctx, source)
}
fn run_checks(mut ctx: Ctx<'_>, source: &str) -> Vec<Diagnostic> {
ctx.check_related_surface_references();
ctx.check_discriminator_variants();
ctx.check_surface_binding_usage();
ctx.check_status_state_machine();
ctx.check_external_entity_source_hints();
ctx.check_type_references();
ctx.check_unreachable_triggers();
ctx.check_unused_fields();
ctx.check_unused_entities();
ctx.check_unused_definitions();
ctx.check_unresolved_use_paths();
ctx.check_ambiguous_imported_names();
ctx.check_deferred_location_hints(source);
ctx.check_rule_invalid_triggers();
ctx.check_rule_undefined_bindings();
ctx.check_duplicate_let_bindings();
ctx.check_config_undefined_references();
ctx.check_list_literal_homogeneity();
ctx.check_undefined_import_aliases();
ctx.check_default_field_schemas();
let mut diagnostics = apply_suppressions(ctx.diagnostics, source);
diagnostics.sort_by(|a, b| {
(a.span.start, a.span.end, a.code.unwrap_or(""), a.message.as_str()).cmp(&(
b.span.start,
b.span.end,
b.code.unwrap_or(""),
b.message.as_str(),
))
});
diagnostics
}
pub fn analyse(module: &Module, source: &str) -> crate::diagnostic::AnalyseResult {
let empty = HashSet::new();
analyse_with_external_refs(module, source, &empty)
}
pub fn analyse_with_external_refs(
module: &Module,
source: &str,
external_refs: &HashSet<String>,
) -> crate::diagnostic::AnalyseResult {
let diagnostics = analyze_with_external_refs(module, source, external_refs);
let findings = find_process_issues(module, None, None, None);
crate::diagnostic::AnalyseResult {
diagnostics,
findings,
}
}
#[allow(clippy::too_many_arguments)] pub fn analyse_with_cross_module(
module: &Module,
source: &str,
external_refs: &HashSet<String>,
resolved_use_paths: &HashSet<String>,
imported_triggers: &HashMap<String, HashSet<String>>,
imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
ambiguous_imports: &AmbiguousImports,
reverse: &ReverseContributions,
imported_referenced_triggers: &HashMap<String, HashSet<String>>,
imported_entity_statuses: &HashMap<String, HashSet<String>>,
) -> crate::diagnostic::AnalyseResult {
let diagnostics = analyze_with_cross_module(
module,
source,
external_refs,
resolved_use_paths,
imported_triggers,
imported_entity_fields,
ambiguous_imports,
reverse,
imported_referenced_triggers,
);
let findings = find_process_issues(
module,
Some(imported_triggers),
Some(reverse),
Some(imported_entity_statuses),
);
crate::diagnostic::AnalyseResult {
diagnostics,
findings,
}
}
struct EntityInfo<'a> {
status_values: HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
field_types: HashMap<&'a str, HashMap<&'a str, &'a str>>,
graph_edges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
terminals: HashMap<&'a str, HashSet<&'a str>>,
}
impl<'a> EntityInfo<'a> {
fn from_module(module: &'a Module) -> Self {
let mut status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = HashMap::new();
let mut field_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
let mut graph_edges: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
let mut terminals: HashMap<&str, HashSet<&str>> = HashMap::new();
let entities = module.declarations.iter().filter_map(|d| match d {
Decl::Block(b) if b.kind == BlockKind::Entity => Some(b),
_ => None,
});
for entity in entities {
let name = match &entity.name {
Some(n) => n.name.as_str(),
None => continue,
};
for item in &entity.items {
match &item.kind {
BlockItemKind::Assignment { name: f, value } if f.name == "status" => {
let mut idents = Vec::new();
collect_pipe_idents(value, &mut idents);
if idents.len() >= 2
&& !idents.iter().any(|id| starts_uppercase(&id.name))
{
let set: HashSet<&str> =
idents.iter().map(|id| id.name.as_str()).collect();
status_values.insert(name, (set, idents));
}
}
BlockItemKind::Assignment { name: f, value } => {
if let Some(t) = extract_field_entity_type(value) {
field_types.entry(name).or_default().insert(f.name.as_str(), t);
}
}
BlockItemKind::TransitionsBlock(graph) => {
let edges: Vec<(&str, &str)> = graph
.edges
.iter()
.map(|e| (e.from.name.as_str(), e.to.name.as_str()))
.collect();
graph_edges.insert(name, edges);
let terms: HashSet<&str> =
graph.terminal.iter().map(|t| t.name.as_str()).collect();
if !terms.is_empty() {
terminals.insert(name, terms);
}
}
_ => {}
}
}
}
Self { status_values, field_types, graph_edges, terminals }
}
fn status_by_entity(&self) -> HashMap<&'a str, HashSet<&'a str>> {
self.status_values
.iter()
.map(|(k, (set, _))| (*k, set.clone()))
.collect()
}
}
fn find_process_issues(
module: &Module,
imported_triggers: Option<&HashMap<String, HashSet<String>>>,
reverse: Option<&ReverseContributions>,
imported_statuses: Option<&HashMap<String, HashSet<String>>>,
) -> Vec<crate::diagnostic::Finding> {
let empty = HashSet::new();
let no_statuses = HashMap::new();
let mut ctx = Ctx::new(module, &empty, None, imported_triggers, None);
ctx.reverse_contributions = reverse;
let info = EntityInfo::from_module(module);
ctx.collect_process_findings(&info);
ctx.collect_conflict_findings(&info, imported_statuses.unwrap_or(&no_statuses));
ctx.collect_invariant_findings(&info);
let mut findings = std::mem::take(&mut ctx.findings);
findings.sort_by_cached_key(|f| {
(
f["type"].as_str().unwrap_or("").to_string(),
f["summary"].as_str().unwrap_or("").to_string(),
f.to_string(),
)
});
findings
}
fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
if diagnostics.is_empty() {
return diagnostics;
}
let sm = SourceMap::new(source);
let directives = collect_suppression_directives(source, &sm);
if directives.is_empty() {
return diagnostics;
}
diagnostics
.into_iter()
.filter(|d| {
let (line, _) = sm.line_col(d.span.start);
let line = line as i64;
let active = directives
.get(&(line as u32))
.or_else(|| directives.get(&((line - 1).max(0) as u32)));
match (active, d.code) {
(Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
(Some(codes), None) => !codes.contains("all"),
_ => true,
}
})
.collect()
}
fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
let mut directives = HashMap::new();
let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
for m in pattern.find_iter(source) {
let text = m.as_str();
let (line, _) = sm.line_col(m.start());
if let Some(idx) = text.find("allium-ignore") {
let offset = m.start() + idx + "allium-ignore".len();
let source_after = &source[offset..m.end()];
let codes: HashSet<&'a str> = source_after
.split(',')
.map(|c| c.trim())
.filter(|c| !c.is_empty())
.collect();
directives.insert(line, codes);
}
}
directives
}
struct Ctx<'a> {
module: &'a Module,
external_refs: &'a HashSet<String>,
resolved_use_paths: Option<&'a HashSet<String>>,
imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
ambiguous_imports: Option<&'a AmbiguousImports>,
imported_entity_fields: Option<&'a HashMap<String, HashMap<String, HashSet<String>>>>,
imported_referenced_triggers: Option<&'a HashMap<String, HashSet<String>>>,
reverse_contributions: Option<&'a ReverseContributions>,
diagnostics: Vec<Diagnostic>,
findings: Vec<crate::diagnostic::Finding>,
}
impl<'a> Ctx<'a> {
fn new(
module: &'a Module,
external_refs: &'a HashSet<String>,
resolved_use_paths: Option<&'a HashSet<String>>,
imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
ambiguous_imports: Option<&'a AmbiguousImports>,
) -> Self {
Self {
module,
external_refs,
resolved_use_paths,
imported_triggers,
ambiguous_imports,
imported_entity_fields: None,
imported_referenced_triggers: None,
reverse_contributions: None,
diagnostics: Vec::new(),
findings: Vec::new(),
}
}
fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
self.module.declarations.iter().filter_map(move |d| match d {
Decl::Block(b) if b.kind == kind => Some(b),
_ => None,
})
}
fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
self.module
.declarations
.iter()
.filter_map(|d| match d {
Decl::Variant(v) => Some(v),
_ => None,
})
}
fn has_use_imports(&self) -> bool {
self.module
.declarations
.iter()
.any(|d| matches!(d, Decl::Use(_)))
}
fn push(&mut self, d: Diagnostic) {
self.diagnostics.push(d);
}
fn push_finding(&mut self, finding: Finding) {
self.findings.push(finding);
}
fn declared_type_names(&self) -> HashSet<&'a str> {
let mut names = HashSet::new();
for d in &self.module.declarations {
match d {
Decl::Block(b) => {
if matches!(
b.kind,
BlockKind::Entity
| BlockKind::ExternalEntity
| BlockKind::Value
| BlockKind::Enum
| BlockKind::Actor
) {
if let Some(n) = &b.name {
names.insert(n.name.as_str());
}
}
}
Decl::Variant(v) => {
names.insert(v.name.name.as_str());
}
_ => {}
}
}
for t in &[
"String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
"List", "Set", "Map", "Any", "Void",
] {
names.insert(t);
}
for d in &self.module.declarations {
if let Decl::Use(u) = d {
if let Some(alias) = &u.alias {
names.insert(alias.name.as_str());
}
}
}
names
}
fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
let mut names = HashSet::new();
for d in &self.module.declarations {
match d {
Decl::Block(b) => {
for item in &b.items {
collect_accessed_fields_from_item(&item.kind, &mut names);
}
if matches!(b.kind, BlockKind::Entity | BlockKind::ExternalEntity) {
let field_names: HashSet<&str> = b
.items
.iter()
.filter_map(|it| match &it.kind {
BlockItemKind::Assignment { name, .. }
| BlockItemKind::FieldWithWhen { name, .. } => {
Some(name.name.as_str())
}
_ => None,
})
.collect();
let mut idents = HashSet::new();
for item in &b.items {
collect_idents_from_item(&item.kind, &mut idents);
}
names.extend(idents.intersection(&field_names).copied());
}
}
Decl::Invariant(inv) => {
collect_accessed_fields_from_expr(&inv.body, &mut names);
}
_ => {}
}
}
names
}
}
impl Ctx<'_> {
fn check_related_surface_references(&mut self) {
let surface_names: HashSet<&str> = self
.blocks(BlockKind::Surface)
.filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
.collect();
for surface in self.blocks(BlockKind::Surface) {
let surface_name = match &surface.name {
Some(n) => &n.name,
None => continue,
};
for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "related" {
continue;
}
let refs = extract_related_surface_names(value);
for ident in refs {
if !surface_names.contains(ident.name.as_str()) {
self.push(
Diagnostic::error(
ident.span,
format!(
"Surface '{surface_name}' references unknown related surface '{}'.",
ident.name
),
)
.with_code("allium.surface.relatedUndefined"),
);
}
}
}
}
}
}
fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
match expr {
Expr::Ident(id) => vec![id],
Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
Expr::Block { items, .. } => items
.iter()
.flat_map(extract_related_surface_names)
.collect(),
_ => vec![],
}
}
fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
match expr {
Expr::Ident(id) => Some(id),
Expr::MemberAccess { object, .. } => extract_leading_ident(object),
_ => None,
}
}
impl Ctx<'_> {
fn check_discriminator_variants(&mut self) {
let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
for v in self.variants() {
let base_name = expr_as_ident(&v.base).or_else(|| {
if let Expr::JoinLookup { entity, .. } = &v.base {
expr_as_ident(entity)
} else {
None
}
});
if let Some(base_name) = base_name {
variants_by_base
.entry(base_name)
.or_default()
.insert(&v.name.name);
}
}
for entity in self.blocks(BlockKind::Entity) {
let entity_name = match &entity.name {
Some(n) => &n.name,
None => continue,
};
for item in &entity.items {
let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
continue;
};
let mut pipe_idents = Vec::new();
collect_pipe_idents(value, &mut pipe_idents);
if pipe_idents.len() < 2 {
continue;
}
let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
if !has_capitalised {
continue;
}
let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
if !all_capitalised {
self.push(
Diagnostic::error(
value.span(),
format!(
"Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
field_name.name
),
)
.with_code("allium.sum.invalidDiscriminator"),
);
continue;
}
let declared = variants_by_base
.get(entity_name.as_str())
.cloned()
.unwrap_or_default();
let missing: Vec<&&Ident> = pipe_idents
.iter()
.filter(|id| !declared.contains(id.name.as_str()))
.collect();
if missing.len() == pipe_idents.len() && declared.is_empty() {
self.push(
Diagnostic::error(
value.span(),
format!(
"Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
declarations. Use lowercase values for a plain enum.",
field_name.name
),
)
.with_code("allium.sum.v1InlineEnum"),
);
} else {
for id in missing {
self.push(
Diagnostic::error(
id.span,
format!(
"Entity '{entity_name}' discriminator references '{}' without matching \
'variant {} : {entity_name}'.",
id.name, id.name
),
)
.with_code("allium.sum.discriminatorUnknownVariant"),
);
}
}
}
}
}
}
fn starts_uppercase(s: &str) -> bool {
s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
}
fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
match expr {
Expr::Ident(id) => out.push(id),
Expr::Pipe { left, right, .. } => {
collect_pipe_idents(left, out);
collect_pipe_idents(right, out);
}
_ => {}
}
}
fn expr_as_ident(expr: &Expr) -> Option<&str> {
match expr {
Expr::Ident(id) => Some(&id.name),
_ => None,
}
}
impl Ctx<'_> {
fn check_surface_binding_usage(&mut self) {
for surface in self.blocks(BlockKind::Surface) {
let surface_name = match &surface.name {
Some(n) => &n.name,
None => continue,
};
let has_provides = surface
.items
.iter()
.any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "facing" && keyword != "context" {
continue;
}
if let Expr::Binding { name, .. } = value {
bindings.push((&name.name, name.span, keyword == "facing"));
}
}
for (name, span, is_facing) in &bindings {
if *name == "_" {
continue;
}
if *is_facing && !has_provides {
continue;
}
let used = surface.items.iter().any(|item| {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
return item_contains_ident(&item.kind, name);
};
if keyword == "facing" || keyword == "context" {
if let Expr::Binding {
name: binding_name, ..
} = value
{
if binding_name.name == *name {
return false;
}
}
}
expr_contains_ident(value, name)
});
if !used {
self.push(
Diagnostic::warning(
*span,
format!(
"Surface '{surface_name}' binding '{name}' is not used in the surface body.",
),
)
.with_code("allium.surface.unusedBinding"),
);
}
}
}
}
}
fn for_each_rule_clause<'a>(items: &'a [BlockItem], f: &mut impl FnMut(&'a str, &'a Expr)) {
for item in items {
match &item.kind {
BlockItemKind::Clause { keyword, value } => f(keyword, value),
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
for_each_rule_clause(&b.items, f);
}
if let Some(else_items) = else_items {
for_each_rule_clause(else_items, f);
}
}
BlockItemKind::ForBlock { items, .. } => {
for_each_rule_clause(items, f);
}
_ => {}
}
}
}
impl Ctx<'_> {
fn check_status_state_machine(&mut self) {
let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
let mut terminal_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut has_transitions: HashSet<&str> = HashSet::new();
let mut declared_edges: HashMap<&str, HashSet<(&str, &str)>> = HashMap::new();
let mut field_entity_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
for entity in self.blocks(BlockKind::Entity) {
let entity_name = match &entity.name {
Some(n) => n.name.as_str(),
None => continue,
};
for item in &entity.items {
match &item.kind {
BlockItemKind::Assignment { name, value } if name.name == "status" => {
let mut idents = Vec::new();
collect_pipe_idents(value, &mut idents);
if idents.len() < 2 {
continue;
}
if idents.iter().any(|id| starts_uppercase(&id.name)) {
continue;
}
let set: HashSet<&str> =
idents.iter().map(|id| id.name.as_str()).collect();
status_by_entity.insert(entity_name, (idents, set));
}
BlockItemKind::Assignment { name, value } => {
if let Some(type_name) = extract_field_entity_type(value) {
field_entity_types
.entry(entity_name)
.or_default()
.insert(name.name.as_str(), type_name);
}
}
BlockItemKind::TransitionsBlock(graph) => {
has_transitions.insert(entity_name);
let terminals: HashSet<&str> =
graph.terminal.iter().map(|t| t.name.as_str()).collect();
if !terminals.is_empty() {
terminal_by_entity.insert(entity_name, terminals);
}
let edges: HashSet<(&str, &str)> = graph
.edges
.iter()
.map(|e| (e.from.name.as_str(), e.to.name.as_str()))
.collect();
declared_edges.insert(entity_name, edges);
}
_ => {}
}
}
}
for fields in field_entity_types.values_mut() {
fields.retain(|_, type_name| status_by_entity.contains_key(type_name));
}
if status_by_entity.is_empty() {
return;
}
let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
for surface in self.blocks(BlockKind::Surface) {
for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword == "provides" {
collect_command_param_types(value, &status_by_entity, &mut command_param_types);
}
}
}
let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
HashMap::new();
let mut created_issues: Vec<Diagnostic> = Vec::new();
for rule in self.blocks(BlockKind::Rule) {
let mut binding_types = collect_rule_binding_types(rule, &status_by_entity);
augment_binding_types_from_commands(rule, &command_param_types, &mut binding_types);
let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "requires" {
return;
}
visit_status_comparisons(
value,
&binding_types,
&status_by_entity,
&field_entity_types,
&mut |binding, status| {
requires_by_binding
.entry(binding)
.or_default()
.insert(status);
},
);
});
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "when" {
return;
}
if let Some((binding, entity, source)) = local_transition_trigger_source(value) {
if status_by_entity
.get(entity)
.is_some_and(|(_, values)| values.contains(source))
{
requires_by_binding.entry(binding).or_default().insert(source);
}
}
});
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "ensures" {
return;
}
visit_status_assignments(
value,
&binding_types,
&status_by_entity,
&field_entity_types,
&mut |binding, target, entity| {
assigned_by_entity
.entry(entity)
.or_default()
.insert(target);
if let Some(sources) = requires_by_binding.get(binding) {
let entity_transitions =
transitions_by_entity.entry(entity).or_default();
for source in sources {
entity_transitions
.entry(source)
.or_default()
.insert(target);
}
}
},
);
visit_created_calls(
value,
&status_by_entity,
&has_transitions,
&mut |entity, status| {
assigned_by_entity
.entry(entity)
.or_default()
.insert(status);
},
&mut created_issues,
);
});
}
if let Some(rev) = self.reverse_contributions {
for (entity, statuses) in &rev.assigned_statuses {
if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
let set = assigned_by_entity.entry(*key).or_default();
for s in statuses {
if values.contains(s.as_str()) {
set.insert(s.as_str());
}
}
}
}
for (entity, edges) in &rev.witnessed_transitions {
if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
for (from, to) in edges {
if values.contains(from.as_str()) && values.contains(to.as_str()) {
transitions_by_entity
.entry(*key)
.or_default()
.entry(from.as_str())
.or_default()
.insert(to.as_str());
assigned_by_entity.entry(*key).or_default().insert(to.as_str());
}
}
}
}
}
for (entity_name, (idents, values)) in &status_by_entity {
let assigned = assigned_by_entity.get(entity_name);
let transitions = transitions_by_entity.get(entity_name);
if let Some(assigned) = assigned {
if assigned.iter().any(|v| !values.contains(v)) {
continue;
}
}
let assigned_set = assigned.cloned().unwrap_or_default();
let transition_map = transitions.cloned().unwrap_or_default();
for id in idents {
if !assigned_set.contains(id.name.as_str()) {
self.push(
Diagnostic::warning(
id.span,
format!(
"Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
id.name
),
)
.with_code("allium.status.unreachableValue"),
);
}
let is_terminal = terminal_by_entity
.get(entity_name)
.map_or_else(
|| is_likely_terminal(&id.name),
|terminals| terminals.contains(id.name.as_str()),
);
if is_terminal {
continue;
}
let exits = transition_map.get(id.name.as_str());
if exits.is_some_and(|e| !e.is_empty()) {
continue;
}
self.push(
Diagnostic::warning(
id.span,
format!(
"Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
id.name
),
)
.with_code("allium.status.noExit"),
);
}
}
for (entity_name, transition_map) in &transitions_by_entity {
if let Some(edges) = declared_edges.get(entity_name) {
if let Some((idents, _)) = status_by_entity.get(entity_name) {
for (from, targets) in transition_map {
for to in targets {
if from != to && !edges.contains(&(*from, *to)) {
let span = idents
.iter()
.find(|id| id.name == *from)
.map(|id| id.span)
.unwrap_or(idents[0].span);
self.push(
Diagnostic::warning(
span,
format!(
"Rule produces transition '{from}' → '{to}' on entity '{entity_name}', but this edge is not in the declared transition graph.",
),
)
.with_code("allium.status.undeclaredTransition"),
);
}
}
}
}
}
}
for issue in created_issues {
self.push(issue);
}
}
}
impl Ctx<'_> {
fn collect_process_findings(&mut self, info: &EntityInfo<'_>) {
let status_values = &info.status_values;
let field_types = &info.field_types;
let graph_edges = &info.graph_edges;
let terminals = &info.terminals;
if status_values.is_empty() {
return;
}
let mut surface_triggers: HashSet<&str> = HashSet::new();
let mut surface_names: Vec<String> = Vec::new();
for surface in self.blocks(BlockKind::Surface) {
if let Some(n) = &surface.name {
surface_names.push(n.name.clone());
}
for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword == "provides" {
collect_call_names(value, &mut surface_triggers);
}
}
}
if let Some(rev) = self.reverse_contributions {
for t in &rev.provided_triggers {
surface_triggers.insert(t.as_str());
}
}
let mut emitted_triggers: HashSet<&str> = HashSet::new();
for rule in self.blocks(BlockKind::Rule) {
for item in &rule.items {
collect_emitted_trigger_from_item(&item.kind, &mut emitted_triggers);
}
}
let mut assigned_fields: HashSet<String> = HashSet::new();
if let Some(rev) = self.reverse_contributions {
for (entity, statuses) in &rev.assigned_statuses {
for s in statuses {
assigned_fields.insert(format!("{entity}.status.{s}"));
}
}
for (entity, edges) in &rev.witnessed_transitions {
for (_from, to) in edges {
assigned_fields.insert(format!("{entity}.status.{to}"));
}
}
}
struct RuleData<'b> {
name: &'b str,
trigger_reachable: bool,
requires_fields: Vec<(String, String, String)>,
transitions: Vec<(String, String, String)>,
field_assignments: HashSet<String>,
entity_bindings: Vec<String>,
}
let mut rules: Vec<RuleData> = Vec::new();
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => n.name.as_str(),
None => continue,
};
let mut trigger_ref: Option<TriggerRef<'_>> = None;
let mut requires_statuses: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut requires_fields: Vec<(String, String, String)> = Vec::new();
let mut ensures_statuses: Vec<(&str, &str)> = Vec::new();
let mut rule_assigned: HashSet<String> = HashSet::new();
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword == "when" {
let mut refs = extract_trigger_refs(value);
if !refs.is_empty() {
trigger_ref = Some(refs.remove(0));
}
}
}
let trigger_reachable = trigger_ref.as_ref().map_or(true, |t| {
self.trigger_reachability(t, &surface_triggers, &emitted_triggers)
.unwrap_or(true)
});
let binding_types = collect_rule_binding_types(rule, &status_values_for_binding(&status_values));
let entity_bindings: Vec<String> = binding_types
.values()
.map(|v| v.to_string())
.collect::<HashSet<_>>()
.into_iter()
.collect();
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "requires" {
return;
}
collect_requires_conditions(
value,
&binding_types,
status_values,
&mut |binding, field, val| {
if field == "status" {
requires_statuses
.entry(binding)
.or_default()
.insert(val);
} else {
let entity = resolve_binding_entity_from_status(
binding, None, &binding_types, &status_values,
);
if let Some(e) = entity {
requires_fields.push((
e.to_string(),
field.to_string(),
val.to_string(),
));
}
}
},
);
});
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "ensures" {
return;
}
collect_field_assignments(
value,
&binding_types,
&status_values,
&field_types,
&mut |entity, field, value| {
let key = format!("{entity}.{field}");
assigned_fields.insert(key.clone());
rule_assigned.insert(key);
if field == "status" && value != "_variable_" {
assigned_fields.insert(format!("{entity}.status.{value}"));
}
},
);
collect_ensures_status(
value,
&binding_types,
&status_values,
&field_types,
&mut |binding, target| {
ensures_statuses.push((binding, target));
},
);
});
let mut transitions = Vec::new();
for (binding, target) in &ensures_statuses {
let entity = resolve_binding_entity_from_status(
binding,
Some(target),
&binding_types,
&status_values,
);
if let Some(e) = entity {
if let Some(sources) = requires_statuses.get(binding) {
for source in sources {
transitions.push((
e.to_string(),
source.to_string(),
target.to_string(),
));
}
}
}
}
rules.push(RuleData {
name: rule_name,
trigger_reachable,
requires_fields,
transitions,
field_assignments: rule_assigned,
entity_bindings,
});
}
let mut created_fields: HashSet<String> = HashSet::new();
for rule in self.blocks(BlockKind::Rule) {
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword != "ensures" {
return;
}
collect_created_field_assignments(value, &status_values, &mut assigned_fields);
collect_created_field_assignments(value, &status_values, &mut created_fields);
});
}
let mut surface_provided_fields: HashSet<String> = HashSet::new();
for surface in self.blocks(BlockKind::Surface) {
for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword == "provides" {
collect_surface_provided_fields(value, &status_values, &mut surface_provided_fields);
}
}
}
let build_searched = |entity: &str, field: &str| -> Vec<serde_json::Value> {
let key = format!("{entity}.{field}");
let mut searched = Vec::new();
let matching_rule: Option<&RuleData> = rules.iter().find(|r| {
r.field_assignments.contains(&key)
});
if let Some(r) = matching_rule {
if !r.trigger_reachable {
searched.push(serde_json::json!({
"kind": "rule_ensures",
"found": r.name,
"but": "trigger has no providing surface"
}));
} else {
searched.push(serde_json::json!({
"kind": "rule_ensures",
"found": r.name
}));
}
} else {
searched.push(serde_json::json!({
"kind": "rule_ensures",
"found": false
}));
}
searched.push(serde_json::json!({
"kind": "surface_provides",
"found": surface_provided_fields.contains(&key)
}));
searched.push(serde_json::json!({
"kind": "created_calls",
"found": created_fields.contains(&key)
}));
searched
};
for (entity, edges) in graph_edges {
let _statuses = match status_values.get(entity) {
Some(v) => v,
None => continue,
};
for (from, to) in edges {
let witnesses: Vec<&RuleData> = rules
.iter()
.filter(|r| {
r.transitions
.iter()
.any(|(e, f, t)| e == *entity && f == *from && t == *to)
})
.collect();
if witnesses.is_empty() {
continue;
}
let any_achievable = witnesses.iter().any(|r| {
r.requires_fields.iter().all(|(e, f, _v)| {
assigned_fields.contains(&format!("{e}.{f}"))
})
});
if !any_achievable {
let witness_names: Vec<String> =
witnesses.iter().map(|r| r.name.to_string()).collect();
let unsatisfiable: Vec<serde_json::Value> = witnesses
.iter()
.flat_map(|r| {
r.requires_fields.iter().filter(|(e, f, _)| {
!assigned_fields.contains(&format!("{e}.{f}"))
})
})
.map(|(e, f, v)| {
serde_json::json!({
"entity": e,
"field": f,
"value": v,
"searched": build_searched(e, f),
})
})
.collect();
self.push_finding(serde_json::json!({
"type": "dead_transition",
"summary": format!(
"Transition '{from}' → '{to}' on entity '{entity}' is declared but unachievable"
),
"edge": {"entity": entity, "from": from, "to": to},
"witnessing_rules": witness_names,
"unsatisfiable_requires": unsatisfiable,
"affected_entities": [entity],
}));
}
}
}
for r in &rules {
for (entity, field, value) in &r.requires_fields {
let key = format!("{entity}.{field}");
if !assigned_fields.contains(&key) {
self.push_finding(serde_json::json!({
"type": "missing_producer",
"summary": format!("Nothing establishes {entity}.{field} = {value}"),
"requires": {"rule": r.name, "field": field, "value": value},
"searched": build_searched(entity, field),
"affected_entities": [entity],
}));
}
}
}
for (entity, edges) in graph_edges {
let entity_terminals = match terminals.get(entity) {
Some(t) => t,
None => continue,
};
let (statuses, _idents) = match status_values.get(entity) {
Some(v) => v,
None => continue,
};
let achievable_edges: HashSet<(&str, &str)> = edges
.iter()
.filter(|(_from, to)| {
let producers: Vec<&RuleData> = rules
.iter()
.filter(|r| {
r.transitions
.iter()
.any(|(e, _f, t)| e == *entity && t == *to)
})
.collect();
if producers.is_empty() {
return assigned_fields.contains(&format!("{entity}.status.{to}"));
}
producers.iter().any(|r| {
r.requires_fields.iter().all(|(e, f, _v)| {
assigned_fields.contains(&format!("{e}.{f}"))
})
})
})
.copied()
.collect();
for status in statuses {
if entity_terminals.contains(status) {
continue;
}
let mut visited = HashSet::new();
let mut queue = vec![*status];
let mut found_terminal = false;
while let Some(current) = queue.pop() {
if !visited.insert(current) {
continue;
}
if entity_terminals.contains(current) {
found_terminal = true;
break;
}
for (from, to) in &achievable_edges {
if *from == current {
queue.push(to);
}
}
}
if !found_terminal {
let has_inbound = achievable_edges
.iter()
.any(|(_, to)| *to == *status);
if has_inbound || statuses.len() <= 6 {
let outbound: Vec<serde_json::Value> = edges
.iter()
.filter(|(f, _)| *f == *status)
.map(|(f, t)| {
let witness_rules: Vec<(&str, &[(String, String, String)])> =
rules
.iter()
.filter(|r| {
r.transitions.iter().any(|(e, _ef, et)| {
e == *entity && et == *t
})
})
.map(|r| {
(r.name, r.requires_fields.as_slice())
})
.collect();
let reason = edge_blocked_reason(
&witness_rules, &assigned_fields,
);
serde_json::json!({
"from": f,
"to": t,
"reason": reason,
})
})
.collect();
let cycle = detect_cycle(*status, &achievable_edges);
self.push_finding(serde_json::json!({
"type": "deadlock",
"summary": format!(
"Entity '{entity}' can reach state '{status}' but has no achievable path to any terminal state"
),
"state": status,
"outbound_edges": outbound,
"cycle": cycle,
"affected_entities": [entity],
}));
}
}
}
}
let mut unreachable_by_trigger: HashMap<String, Vec<(&str, Vec<String>)>> = HashMap::new();
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => n.name.as_str(),
None => continue,
};
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
for tref in extract_trigger_refs(value) {
if self.trigger_reachability(&tref, &surface_triggers, &emitted_triggers)
== Some(false)
{
let rule_data = rules.iter().find(|r| r.name == rule_name);
let bindings = rule_data
.map(|r| r.entity_bindings.clone())
.unwrap_or_default();
unreachable_by_trigger
.entry(tref.display())
.or_default()
.push((rule_name, bindings));
}
}
}
}
for (trigger, rule_entries) in &unreachable_by_trigger {
let listening_rules: Vec<&str> = rule_entries.iter().map(|(n, _)| *n).collect();
let affected_entities: Vec<String> = rule_entries
.iter()
.flat_map(|(_, bindings)| bindings.iter().cloned())
.collect::<HashSet<_>>()
.into_iter()
.collect();
self.push_finding(serde_json::json!({
"type": "unreachable_trigger",
"summary": format!(
"Trigger '{trigger}' is not provided by any surface"
),
"trigger": trigger,
"listening_rules": listening_rules,
"surfaces_checked": surface_names,
"affected_entities": affected_entities,
}));
}
}
fn collect_conflict_findings(
&mut self,
info: &EntityInfo<'_>,
imported_statuses: &HashMap<String, HashSet<String>>,
) {
let local = info.status_by_entity();
let mut status_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
for (k, v) in &local {
status_by_entity.insert(*k, v.iter().copied().collect());
}
for (ent, statuses) in imported_statuses {
status_by_entity
.entry(ent.as_str())
.or_insert_with(|| statuses.iter().map(String::as_str).collect());
}
if status_by_entity.is_empty() {
return;
}
struct ConflictRule<'b> {
name: &'b str,
trigger_kind: ConflictTriggerKind<'b>,
requires_statuses: HashMap<String, HashSet<String>>,
ensures_statuses: HashMap<String, String>,
}
let mut conflict_rules: Vec<ConflictRule> = Vec::new();
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => n.name.as_str(),
None => continue,
};
let binding_types = collect_rule_binding_types(rule, &HashMap::<&str, ()>::new());
let mut trigger_kind = ConflictTriggerKind::Unknown;
let mut requires_statuses: HashMap<String, HashSet<String>> = HashMap::new();
let mut ensures_statuses: HashMap<String, String> = HashMap::new();
for_each_rule_clause(&rule.items, &mut |keyword, value| match keyword {
"when" => {
trigger_kind = classify_trigger(value);
}
"requires" => {
collect_requires_statuses_for_conflict(
value,
&binding_types,
&status_by_entity,
&mut requires_statuses,
);
}
"ensures" => {
collect_ensures_statuses_for_conflict(
value,
&binding_types,
&status_by_entity,
&mut ensures_statuses,
);
}
_ => {}
});
conflict_rules.push(ConflictRule {
name: rule_name,
trigger_kind,
requires_statuses,
ensures_statuses,
});
}
let mut reported: HashSet<(usize, usize)> = HashSet::new();
for i in 0..conflict_rules.len() {
for j in (i + 1)..conflict_rules.len() {
let a = &conflict_rules[i];
let b = &conflict_rules[j];
if matches!(
(&a.trigger_kind, &b.trigger_kind),
(ConflictTriggerKind::Call(_), ConflictTriggerKind::Call(_))
) {
continue;
}
let mut overlap_state: Option<(&str, &str)> = None;
let mut compatible = false;
for (entity, a_statuses) in &a.requires_statuses {
if let Some(b_statuses) = b.requires_statuses.get(entity) {
let intersection: Vec<&String> =
a_statuses.intersection(b_statuses).collect();
if !intersection.is_empty() {
compatible = true;
overlap_state = Some((entity.as_str(), intersection[0].as_str()));
break;
}
}
}
if !compatible {
continue;
}
for (entity, a_target) in &a.ensures_statuses {
if let Some(b_target) = b.ensures_statuses.get(entity) {
if a_target != b_target && !reported.contains(&(i, j)) {
reported.insert((i, j));
let state = overlap_state
.map(|(_, s)| s.to_string())
.unwrap_or_default();
let mut values = serde_json::Map::new();
values.insert(a.name.to_string(), serde_json::json!(a_target));
values.insert(b.name.to_string(), serde_json::json!(b_target));
self.push_finding(serde_json::json!({
"type": "conflict",
"summary": format!(
"Rules '{}' and '{}' can both fire when entity '{entity}' is in state '{state}', setting status to conflicting values",
a.name, b.name,
),
"rule_a": a.name,
"rule_b": b.name,
"field": "status",
"state": state,
"values": values,
"affected_entities": [entity],
}));
}
}
}
}
}
}
fn collect_invariant_findings(&mut self, info: &EntityInfo<'_>) {
let status_by_entity = info.status_by_entity();
let field_types = &info.field_types;
struct RuleEffect<'b> {
name: &'b str,
status_sets: Vec<(String, String)>,
field_sets: HashSet<String>,
requires: Vec<(String, String, String)>,
}
let binding_map: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
.iter()
.map(|(k, v)| (*k, (v.clone(), Vec::new())))
.collect();
let binding_map_for_types: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = status_by_entity
.iter()
.map(|(k, v)| (*k, (Vec::new(), v.clone())))
.collect();
let mut rule_effects: Vec<RuleEffect> = Vec::new();
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => n.name.as_str(),
None => continue,
};
let binding_types = collect_rule_binding_types(rule, &binding_map_for_types);
let mut status_sets = Vec::new();
let mut field_sets = HashSet::new();
let mut requires = Vec::new();
for_each_rule_clause(&rule.items, &mut |keyword, value| match keyword {
"ensures" => {
collect_rule_effects(
value,
&binding_types,
&status_by_entity,
&field_types,
&mut status_sets,
&mut field_sets,
);
}
"requires" => {
collect_requires_conditions(
value,
&binding_types,
&binding_map,
&mut |binding, field, val| {
let entity = resolve_binding_entity(
binding,
None,
&binding_types,
&binding_map_for_types,
);
if let Some(e) = entity {
requires.push((
e.to_string(),
field.to_string(),
val.to_string(),
));
}
},
);
}
_ => {}
});
rule_effects.push(RuleEffect {
name: rule_name,
status_sets,
field_sets,
requires,
});
}
for decl in &self.module.declarations {
let Decl::Invariant(inv) = decl else {
continue;
};
if let Some(pattern) = extract_uniqueness_invariant(&inv.body) {
let key_entity_type: Option<&str> = status_by_entity
.keys()
.find_map(|entity_name| {
field_types
.get(entity_name)
.and_then(|fields| fields.get(pattern.key_field).copied())
});
for effect in &rule_effects {
for (entity, target) in &effect.status_sets {
if target == pattern.prohibited_status {
let has_guard = key_entity_type.map_or(false, |ket| {
effect.field_sets.iter().any(|f| {
f.starts_with(&format!("{ket}."))
}) || effect.requires.iter().any(|(e, _f, _v)| {
e == ket
})
});
if !has_guard {
let needed = format!(
"Rule should set {}.status to prevent concurrent {} states",
key_entity_type.unwrap_or("related entity"),
pattern.prohibited_status,
);
self.push_finding(serde_json::json!({
"type": "invariant_risk",
"summary": format!(
"Rule '{}' could violate invariant '{}'",
effect.name, inv.name.name,
),
"rule": effect.name,
"invariant": inv.name.name,
"mechanism": format!(
"Sets {entity}.status to '{target}' without preventing concurrent instances"
),
"guard_analysis": {
"has_guard": false,
"needed": needed,
},
"affected_entities": [entity],
}));
}
}
}
}
}
}
}
}
fn edge_blocked_reason(
witness_rules: &[(&str, &[(String, String, String)])],
assigned_fields: &HashSet<String>,
) -> String {
if witness_rules.is_empty() {
return "no witnessing rule".to_string();
}
for (name, requires_fields) in witness_rules {
for (e, f, v) in *requires_fields {
if !assigned_fields.contains(&format!("{e}.{f}")) {
return format!(
"rule {name} requires {e}.{f} = {v}, never established",
);
}
}
}
"no achievable witnessing rule".to_string()
}
fn detect_cycle<'a>(
start: &'a str,
edges: &HashSet<(&'a str, &'a str)>,
) -> Option<Vec<&'a str>> {
let mut stack: Vec<(&str, Vec<&str>)> = vec![(start, vec![start])];
let mut visited: HashSet<&str> = HashSet::new();
while let Some((current, path)) = stack.pop() {
if !visited.insert(current) {
continue;
}
for (from, to) in edges {
if *from != current {
continue;
}
if let Some(pos) = path.iter().position(|s| *s == *to) {
let mut cycle: Vec<&str> = path[pos..].to_vec();
cycle.push(to);
return Some(cycle);
}
let mut next_path = path.clone();
next_path.push(to);
visited.remove(to);
stack.push((to, next_path));
}
}
None
}
fn collect_surface_provided_fields(
expr: &Expr,
status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
out: &mut HashSet<String>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::Ident(fn_name) = function.as_ref() {
for arg in args {
if let CallArg::Positional(Expr::Ident(binding)) = arg {
if status_values.contains_key(binding.name.as_str()) {
out.insert(format!("{}.status", binding.name));
}
}
if let CallArg::Named(named) = arg {
if let Expr::Ident(val) = &named.value {
if status_values.contains_key(val.name.as_str()) {
out.insert(format!("{}.{}", val.name, named.name.name));
}
}
}
}
let _ = fn_name;
}
}
Expr::Block { items, .. } => {
for item in items {
collect_surface_provided_fields(item, status_values, out);
}
}
Expr::WhenGuard { action, .. } => {
collect_surface_provided_fields(action, status_values, out);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_surface_provided_fields(&b.body, status_values, out);
}
if let Some(body) = else_body {
collect_surface_provided_fields(body, status_values, out);
}
}
_ => {}
}
}
fn collect_rule_effects(
expr: &Expr,
binding_types: &HashMap<&str, &str>,
status_by_entity: &HashMap<&str, HashSet<&str>>,
field_types: &HashMap<&str, HashMap<&str, &str>>,
status_sets: &mut Vec<(String, String)>,
field_sets: &mut HashSet<String>,
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, field)) = expr_as_member_access(left) {
let entity = resolve_binding_entity(
binding,
if field == "status" { Some(target) } else { None },
binding_types,
&status_by_entity
.iter()
.map(|(k, v)| (*k, (Vec::new(), v.clone())))
.collect(),
);
if let Some(e) = entity {
if field == "status" {
status_sets.push((e.to_string(), target.to_string()));
}
field_sets.insert(format!("{e}.{field}"));
}
}
if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
let root_entity = resolve_binding_entity(
root,
None,
binding_types,
&status_by_entity
.iter()
.map(|(k, v)| (*k, (Vec::new(), v.clone())))
.collect(),
);
if let Some(re) = root_entity {
if let Some(nested) =
field_types.get(re).and_then(|f| f.get(mid).copied())
{
if field == "status" {
status_sets.push((nested.to_string(), target.to_string()));
}
field_sets.insert(format!("{nested}.{field}"));
}
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_rule_effects(
item, binding_types, status_by_entity, field_types, status_sets, field_sets,
);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_rule_effects(
&branch.body, binding_types, status_by_entity, field_types, status_sets,
field_sets,
);
}
if let Some(body) = else_body {
collect_rule_effects(
body, binding_types, status_by_entity, field_types, status_sets, field_sets,
);
}
}
_ => {}
}
}
struct UniquenessPattern<'a> {
prohibited_status: &'a str,
key_field: &'a str,
}
fn extract_uniqueness_invariant<'a>(expr: &'a Expr) -> Option<UniquenessPattern<'a>> {
let Expr::For { body, .. } = expr else {
return None;
};
let Expr::For { body: inner_body, .. } = body.as_ref() else {
return None;
};
let Expr::LogicalOp {
op: LogicalOp::Implies,
left: premise,
right: conclusion,
..
} = inner_body.as_ref()
else {
return None;
};
let Expr::Not { operand, .. } = conclusion.as_ref() else {
return None;
};
let prohibited = extract_prohibited_status(operand)?;
let key_field = extract_key_field(premise)?;
Some(UniquenessPattern {
prohibited_status: prohibited,
key_field,
})
}
fn extract_prohibited_status(expr: &Expr) -> Option<&str> {
let Expr::LogicalOp {
op: LogicalOp::And,
left,
right,
..
} = expr
else {
return None;
};
let l_status = extract_status_value(left)?;
let r_status = extract_status_value(right)?;
if l_status == r_status {
Some(l_status)
} else {
None
}
}
fn extract_status_value(expr: &Expr) -> Option<&str> {
if let Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} = expr
{
if let Some((_, "status")) = expr_as_member_access(left) {
return expr_as_ident(right);
}
}
None
}
fn extract_key_field(expr: &Expr) -> Option<&str> {
let Expr::LogicalOp {
op: LogicalOp::And,
left: _,
right,
..
} = expr
else {
return None;
};
if let Expr::Comparison {
left,
op: ComparisonOp::Eq,
right: _,
..
} = right.as_ref()
{
if let Some((_, field)) = expr_as_member_access(left) {
return Some(field);
}
}
None
}
#[derive(PartialEq)]
enum ConflictTriggerKind<'a> {
Call(&'a str),
Temporal,
Unknown,
}
fn classify_trigger(expr: &Expr) -> ConflictTriggerKind<'_> {
match expr {
Expr::Call { function, .. } => {
if let Expr::Ident(id) = function.as_ref() {
return ConflictTriggerKind::Call(&id.name);
}
ConflictTriggerKind::Unknown
}
Expr::Binding { value, .. } => classify_trigger(value),
Expr::Comparison { .. }
| Expr::Becomes { .. }
| Expr::TransitionsTo { .. } => ConflictTriggerKind::Temporal,
_ => ConflictTriggerKind::Unknown,
}
}
fn collect_requires_statuses_for_conflict(
expr: &Expr,
binding_types: &HashMap<&str, &str>,
status_by_entity: &HashMap<&str, HashSet<&str>>,
out: &mut HashMap<String, HashSet<String>>,
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let (Some((binding, "status")), Some(target)) =
(expr_as_member_access(left), expr_as_ident(right))
{
let entity = resolve_binding_entity(
binding,
Some(target),
binding_types,
&status_by_entity
.iter()
.map(|(k, v)| (*k, (Vec::new(), v.clone())))
.collect(),
);
if let Some(e) = entity {
out.entry(e.to_string()).or_default().insert(target.to_string());
}
}
}
Expr::LogicalOp { left, right, .. } => {
collect_requires_statuses_for_conflict(left, binding_types, status_by_entity, out);
collect_requires_statuses_for_conflict(right, binding_types, status_by_entity, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_requires_statuses_for_conflict(item, binding_types, status_by_entity, out);
}
}
_ => {}
}
}
fn collect_ensures_statuses_for_conflict(
expr: &Expr,
binding_types: &HashMap<&str, &str>,
status_by_entity: &HashMap<&str, HashSet<&str>>,
out: &mut HashMap<String, String>,
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let (Some((binding, "status")), Some(target)) =
(expr_as_member_access(left), expr_as_ident(right))
{
let entity = resolve_binding_entity(
binding,
Some(target),
binding_types,
&status_by_entity
.iter()
.map(|(k, v)| (*k, (Vec::new(), v.clone())))
.collect(),
);
if let Some(e) = entity {
out.insert(e.to_string(), target.to_string());
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_ensures_statuses_for_conflict(item, binding_types, status_by_entity, out);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_ensures_statuses_for_conflict(
&branch.body, binding_types, status_by_entity, out,
);
}
if let Some(body) = else_body {
collect_ensures_statuses_for_conflict(body, binding_types, status_by_entity, out);
}
}
_ => {}
}
}
fn status_values_for_binding<'a>(
status_values: &'a HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
) -> HashMap<&'a str, (Vec<&'a Ident>, HashSet<&'a str>)> {
status_values
.iter()
.map(|(k, (set, idents))| (*k, (idents.clone(), set.clone())))
.collect()
}
fn resolve_binding_entity_from_status<'a>(
binding: &str,
target: Option<&str>,
binding_types: &HashMap<&'a str, &'a str>,
status_values: &HashMap<&'a str, (HashSet<&'a str>, Vec<&Ident>)>,
) -> Option<&'a str> {
binding_types
.get(binding)
.copied()
.or_else(|| {
status_values
.keys()
.find(|name| name.eq_ignore_ascii_case(binding))
.copied()
})
.or_else(|| {
let target = target?;
let mut candidates = status_values
.iter()
.filter(|(_, (values, _))| values.contains(target));
let first = candidates.next()?;
if candidates.next().is_none() {
Some(first.0)
} else {
None
}
})
}
fn collect_requires_conditions<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
cb: &mut impl FnMut(&'a str, &'a str, &'a str),
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, field)) = expr_as_member_access(left) {
cb(binding, field, target);
} else if let Some((root, _mid, field)) =
expr_as_nested_member_access(left)
{
if field == "status" {
cb(root, "status", target);
}
}
}
if let Expr::BoolLiteral { value: true, .. } = right.as_ref() {
if let Some((binding, field)) = expr_as_member_access(left) {
cb(binding, field, "true");
}
}
}
Expr::Comparison {
op: ComparisonOp::GtEq,
..
} => {
}
Expr::LogicalOp { left, right, .. } => {
collect_requires_conditions(left, binding_types, status_values, cb);
collect_requires_conditions(right, binding_types, status_values, cb);
}
Expr::Block { items, .. } => {
for item in items {
collect_requires_conditions(item, binding_types, status_values, cb);
}
}
_ => {}
}
}
fn collect_field_assignments<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
field_types: &HashMap<&str, HashMap<&str, &str>>,
cb: &mut impl FnMut(&str, &str, &str),
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some((binding, field)) = expr_as_member_access(left) {
let entity = resolve_binding_entity_from_status(
binding, None, binding_types, status_values,
);
if let Some(entity) = entity {
let val = expr_as_ident(right).unwrap_or("_variable_");
cb(entity, field, val);
}
}
if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
let root_entity = resolve_binding_entity_from_status(
root, None, binding_types, status_values,
);
if let Some(root_entity) = root_entity {
if let Some(nested) =
field_types.get(root_entity).and_then(|f| f.get(mid).copied())
{
let val = expr_as_ident(right).unwrap_or("_variable_");
cb(nested, field, val);
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_field_assignments(item, binding_types, status_values, field_types, cb);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_field_assignments(
&branch.body, binding_types, status_values, field_types, cb,
);
}
if let Some(body) = else_body {
collect_field_assignments(body, binding_types, status_values, field_types, cb);
}
}
_ => {}
}
}
fn collect_ensures_status<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
field_types: &HashMap<&str, HashMap<&str, &str>>,
cb: &mut impl FnMut(&'a str, &'a str),
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, "status")) = expr_as_member_access(left) {
cb(binding, target);
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_ensures_status(item, binding_types, status_values, field_types, cb);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_ensures_status(
&branch.body, binding_types, status_values, field_types, cb,
);
}
if let Some(body) = else_body {
collect_ensures_status(body, binding_types, status_values, field_types, cb);
}
}
_ => {}
}
}
fn collect_created_field_assignments<'a>(
expr: &'a Expr,
status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
assigned: &mut HashSet<String>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
if field.name == "created" {
if let Expr::Ident(entity_id) = object.as_ref() {
let entity = entity_id.name.as_str();
if status_values.contains_key(entity) {
for arg in args {
if let CallArg::Named(named) = arg {
assigned.insert(format!(
"{entity}.{}", named.name.name
));
if named.name.name == "status" {
if let Expr::Ident(val) = &named.value {
assigned.insert(format!(
"{entity}.status.{}", val.name
));
}
}
}
}
}
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_created_field_assignments(item, status_values, assigned);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_created_field_assignments(&branch.body, status_values, assigned);
}
if let Some(body) = else_body {
collect_created_field_assignments(body, status_values, assigned);
}
}
_ => {}
}
}
fn collect_rule_binding_types<'a, V>(
rule: &'a BlockDecl,
status_by_entity: &HashMap<&str, V>,
) -> HashMap<&'a str, &'a str> {
let mut types = HashMap::new();
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
collect_binding_types_from_expr(value, status_by_entity, &mut types);
}
types
}
fn collect_binding_types_from_expr<'a, V>(
expr: &'a Expr,
status_by_entity: &HashMap<&str, V>,
out: &mut HashMap<&'a str, &'a str>,
) {
match expr {
Expr::Binding { name, value, .. } => {
if let Some(entity_name) = extract_entity_from_trigger(value) {
if status_by_entity.contains_key(entity_name) {
out.insert(&name.name, entity_name);
}
}
}
Expr::Call { function, args, .. } => {
if let Expr::Ident(fn_name) = function.as_ref() {
for arg in args {
if let CallArg::Positional(Expr::Ident(binding)) = arg {
if status_by_entity.contains_key(fn_name.name.as_str()) {
out.insert(&binding.name, &fn_name.name);
}
}
}
}
}
Expr::LogicalOp { left, right, .. } => {
collect_binding_types_from_expr(left, status_by_entity, out);
collect_binding_types_from_expr(right, status_by_entity, out);
}
_ => {}
}
}
fn collect_command_param_types<'a, V>(
expr: &'a Expr,
status_by_entity: &HashMap<&str, V>,
out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::Ident(fn_name) = function.as_ref() {
let params: Vec<Option<&str>> = args
.iter()
.map(|arg| match arg {
CallArg::Named(named) => match unwrap_type_refinement(&named.value) {
Expr::Ident(val)
if status_by_entity.contains_key(val.name.as_str()) =>
{
Some(val.name.as_str())
}
_ => None,
},
CallArg::Positional(_) => None,
})
.collect();
if params.iter().any(Option::is_some) {
out.insert(&fn_name.name, params);
}
}
}
Expr::WhenGuard { action, .. } => {
collect_command_param_types(action, status_by_entity, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_command_param_types(item, status_by_entity, out);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
collect_command_param_types(&branch.body, status_by_entity, out);
}
if let Some(body) = else_body {
collect_command_param_types(body, status_by_entity, out);
}
}
_ => {}
}
}
fn augment_binding_types_from_commands<'a>(
rule: &'a BlockDecl,
command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
out: &mut HashMap<&'a str, &'a str>,
) {
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
augment_binding_types_from_call(value, command_param_types, out);
}
}
fn augment_binding_types_from_call<'a>(
expr: &'a Expr,
command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
out: &mut HashMap<&'a str, &'a str>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::Ident(fn_name) = function.as_ref() {
if let Some(params) = command_param_types.get(fn_name.name.as_str()) {
for (arg, param_type) in args.iter().zip(params) {
if let (CallArg::Positional(Expr::Ident(binding)), Some(entity)) =
(arg, param_type)
{
out.entry(&binding.name).or_insert(entity);
}
}
}
}
}
Expr::LogicalOp { left, right, .. } => {
augment_binding_types_from_call(left, command_param_types, out);
augment_binding_types_from_call(right, command_param_types, out);
}
_ => {}
}
}
fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
match expr {
Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
extract_entity_from_member(subject)
}
Expr::MemberAccess { object, .. } => expr_as_ident(object),
_ => None,
}
}
fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
match expr {
Expr::MemberAccess { object, .. } => expr_as_ident(object),
_ => None,
}
}
fn visit_status_assignments<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
cb: &mut impl FnMut(&'a str, &'a str, &'a str),
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, "status")) = expr_as_member_access(left) {
let entity = resolve_binding_entity(
binding,
Some(target),
binding_types,
status_by_entity,
);
if let Some(entity) = entity {
cb(binding, target, entity);
}
}
else if let Some((root, field, "status")) =
expr_as_nested_member_access(left)
{
let root_entity = resolve_binding_entity(
root, None, binding_types, status_by_entity,
);
if let Some(root_entity) = root_entity {
if let Some(nested_entity) = field_entity_types
.get(root_entity)
.and_then(|fields| fields.get(field).copied())
{
cb("_nested_", target, nested_entity);
}
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
visit_status_assignments(
item,
binding_types,
status_by_entity,
field_entity_types,
cb,
);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
visit_status_assignments(
&branch.body,
binding_types,
status_by_entity,
field_entity_types,
cb,
);
}
if let Some(body) = else_body {
visit_status_assignments(
body,
binding_types,
status_by_entity,
field_entity_types,
cb,
);
}
}
_ => {}
}
}
fn visit_created_calls<'a>(
expr: &'a Expr,
status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
has_transitions: &HashSet<&'a str>,
on_status: &mut impl FnMut(&'a str, &'a str),
issues: &mut Vec<Diagnostic>,
) {
match expr {
Expr::Call {
function, args, span, ..
} => {
if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
if field.name == "created" {
if let Expr::Ident(entity_ident) = object.as_ref() {
let entity_name = entity_ident.name.as_str();
if let Some((_, values)) = status_by_entity.get(entity_name) {
let status_arg = args.iter().find_map(|arg| {
if let CallArg::Named(named) = arg {
if named.name.name == "status" {
return Some(named);
}
}
None
});
match status_arg {
Some(named) => {
if let Expr::Ident(status_ident) = &named.value {
let status = status_ident.name.as_str();
if values.contains(status) {
on_status(entity_name, status);
} else {
issues.push(
Diagnostic::error(
named.value.span(),
format!(
".created() on entity '{entity_name}' sets status to '{status}', which is not a declared status value.",
),
)
.with_code("allium.created.invalidStatus"),
);
}
}
}
None => {
if has_transitions.contains(entity_name) {
issues.push(
Diagnostic::warning(
*span,
format!(
".created() on entity '{entity_name}' omits the status field, but the entity has a transition graph. The initial state is unspecified.",
),
)
.with_code("allium.created.missingStatus"),
);
}
}
}
}
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
visit_created_calls(item, status_by_entity, has_transitions, on_status, issues);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for branch in branches {
visit_created_calls(
&branch.body,
status_by_entity,
has_transitions,
on_status,
issues,
);
}
if let Some(body) = else_body {
visit_created_calls(body, status_by_entity, has_transitions, on_status, issues);
}
}
_ => {}
}
}
fn visit_status_comparisons<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
cb: &mut impl FnMut(&'a str, &'a str),
) {
match expr {
Expr::Comparison {
left,
op: ComparisonOp::Eq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, "status")) = expr_as_member_access(left) {
let known = resolve_binding_entity(
binding,
Some(target),
binding_types,
status_by_entity,
)
.is_some();
if known {
cb(binding, target);
}
}
}
}
Expr::Comparison {
left,
op: ComparisonOp::NotEq,
right,
..
} => {
if let Some(target) = expr_as_ident(right) {
if let Some((binding, "status")) = expr_as_member_access(left) {
if let Some(entity) = resolve_binding_entity(
binding,
Some(target),
binding_types,
status_by_entity,
) {
if let Some((_, values)) = status_by_entity.get(entity) {
if values.contains(target) {
for value in values.iter().filter(|v| **v != target) {
cb(binding, value);
}
}
}
}
}
}
}
Expr::LogicalOp { left, right, .. } => {
visit_status_comparisons(left, binding_types, status_by_entity, field_entity_types, cb);
visit_status_comparisons(right, binding_types, status_by_entity, field_entity_types, cb);
}
Expr::Block { items, .. } => {
for item in items {
visit_status_comparisons(item, binding_types, status_by_entity, field_entity_types, cb);
}
}
_ => {}
}
}
fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
match expr {
Expr::MemberAccess { object, field, .. } => {
expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
}
_ => None,
}
}
fn expr_as_nested_member_access(expr: &Expr) -> Option<(&str, &str, &str)> {
if let Expr::MemberAccess {
object, field: last, ..
} = expr
{
if let Expr::MemberAccess {
object: root_obj,
field: mid,
..
} = object.as_ref()
{
if let Expr::Ident(root) = root_obj.as_ref() {
return Some((&root.name, &mid.name, &last.name));
}
}
}
None
}
fn resolve_binding_entity<'a>(
binding: &str,
target: Option<&str>,
binding_types: &HashMap<&'a str, &'a str>,
status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
) -> Option<&'a str> {
binding_types
.get(binding)
.copied()
.or_else(|| {
status_by_entity
.keys()
.find(|name| name.eq_ignore_ascii_case(binding))
.copied()
})
.or_else(|| {
let target = target?;
let mut candidates = status_by_entity
.iter()
.filter(|(_, (_, values))| values.contains(target));
let first = candidates.next()?;
if candidates.next().is_none() {
Some(first.0)
} else {
None
}
})
}
fn extract_field_entity_type(expr: &Expr) -> Option<&str> {
match expr {
Expr::Ident(id) if starts_uppercase(&id.name) => Some(&id.name),
Expr::JoinLookup { entity, .. } => {
if let Expr::Ident(id) = entity.as_ref() {
if starts_uppercase(&id.name) {
return Some(&id.name);
}
}
None
}
_ => None,
}
}
fn is_likely_terminal(status: &str) -> bool {
matches!(
status,
"completed"
| "cancelled"
| "canceled"
| "expired"
| "closed"
| "deleted"
| "archived"
| "failed"
| "rejected"
| "done"
)
}
impl Ctx<'_> {
fn check_external_entity_source_hints(&mut self) {
if self.has_use_imports() {
return;
}
let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
for entity in self.blocks(BlockKind::ExternalEntity) {
let name = match &entity.name {
Some(n) => n,
None => continue,
};
let referenced_in_rules = rule_blocks
.iter()
.any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
let msg = format!(
"External entity '{}' has no obvious governing specification import in this module.",
name.name
);
if referenced_in_rules {
self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
} else {
self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
}
}
}
}
impl Ctx<'_> {
fn check_unresolved_use_paths(&mut self) {
let Some(resolved) = self.resolved_use_paths else {
return;
};
for d in &self.module.declarations {
let Decl::Use(u) = d else { continue };
let path_text = u.path.text();
if !resolved.contains(&path_text) {
self.push(
Diagnostic::warning(
u.path.span,
format!(
"Use path \"{path_text}\" does not resolve to a file in the current check set.",
),
)
.with_code("allium.use.unresolvedPath"),
);
}
}
}
}
impl Ctx<'_> {
fn check_ambiguous_imported_names(&mut self) {
let Some(ambiguous) = self.ambiguous_imports else {
return;
};
if ambiguous.names.is_empty() {
return;
}
let mut local = self.declared_type_names();
for b in self.blocks(BlockKind::Contract) {
if let Some(n) = &b.name {
local.insert(n.name.as_str());
}
}
let mut flagged: HashSet<&str> = HashSet::new();
let mut findings = Vec::new();
for id in collect_referenced_ident_nodes(self.module) {
if id.qualified || local.contains(id.name) || flagged.contains(id.name) {
continue;
}
let Some(aliases) = ambiguous.names.get(id.name) else {
continue;
};
flagged.insert(id.name);
findings.push(
Diagnostic::warning(
id.span,
format!(
"Unqualified reference '{}' is ambiguous: it is declared in imported modules {}. Use a qualified name (e.g. '{}/{}').",
id.name,
format_alias_list(aliases),
aliases[0],
id.name,
),
)
.with_code("allium.use.ambiguousReference"),
);
}
self.diagnostics.extend(findings);
}
}
fn format_alias_list(aliases: &[String]) -> String {
let quoted: Vec<String> = aliases.iter().map(|a| format!("'{a}'")).collect();
match quoted.split_last() {
Some((last, rest)) if !rest.is_empty() => {
format!("{} and {last}", rest.join(", "))
}
_ => quoted.join(", "),
}
}
impl Ctx<'_> {
fn check_type_references(&mut self) {
let known = self.declared_type_names();
for d in &self.module.declarations {
let block = match d {
Decl::Block(b)
if matches!(
b.kind,
BlockKind::Entity
| BlockKind::ExternalEntity
| BlockKind::Value
) =>
{
b
}
Decl::Variant(v) => {
for item in &v.items {
self.check_type_ref_in_item(item, &known);
}
continue;
}
_ => continue,
};
for item in &block.items {
self.check_type_ref_in_item(item, &known);
}
}
let rules: Vec<_> = self.blocks(BlockKind::Rule).collect();
for rule in rules {
let mut refs = Vec::new();
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword == "when" || keyword == "ensures" || keyword == "requires" {
refs.push(value);
}
});
for value in refs {
self.check_type_refs_in_rule_expr(value, &known);
}
}
}
fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
match &item.kind {
BlockItemKind::Assignment { value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
self.check_type_refs_in_value(value, known);
}
_ => {}
}
}
fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
match expr {
Expr::Ident(id) if starts_uppercase(&id.name) => {
if !known.contains(id.name.as_str()) {
self.push(
Diagnostic::error(
id.span,
format!(
"Type reference '{}' is not declared locally or imported.",
id.name
),
)
.with_code("allium.type.undefinedReference"),
);
}
}
Expr::GenericType { name, args, .. } => {
self.check_type_refs_in_value(name, known);
for arg in args {
self.check_type_refs_in_value(arg, known);
}
}
Expr::Pipe { left, right, .. } => {
self.check_type_refs_in_value(left, known);
self.check_type_refs_in_value(right, known);
}
Expr::TypeOptional { inner, .. } => {
self.check_type_refs_in_value(inner, known);
}
_ => {}
}
}
fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
match expr {
Expr::Binding { value, .. } => {
self.check_type_refs_in_rule_expr(value, known);
}
Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
if let Expr::MemberAccess { object, .. } = subject.as_ref() {
if let Expr::Ident(id) = object.as_ref() {
if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
self.push(
Diagnostic::error(
id.span,
format!(
"Type reference '{}' is not declared locally or imported.",
id.name
),
)
.with_code("allium.rule.undefinedTypeReference"),
);
}
}
}
}
Expr::Call { function, .. } => {
if let Expr::MemberAccess { object, .. } = function.as_ref() {
if let Expr::Ident(id) = object.as_ref() {
if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
self.push(
Diagnostic::error(
id.span,
format!(
"Type reference '{}' is not declared locally or imported.",
id.name
),
)
.with_code("allium.rule.undefinedTypeReference"),
);
}
}
}
}
Expr::MemberAccess { object, .. } => {
if let Expr::Ident(id) = object.as_ref() {
if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
self.push(
Diagnostic::error(
id.span,
format!(
"Type reference '{}' is not declared locally or imported.",
id.name
),
)
.with_code("allium.rule.undefinedTypeReference"),
);
}
}
}
Expr::Block { items, .. } => {
for item in items {
self.check_type_refs_in_rule_expr(item, known);
}
}
Expr::LogicalOp { left, right, .. } => {
self.check_type_refs_in_rule_expr(left, known);
self.check_type_refs_in_rule_expr(right, known);
}
_ => {}
}
}
}
impl Ctx<'_> {
fn check_unreachable_triggers(&mut self) {
let mut provided: HashSet<&str> = HashSet::new();
if let Some(rev) = self.reverse_contributions {
for t in &rev.provided_triggers {
provided.insert(t.as_str());
}
}
for surface in self.blocks(BlockKind::Surface) {
for item in &surface.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "provides" {
continue;
}
collect_call_names(value, &mut provided);
}
}
let mut emitted: HashSet<&str> = HashSet::new();
for rule in self.blocks(BlockKind::Rule) {
for item in &rule.items {
collect_emitted_trigger_from_item(&item.kind, &mut emitted);
}
}
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => &n.name,
None => continue,
};
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
for tref in extract_trigger_refs(value) {
if tref.qualifier.is_none()
&& !provided.contains(tref.name)
&& !emitted.contains(tref.name)
{
if let Some(aliases) = self
.ambiguous_imports
.and_then(|a| a.triggers.get(tref.name))
{
let message = format!(
"Rule '{rule_name}' listens for trigger '{}', which is provided or emitted by imported modules {}. Use a qualified name (e.g. '{}/{}').",
tref.name,
format_alias_list(aliases),
aliases[0],
tref.name,
);
self.push(
Diagnostic::warning(tref.span, message)
.with_code("allium.use.ambiguousReference"),
);
}
}
if self.trigger_reachability(&tref, &provided, &emitted) != Some(false) {
continue;
}
let message = match tref.qualifier {
None => format!(
"Rule '{rule_name}' listens for trigger '{}' but no local surface provides or rule emits it.",
tref.name,
),
Some(q) => format!(
"Rule '{rule_name}' listens for trigger '{q}/{}' but imported module '{q}' does not provide or emit it.",
tref.name,
),
};
self.push(
Diagnostic::info(tref.span, message)
.with_code("allium.rule.unreachableTrigger"),
);
}
}
}
}
fn trigger_reachability(
&self,
tref: &TriggerRef<'_>,
provided: &HashSet<&str>,
emitted: &HashSet<&str>,
) -> Option<bool> {
match tref.qualifier {
None => {
if provided.contains(tref.name) || emitted.contains(tref.name) {
return Some(true);
}
if let Some(imports) = self.imported_triggers {
if imports.values().any(|set| set.contains(tref.name)) {
return Some(true);
}
}
Some(false)
}
Some(q) => self
.imported_triggers?
.get(q)
.map(|set| set.contains(tref.name)),
}
}
}
fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
match kind {
BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
collect_leading_ensures_call(value, out);
}
BlockItemKind::ForBlock { items, .. } => {
for item in items {
collect_emitted_trigger_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock { branches, else_items, .. } => {
for b in branches {
for item in &b.items {
collect_emitted_trigger_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_emitted_trigger_from_item(&item.kind, out);
}
}
}
_ => {}
}
}
fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
match expr {
Expr::Call { function, .. } => {
if let Expr::Ident(id) = function.as_ref() {
if starts_uppercase(&id.name) {
out.insert(&id.name);
}
}
}
Expr::Block { items, .. } => {
if let Some(first) = items.first() {
collect_leading_ensures_call(first, out);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for b in branches {
collect_leading_ensures_call(&b.body, out);
}
if let Some(body) = else_body {
collect_leading_ensures_call(body, out);
}
}
Expr::For { body, .. } => {
collect_leading_ensures_call(body, out);
}
_ => {}
}
}
fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
match expr {
Expr::Call { function, .. } => {
if let Expr::Ident(id) = function.as_ref() {
if starts_uppercase(&id.name) {
out.insert(&id.name);
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_call_names(item, out);
}
}
Expr::WhenGuard { action, .. } => {
collect_call_names(action, out);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_call_names(&b.body, out);
}
if let Some(body) = else_body {
collect_call_names(body, out);
}
}
Expr::For { body, .. } => {
collect_call_names(body, out);
}
_ => {}
}
}
struct TriggerRef<'a> {
qualifier: Option<&'a str>,
name: &'a str,
span: Span,
}
impl TriggerRef<'_> {
fn display(&self) -> String {
match self.qualifier {
Some(q) => format!("{q}/{}", self.name),
None => self.name.to_string(),
}
}
}
fn extract_trigger_refs(expr: &Expr) -> Vec<TriggerRef<'_>> {
match expr {
Expr::Call { function, .. } => match function.as_ref() {
Expr::Ident(id) if starts_uppercase(&id.name) => vec![TriggerRef {
qualifier: None,
name: &id.name,
span: id.span,
}],
Expr::QualifiedName(q) if starts_uppercase(&q.name) => vec![TriggerRef {
qualifier: q.qualifier.as_deref(),
name: &q.name,
span: q.span,
}],
_ => vec![],
},
Expr::Binding { .. } => {
vec![]
}
Expr::LogicalOp { left, right, .. } => {
let mut out = extract_trigger_refs(left);
out.extend(extract_trigger_refs(right));
out
}
_ => vec![],
}
}
impl Ctx<'_> {
fn check_unused_fields(&mut self) {
let mut accessed = self.collect_all_accessed_field_names();
if let Some(rev) = self.reverse_contributions {
accessed.extend(rev.referenced_fields.iter().map(String::as_str));
}
for d in &self.module.declarations {
let block = match d {
Decl::Block(b)
if matches!(
b.kind,
BlockKind::Entity | BlockKind::ExternalEntity
) =>
{
b
}
Decl::Variant(v) => {
let entity_name = &v.name.name;
for item in &v.items {
if let BlockItemKind::Assignment { name, .. }
| BlockItemKind::FieldWithWhen { name, .. } = &item.kind
{
if !accessed.contains(name.name.as_str()) {
self.push(
Diagnostic::info(
name.span,
format!(
"Field '{entity_name}.{}' is declared but not referenced elsewhere.",
name.name
),
)
.with_code("allium.field.unused"),
);
}
}
}
continue;
}
_ => continue,
};
let entity_name = match &block.name {
Some(n) => &n.name,
None => continue,
};
for item in &block.items {
if let BlockItemKind::Assignment { name, .. }
| BlockItemKind::FieldWithWhen { name, .. } = &item.kind
{
if !accessed.contains(name.name.as_str()) {
self.push(
Diagnostic::info(
name.span,
format!(
"Field '{entity_name}.{}' is declared but not referenced elsewhere.",
name.name
),
)
.with_code("allium.field.unused"),
);
}
}
}
}
}
}
fn collect_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => collect_idents_from_expr(value, out),
BlockItemKind::ForBlock { collection, filter, items, .. } => {
collect_idents_from_expr(collection, out);
if let Some(f) = filter {
collect_idents_from_expr(f, out);
}
for item in items {
collect_idents_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
collect_idents_from_expr(&b.condition, out);
for item in &b.items {
collect_idents_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_idents_from_item(&item.kind, out);
}
}
}
_ => {}
}
}
fn collect_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
match expr {
Expr::Ident(id) => {
out.insert(&id.name);
}
Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
collect_idents_from_expr(object, out);
}
Expr::Call { function, args, .. } => {
collect_idents_from_expr(function, out);
for a in args {
match a {
CallArg::Positional(e) => collect_idents_from_expr(e, out),
CallArg::Named(n) => collect_idents_from_expr(&n.value, out),
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
collect_idents_from_expr(left, out);
collect_idents_from_expr(right, out);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => {
collect_idents_from_expr(operand, out);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
collect_idents_from_expr(element, out);
collect_idents_from_expr(collection, out);
}
Expr::Where { source, condition, .. }
| Expr::With { source, predicate: condition, .. } => {
collect_idents_from_expr(source, out);
collect_idents_from_expr(condition, out);
}
Expr::WhenGuard { action, condition, .. } => {
collect_idents_from_expr(action, out);
collect_idents_from_expr(condition, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_idents_from_expr(item, out);
}
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
collect_idents_from_expr(value, out)
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_idents_from_expr(&b.condition, out);
collect_idents_from_expr(&b.body, out);
}
if let Some(body) = else_body {
collect_idents_from_expr(body, out);
}
}
Expr::For { collection, filter, body, .. } => {
collect_idents_from_expr(collection, out);
if let Some(f) = filter {
collect_idents_from_expr(f, out);
}
collect_idents_from_expr(body, out);
}
Expr::Lambda { body, .. } => collect_idents_from_expr(body, out),
Expr::TransitionsTo { subject, new_state, .. }
| Expr::Becomes { subject, new_state, .. } => {
collect_idents_from_expr(subject, out);
collect_idents_from_expr(new_state, out);
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
collect_idents_from_expr(e, out);
}
}
Expr::ObjectLiteral { fields, .. } => {
for f in fields {
collect_idents_from_expr(&f.value, out);
}
}
Expr::GenericType { name, args, .. } => {
collect_idents_from_expr(name, out);
for a in args {
collect_idents_from_expr(a, out);
}
}
Expr::ProjectionMap { source, .. } => collect_idents_from_expr(source, out),
Expr::JoinLookup { entity, fields, .. } => {
collect_idents_from_expr(entity, out);
for f in fields {
if let Some(v) = &f.value {
collect_idents_from_expr(v, out);
}
}
}
_ => {}
}
}
fn collect_qualified_field_refs<'a>(expr: &'a Expr, alias: &str, out: &mut HashSet<&'a str>) {
match expr {
Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
if let Expr::QualifiedName(q) = object.as_ref() {
if q.qualifier.as_deref() == Some(alias) {
out.insert(&field.name);
}
}
collect_qualified_field_refs(object, alias, out);
}
Expr::Call { function, args, .. } => {
collect_qualified_field_refs(function, alias, out);
for a in args {
match a {
CallArg::Positional(e) => collect_qualified_field_refs(e, alias, out),
CallArg::Named(n) => collect_qualified_field_refs(&n.value, alias, out),
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. }
| Expr::In { element: left, collection: right, .. }
| Expr::NotIn { element: left, collection: right, .. } => {
collect_qualified_field_refs(left, alias, out);
collect_qualified_field_refs(right, alias, out);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => {
collect_qualified_field_refs(operand, alias, out);
}
Expr::Where { source, condition, .. }
| Expr::With { source, predicate: condition, .. } => {
collect_qualified_field_refs(source, alias, out);
collect_qualified_field_refs(condition, alias, out);
}
Expr::WhenGuard { action, condition, .. } => {
collect_qualified_field_refs(action, alias, out);
collect_qualified_field_refs(condition, alias, out);
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } | Expr::Lambda { body: value, .. } => {
collect_qualified_field_refs(value, alias, out);
}
Expr::TransitionsTo { subject, new_state, .. }
| Expr::Becomes { subject, new_state, .. } => {
collect_qualified_field_refs(subject, alias, out);
collect_qualified_field_refs(new_state, alias, out);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_qualified_field_refs(&b.condition, alias, out);
collect_qualified_field_refs(&b.body, alias, out);
}
if let Some(body) = else_body {
collect_qualified_field_refs(body, alias, out);
}
}
Expr::For { collection, filter, body, .. } => {
collect_qualified_field_refs(collection, alias, out);
if let Some(f) = filter {
collect_qualified_field_refs(f, alias, out);
}
collect_qualified_field_refs(body, alias, out);
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
collect_qualified_field_refs(e, alias, out);
}
}
Expr::ObjectLiteral { fields, .. } => {
for f in fields {
collect_qualified_field_refs(&f.value, alias, out);
}
}
Expr::Block { items, .. } => {
for item in items {
collect_qualified_field_refs(item, alias, out);
}
}
_ => {}
}
}
fn collect_qualified_field_refs_from_item<'a>(
kind: &'a BlockItemKind,
alias: &str,
out: &mut HashSet<&'a str>,
) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
collect_qualified_field_refs(value, alias, out);
}
BlockItemKind::ForBlock { collection, filter, items, .. } => {
collect_qualified_field_refs(collection, alias, out);
if let Some(f) = filter {
collect_qualified_field_refs(f, alias, out);
}
for item in items {
collect_qualified_field_refs_from_item(&item.kind, alias, out);
}
}
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
collect_qualified_field_refs(&b.condition, alias, out);
for item in &b.items {
collect_qualified_field_refs_from_item(&item.kind, alias, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_qualified_field_refs_from_item(&item.kind, alias, out);
}
}
}
_ => {}
}
}
fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
collect_accessed_fields_from_expr(value, out);
}
BlockItemKind::ForBlock {
collection,
filter,
items,
..
} => {
collect_accessed_fields_from_expr(collection, out);
if let Some(f) = filter {
collect_accessed_fields_from_expr(f, out);
}
for item in items {
collect_accessed_fields_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock {
branches,
else_items,
} => {
for b in branches {
collect_accessed_fields_from_expr(&b.condition, out);
for item in &b.items {
collect_accessed_fields_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_accessed_fields_from_item(&item.kind, out);
}
}
}
_ => {}
}
}
fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
match expr {
Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
out.insert(&field.name);
collect_accessed_fields_from_expr(object, out);
}
Expr::Call { function, args, .. } => {
collect_accessed_fields_from_expr(function, out);
let is_created = matches!(
function.as_ref(),
Expr::MemberAccess { field, .. } if field.name == "created"
);
for a in args {
match a {
CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
CallArg::Named(n) => {
if is_created {
out.insert(&n.name.name);
}
collect_accessed_fields_from_expr(&n.value, out);
}
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
collect_accessed_fields_from_expr(left, out);
collect_accessed_fields_from_expr(right, out);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => {
collect_accessed_fields_from_expr(operand, out);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
collect_accessed_fields_from_expr(element, out);
collect_accessed_fields_from_expr(collection, out);
}
Expr::Where { source, condition, .. }
| Expr::With {
source,
predicate: condition,
..
} => {
collect_accessed_fields_from_expr(source, out);
collect_accessed_fields_from_expr(condition, out);
}
Expr::WhenGuard { action, condition, .. } => {
collect_accessed_fields_from_expr(action, out);
collect_accessed_fields_from_expr(condition, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_accessed_fields_from_expr(item, out);
}
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
collect_accessed_fields_from_expr(value, out);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_accessed_fields_from_expr(&b.condition, out);
collect_accessed_fields_from_expr(&b.body, out);
}
if let Some(body) = else_body {
collect_accessed_fields_from_expr(body, out);
}
}
Expr::For { collection, filter, body, .. } => {
collect_accessed_fields_from_expr(collection, out);
if let Some(f) = filter {
collect_accessed_fields_from_expr(f, out);
}
collect_accessed_fields_from_expr(body, out);
}
Expr::Lambda { body, .. } => {
collect_accessed_fields_from_expr(body, out);
}
Expr::JoinLookup { entity, fields, .. } => {
collect_accessed_fields_from_expr(entity, out);
for f in fields {
out.insert(&f.field.name);
if let Some(v) = &f.value {
collect_accessed_fields_from_expr(v, out);
}
}
}
Expr::TransitionsTo { subject, new_state, .. }
| Expr::Becomes { subject, new_state, .. } => {
collect_accessed_fields_from_expr(subject, out);
collect_accessed_fields_from_expr(new_state, out);
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
collect_accessed_fields_from_expr(e, out);
}
}
Expr::ObjectLiteral { fields, .. } => {
for f in fields {
collect_accessed_fields_from_expr(&f.value, out);
}
}
Expr::GenericType { name, args, .. } => {
collect_accessed_fields_from_expr(name, out);
for a in args {
collect_accessed_fields_from_expr(a, out);
}
}
Expr::ProjectionMap { source, .. } => {
collect_accessed_fields_from_expr(source, out);
}
_ => {}
}
}
impl Ctx<'_> {
fn check_unused_entities(&mut self) {
let mut all_idents = self.collect_all_referenced_idents();
for name in self.external_refs {
all_idents.insert(name.as_str());
}
for v in self.variants() {
let base = expr_as_ident(&v.base).or_else(|| {
if let Expr::JoinLookup { entity, .. } = &v.base {
expr_as_ident(entity)
} else {
None
}
});
if let Some(name) = base {
all_idents.insert(name);
}
}
let mut findings = Vec::new();
for d in &self.module.declarations {
let block = match d {
Decl::Block(b)
if matches!(
b.kind,
BlockKind::Entity | BlockKind::ExternalEntity
) =>
{
b
}
_ => continue,
};
let name = match &block.name {
Some(n) => n,
None => continue,
};
if !all_idents.contains(name.name.as_str()) {
findings.push(
Diagnostic::warning(
name.span,
format!(
"Entity '{}' is declared but not referenced elsewhere in this specification.",
name.name
),
)
.with_code("allium.entity.unused"),
);
}
}
self.diagnostics.extend(findings);
}
fn check_unused_definitions(&mut self) {
let mut all_idents = self.collect_all_referenced_idents();
for name in self.external_refs {
all_idents.insert(name.as_str());
}
let mut findings = Vec::new();
for d in &self.module.declarations {
match d {
Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
let name = match &b.name {
Some(n) => n,
None => continue,
};
if !all_idents.contains(name.name.as_str()) {
findings.push(
Diagnostic::warning(
name.span,
format!(
"Value '{}' is declared but not referenced elsewhere.",
name.name
),
)
.with_code("allium.definition.unused"),
);
}
}
_ => {}
}
}
self.diagnostics.extend(findings);
}
fn collect_all_referenced_idents(&self) -> HashSet<&str> {
collect_referenced_ident_nodes(self.module)
.into_iter()
.map(|id| id.name)
.collect()
}
}
fn collect_uppercase_idents_from_item<'a>(
kind: &'a BlockItemKind,
out: &mut Vec<ReferencedIdent<'a>>,
) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
collect_uppercase_idents_from_expr(value, out);
}
BlockItemKind::ForBlock {
collection,
filter,
items,
..
} => {
collect_uppercase_idents_from_expr(collection, out);
if let Some(f) = filter {
collect_uppercase_idents_from_expr(f, out);
}
for item in items {
collect_uppercase_idents_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock {
branches,
else_items,
} => {
for b in branches {
collect_uppercase_idents_from_expr(&b.condition, out);
for item in &b.items {
collect_uppercase_idents_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_uppercase_idents_from_item(&item.kind, out);
}
}
}
BlockItemKind::ContractsClause { entries } => {
for e in entries {
if e.qualifier.is_none() {
out.push(ReferencedIdent::unqualified(&e.name));
}
}
}
_ => {}
}
}
fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut Vec<ReferencedIdent<'a>>) {
match expr {
Expr::Ident(id) if starts_uppercase(&id.name) => {
out.push(ReferencedIdent::unqualified(id));
}
Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
collect_uppercase_idents_from_expr(object, out);
}
Expr::Call { function, args, .. } => {
collect_uppercase_idents_from_expr(function, out);
for a in args {
match a {
CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
}
}
}
Expr::JoinLookup { entity, fields, .. } => {
collect_uppercase_idents_from_expr(entity, out);
for f in fields {
if let Some(v) = &f.value {
collect_uppercase_idents_from_expr(v, out);
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
collect_uppercase_idents_from_expr(left, out);
collect_uppercase_idents_from_expr(right, out);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => {
collect_uppercase_idents_from_expr(operand, out);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
collect_uppercase_idents_from_expr(element, out);
collect_uppercase_idents_from_expr(collection, out);
}
Expr::Where { source, condition, .. }
| Expr::With {
source,
predicate: condition,
..
} => {
collect_uppercase_idents_from_expr(source, out);
collect_uppercase_idents_from_expr(condition, out);
}
Expr::WhenGuard { action, condition, .. } => {
collect_uppercase_idents_from_expr(action, out);
collect_uppercase_idents_from_expr(condition, out);
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
collect_uppercase_idents_from_expr(value, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_uppercase_idents_from_expr(item, out);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_uppercase_idents_from_expr(&b.condition, out);
collect_uppercase_idents_from_expr(&b.body, out);
}
if let Some(body) = else_body {
collect_uppercase_idents_from_expr(body, out);
}
}
Expr::For { collection, filter, body, .. } => {
collect_uppercase_idents_from_expr(collection, out);
if let Some(f) = filter {
collect_uppercase_idents_from_expr(f, out);
}
collect_uppercase_idents_from_expr(body, out);
}
Expr::Lambda { body, .. } => {
collect_uppercase_idents_from_expr(body, out);
}
Expr::TransitionsTo { subject, new_state, .. }
| Expr::Becomes { subject, new_state, .. } => {
collect_uppercase_idents_from_expr(subject, out);
collect_uppercase_idents_from_expr(new_state, out);
}
Expr::GenericType { name, args, .. } => {
collect_uppercase_idents_from_expr(name, out);
for a in args {
collect_uppercase_idents_from_expr(a, out);
}
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
collect_uppercase_idents_from_expr(e, out);
}
}
Expr::ObjectLiteral { fields, .. } => {
for f in fields {
collect_uppercase_idents_from_expr(&f.value, out);
}
}
Expr::ProjectionMap { source, .. } => {
collect_uppercase_idents_from_expr(source, out);
}
Expr::QualifiedName(q) => {
out.push(ReferencedIdent {
name: &q.name,
span: q.span,
qualified: q.qualifier.is_some(),
});
}
_ => {}
}
}
pub fn collect_qualified_references(module: &Module) -> Vec<(String, String)> {
collect_qref_nodes(module)
.into_iter()
.map(|r| (r.qualifier.to_string(), r.name.to_string()))
.collect()
}
fn collect_qref_nodes(module: &Module) -> Vec<QRef<'_>> {
let mut refs = Vec::new();
for d in &module.declarations {
match d {
Decl::Block(b) => {
for item in &b.items {
collect_qrefs_from_item(&item.kind, &mut refs);
}
}
Decl::Variant(v) => {
collect_qrefs_from_expr(&v.base, &mut refs);
for item in &v.items {
collect_qrefs_from_item(&item.kind, &mut refs);
}
}
Decl::Invariant(inv) => {
collect_qrefs_from_expr(&inv.body, &mut refs);
}
Decl::Default(def) => {
collect_qrefs_from_expr(&def.value, &mut refs);
}
Decl::Deferred(def) => {
collect_qrefs_from_expr(&def.path, &mut refs);
}
_ => {}
}
}
refs
}
pub fn collect_all_referenced_idents(module: &Module) -> HashSet<String> {
collect_referenced_ident_nodes(module)
.into_iter()
.map(|id| id.name.to_string())
.collect()
}
struct ReferencedIdent<'a> {
name: &'a str,
span: Span,
qualified: bool,
}
impl<'a> ReferencedIdent<'a> {
fn unqualified(id: &'a Ident) -> Self {
Self {
name: &id.name,
span: id.span,
qualified: false,
}
}
fn qualified(id: &'a Ident) -> Self {
Self {
name: &id.name,
span: id.span,
qualified: true,
}
}
}
fn collect_referenced_ident_nodes(module: &Module) -> Vec<ReferencedIdent<'_>> {
let mut idents: Vec<ReferencedIdent<'_>> = Vec::new();
for d in &module.declarations {
match d {
Decl::Block(b) => {
for item in &b.items {
collect_uppercase_idents_from_item(&item.kind, &mut idents);
}
}
Decl::Variant(v) => {
if let Expr::Ident(id) = &v.base {
idents.push(ReferencedIdent::unqualified(id));
}
for item in &v.items {
collect_uppercase_idents_from_item(&item.kind, &mut idents);
}
}
Decl::Invariant(inv) => {
collect_uppercase_idents_from_expr(&inv.body, &mut idents);
}
Decl::Default(def) => {
if let Some(tn) = &def.type_name {
if def.type_alias.is_some() {
idents.push(ReferencedIdent::qualified(tn));
} else {
idents.push(ReferencedIdent::unqualified(tn));
}
}
collect_uppercase_idents_from_expr(&def.value, &mut idents);
}
_ => {}
}
}
idents
}
pub fn collect_declared_names(module: &Module) -> HashSet<String> {
let mut names = HashSet::new();
for d in &module.declarations {
match d {
Decl::Block(b) => {
if matches!(
b.kind,
BlockKind::Entity
| BlockKind::ExternalEntity
| BlockKind::Value
| BlockKind::Enum
| BlockKind::Actor
| BlockKind::Contract
) {
if let Some(n) = &b.name {
names.insert(n.name.clone());
}
}
}
Decl::Variant(v) => {
names.insert(v.name.name.clone());
}
_ => {}
}
}
names
}
pub fn collect_trigger_outputs(module: &Module) -> HashSet<String> {
let mut names: HashSet<&str> = HashSet::new();
for d in &module.declarations {
let Decl::Block(b) = d else { continue };
match b.kind {
BlockKind::Surface => {
for item in &b.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "provides" {
collect_call_names(value, &mut names);
}
}
}
}
BlockKind::Rule => {
for item in &b.items {
collect_emitted_trigger_from_item(&item.kind, &mut names);
}
}
_ => {}
}
}
names.into_iter().map(str::to_string).collect()
}
pub fn collect_referenced_trigger_names(module: &Module) -> HashSet<String> {
let mut names = collect_trigger_outputs(module);
names.extend(collect_declared_names(module));
for d in &module.declarations {
let Decl::Block(b) = d else { continue };
if b.kind != BlockKind::Rule {
continue;
}
for item in &b.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "when" {
for tref in extract_trigger_refs(value) {
names.insert(tref.name.to_string());
}
}
}
}
}
names
}
pub fn collect_reverse_contributions<'a>(
importer: &'a Module,
alias: &str,
imported: &'a Module,
) -> ReverseContributions {
let mut out = ReverseContributions::default();
let imported_info = EntityInfo::from_module(imported);
let status_by_entity = imported_info.status_by_entity();
let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
for b in module_blocks(imported, BlockKind::Surface) {
for item in &b.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "provides" {
collect_command_param_types(value, &status_by_entity, &mut command_param_types);
}
}
}
}
collect_importer_command_param_types(
importer, alias, &status_by_entity, &mut command_param_types,
);
collect_emitted_event_param_types(imported, &status_by_entity, &mut command_param_types);
for b in module_blocks(importer, BlockKind::Surface) {
for item in &b.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "provides" {
collect_qualified_provides(value, alias, &mut out.provided_triggers);
}
}
}
}
for rule in module_blocks(importer, BlockKind::Rule) {
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword == "ensures" {
collect_qualified_created(value, alias, &status_by_entity, &mut out.assigned_statuses);
}
});
collect_witnessed_transition(
rule, alias, &command_param_types, &status_by_entity, &mut out,
);
}
let mut imported_field_names: HashSet<&str> = HashSet::new();
for entity in module_blocks(imported, BlockKind::Entity)
.chain(module_blocks(imported, BlockKind::ExternalEntity))
{
for item in &entity.items {
if let BlockItemKind::Assignment { name, .. }
| BlockItemKind::FieldWithWhen { name, .. } = &item.kind
{
imported_field_names.insert(&name.name);
}
}
}
if !imported_field_names.is_empty() {
let mut qualified_refs: HashSet<&str> = HashSet::new();
for block in &importer.declarations {
if let Decl::Block(b) = block {
for item in &b.items {
collect_qualified_field_refs_from_item(&item.kind, alias, &mut qualified_refs);
}
}
}
for f in qualified_refs {
if imported_field_names.contains(f) {
out.referenced_fields.insert(f.to_string());
}
}
}
out
}
fn module_blocks(module: &Module, kind: BlockKind) -> impl Iterator<Item = &BlockDecl> {
module.declarations.iter().filter_map(move |d| match d {
Decl::Block(b) if b.kind == kind => Some(b),
_ => None,
})
}
fn collect_qualified_provides(expr: &Expr, alias: &str, out: &mut HashSet<String>) {
match expr {
Expr::Call { function, .. } => {
if let Expr::QualifiedName(q) = function.as_ref() {
if q.qualifier.as_deref() == Some(alias) && starts_uppercase(&q.name) {
out.insert(q.name.clone());
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_qualified_provides(item, alias, out);
}
}
Expr::WhenGuard { action, .. } => collect_qualified_provides(action, alias, out),
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_qualified_provides(&b.body, alias, out);
}
if let Some(body) = else_body {
collect_qualified_provides(body, alias, out);
}
}
Expr::For { body, .. } => collect_qualified_provides(body, alias, out),
_ => {}
}
}
fn collect_importer_command_param_types<'a>(
importer: &'a Module,
alias: &str,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
) {
for surface in module_blocks(importer, BlockKind::Surface) {
let mut context_types: HashMap<&str, &str> = HashMap::new();
for item in &surface.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "context" || keyword == "facing" {
qualified_context_binding(value, alias, status_by_entity, &mut context_types);
}
}
}
for item in &surface.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "provides" {
collect_provides_param_types(
value, alias, &context_types, status_by_entity, out,
);
}
}
}
}
}
fn augment_binding_types_from_status<'a>(
rule: &'a BlockDecl,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
binding_types: &mut HashMap<&'a str, &'a str>,
) {
let status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
.iter()
.map(|(k, v)| (*k, (v.clone(), Vec::new())))
.collect();
let mut pairs: Vec<(&str, &str)> = Vec::new();
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword == "requires" || keyword == "ensures" {
collect_status_equations(value, &mut pairs);
}
});
for (binding, target) in pairs {
if binding_types.contains_key(binding) {
continue;
}
if let Some(entity) =
resolve_binding_entity_from_status(binding, Some(target), binding_types, &status_values)
{
binding_types.insert(binding, entity);
}
}
}
fn collect_status_equations<'a>(expr: &'a Expr, out: &mut Vec<(&'a str, &'a str)>) {
match expr {
Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
if let (Some((binding, "status")), Some(target)) =
(expr_as_member_access(left), expr_as_ident(right))
{
out.push((binding, target));
}
}
Expr::LogicalOp { left, right, .. } => {
collect_status_equations(left, out);
collect_status_equations(right, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_status_equations(item, out);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_status_equations(&b.body, out);
}
if let Some(body) = else_body {
collect_status_equations(body, out);
}
}
_ => {}
}
}
fn collect_emitted_event_param_types<'a>(
imported: &'a Module,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
) {
let mut surface_params: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
for surface in module_blocks(imported, BlockKind::Surface) {
for item in &surface.items {
if let BlockItemKind::Clause { keyword, value } = &item.kind {
if keyword == "provides" {
collect_command_param_types(value, status_by_entity, &mut surface_params);
}
}
}
}
for rule in module_blocks(imported, BlockKind::Rule) {
let mut binding_types = collect_rule_binding_types(rule, status_by_entity);
augment_binding_types_from_commands(rule, &surface_params, &mut binding_types);
augment_binding_types_from_status(rule, status_by_entity, &mut binding_types);
if binding_types.is_empty() {
continue;
}
for_each_rule_clause(&rule.items, &mut |keyword, value| {
if keyword == "ensures" {
collect_emission_param_types(value, &binding_types, out);
}
});
}
}
fn collect_emission_param_types<'a>(
expr: &'a Expr,
binding_types: &HashMap<&'a str, &'a str>,
out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::Ident(event) = function.as_ref() {
let params: Vec<Option<&str>> = args
.iter()
.map(|arg| {
let val = match arg {
CallArg::Named(n) => &n.value,
CallArg::Positional(e) => e,
};
match val {
Expr::Ident(v) => binding_types.get(v.name.as_str()).copied(),
_ => None,
}
})
.collect();
if params.iter().any(Option::is_some) {
out.entry(&event.name).or_insert(params);
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_emission_param_types(item, binding_types, out);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_emission_param_types(&b.body, binding_types, out);
}
if let Some(body) = else_body {
collect_emission_param_types(body, binding_types, out);
}
}
_ => {}
}
}
fn unwrap_type_refinement(expr: &Expr) -> &Expr {
let mut cur = expr;
loop {
cur = match cur {
Expr::Where { source, .. } | Expr::With { source, .. } => source,
Expr::TypeOptional { inner, .. } => inner,
other => return other,
};
}
}
fn qualified_context_binding<'a>(
expr: &'a Expr,
alias: &str,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
out: &mut HashMap<&'a str, &'a str>,
) {
match expr {
Expr::Binding { name, value, .. } => {
let type_expr = unwrap_type_refinement(value.as_ref());
if let Expr::QualifiedName(q) = type_expr {
if q.qualifier.as_deref() == Some(alias) {
if let Some((entity, _)) = status_by_entity.get_key_value(q.name.as_str()) {
out.insert(&name.name, entity);
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
qualified_context_binding(item, alias, status_by_entity, out);
}
}
_ => {}
}
}
fn collect_provides_param_types<'a>(
expr: &'a Expr,
alias: &str,
context_types: &HashMap<&'a str, &'a str>,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::Ident(fn_name) = function.as_ref() {
let params: Vec<Option<&str>> = args
.iter()
.map(|arg| match arg {
CallArg::Positional(Expr::Ident(id)) => {
context_types.get(id.name.as_str()).copied()
}
CallArg::Named(n) => match unwrap_type_refinement(&n.value) {
Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => {
status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
}
Expr::Ident(v) => context_types.get(v.name.as_str()).copied(),
_ => None,
},
_ => None,
})
.collect();
if params.iter().any(Option::is_some) {
out.insert(&fn_name.name, params);
}
}
}
Expr::WhenGuard { action, .. } => {
collect_provides_param_types(action, alias, context_types, status_by_entity, out)
}
Expr::Block { items, .. } => {
for item in items {
collect_provides_param_types(item, alias, context_types, status_by_entity, out);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_provides_param_types(&b.body, alias, context_types, status_by_entity, out);
}
if let Some(body) = else_body {
collect_provides_param_types(body, alias, context_types, status_by_entity, out);
}
}
Expr::For { body, .. } => {
collect_provides_param_types(body, alias, context_types, status_by_entity, out)
}
_ => {}
}
}
fn collect_qualified_created(
expr: &Expr,
alias: &str,
status_by_entity: &HashMap<&str, HashSet<&str>>,
out: &mut HashMap<String, HashSet<String>>,
) {
match expr {
Expr::Call { function, args, .. } => {
if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
if field.name == "created" {
if let Expr::QualifiedName(q) = object.as_ref() {
if q.qualifier.as_deref() == Some(alias) {
if let Some(statuses) = status_by_entity.get(q.name.as_str()) {
for arg in args {
if let CallArg::Named(named) = arg {
if named.name.name == "status" {
if let Expr::Ident(val) = &named.value {
if statuses.contains(val.name.as_str()) {
out.entry(q.name.clone())
.or_default()
.insert(val.name.clone());
}
}
}
}
}
}
}
}
}
}
}
Expr::Block { items, .. } => {
for item in items {
collect_qualified_created(item, alias, status_by_entity, out);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_qualified_created(&b.body, alias, status_by_entity, out);
}
if let Some(body) = else_body {
collect_qualified_created(body, alias, status_by_entity, out);
}
}
_ => {}
}
}
fn collect_witnessed_transition(
rule: &BlockDecl,
alias: &str,
command_param_types: &HashMap<&str, Vec<Option<&str>>>,
status_by_entity: &HashMap<&str, HashSet<&str>>,
out: &mut ReverseContributions,
) {
let mut binding_entity: HashMap<&str, &str> = HashMap::new();
let mut trigger_source: HashMap<&str, &str> = HashMap::new();
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
match value {
Expr::Call { function, args, .. } => {
let trigger_name = match function.as_ref() {
Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => Some(&q.name),
Expr::Ident(id) => Some(&id.name),
_ => None,
};
if let Some(name) = trigger_name {
if let Some(params) = command_param_types.get(name.as_str()) {
for (arg, param) in args.iter().zip(params) {
if let (CallArg::Positional(Expr::Ident(b)), Some(entity)) = (arg, param)
{
binding_entity.insert(b.name.as_str(), entity);
}
}
}
}
}
Expr::Binding { name, value: inner, .. } => {
if let Some((entity, source)) =
qualified_transition_trigger(inner, alias, status_by_entity)
{
binding_entity.insert(name.name.as_str(), entity);
trigger_source.insert(name.name.as_str(), source);
} else if let Some(entity) =
qualified_temporal_trigger_entity(inner, alias, status_by_entity)
{
binding_entity.insert(name.name.as_str(), entity);
}
}
_ => {}
}
}
if binding_entity.is_empty() {
return;
}
let mut froms: HashMap<&str, HashSet<&str>> = HashMap::new();
let mut tos: HashMap<&str, HashSet<&str>> = HashMap::new();
for (binding, source) in &trigger_source {
froms.entry(binding).or_default().insert(source);
}
for_each_rule_clause(&rule.items, &mut |keyword, value| {
let target = match keyword {
"requires" => &mut froms,
"ensures" => &mut tos,
_ => return,
};
collect_binding_status_eq(value, &mut |binding, status| {
target.entry(binding).or_default().insert(status);
});
});
for (binding, entity) in &binding_entity {
let Some(valid) = status_by_entity.get(*entity) else {
continue;
};
let Some(to_set) = tos.get(binding) else {
continue;
};
for to in to_set {
if !valid.contains(to) {
continue;
}
out.assigned_statuses
.entry((*entity).to_string())
.or_default()
.insert((*to).to_string());
if let Some(from_set) = froms.get(binding) {
for from in from_set {
if valid.contains(from) {
out.witnessed_transitions
.entry((*entity).to_string())
.or_default()
.insert(((*from).to_string(), (*to).to_string()));
}
}
}
}
}
}
fn qualified_transition_trigger<'a>(
expr: &'a Expr,
alias: &str,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
) -> Option<(&'a str, &'a str)> {
let (subject, new_state) = match expr {
Expr::Becomes { subject, new_state, .. }
| Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
_ => return None,
};
let Expr::MemberAccess { object, field, .. } = subject else {
return None;
};
if field.name != "status" {
return None;
}
let Expr::QualifiedName(q) = object.as_ref() else {
return None;
};
if q.qualifier.as_deref() != Some(alias) {
return None;
}
let (entity, values) = status_by_entity.get_key_value(q.name.as_str())?;
let source = expr_as_ident(new_state)?;
if !values.contains(source) {
return None;
}
Some((*entity, source))
}
fn qualified_temporal_trigger_entity<'a>(
expr: &'a Expr,
alias: &str,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
) -> Option<&'a str> {
fn member_entity<'a>(
e: &'a Expr,
alias: &str,
status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
) -> Option<&'a str> {
let Expr::MemberAccess { object, .. } = e else {
return None;
};
let Expr::QualifiedName(q) = object.as_ref() else {
return None;
};
if q.qualifier.as_deref() != Some(alias) {
return None;
}
status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
}
match expr {
Expr::Comparison { left, right, .. } => member_entity(left, alias, status_by_entity)
.or_else(|| member_entity(right, alias, status_by_entity)),
_ => None,
}
}
fn local_transition_trigger_source(expr: &Expr) -> Option<(&str, &str, &str)> {
let Expr::Binding { name, value, .. } = expr else {
return None;
};
let (subject, new_state) = match value.as_ref() {
Expr::Becomes { subject, new_state, .. }
| Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
_ => return None,
};
let Expr::MemberAccess { object, field, .. } = subject else {
return None;
};
if field.name != "status" {
return None;
}
Some((name.name.as_str(), expr_as_ident(object)?, expr_as_ident(new_state)?))
}
fn collect_binding_status_eq<'a>(expr: &'a Expr, cb: &mut impl FnMut(&'a str, &'a str)) {
match expr {
Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
if let (Some((binding, "status")), Some(status)) =
(expr_as_member_access(left), expr_as_ident(right))
{
cb(binding, status);
}
}
Expr::LogicalOp { left, right, .. } => {
collect_binding_status_eq(left, cb);
collect_binding_status_eq(right, cb);
}
Expr::Block { items, .. } => {
for item in items {
collect_binding_status_eq(item, cb);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
collect_binding_status_eq(&b.body, cb);
}
if let Some(body) = else_body {
collect_binding_status_eq(body, cb);
}
}
_ => {}
}
}
pub fn collect_entity_field_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
let mut out: HashMap<String, HashSet<String>> = HashMap::new();
for (name, fields) in collect_local_type_schemas(module) {
out.insert(
name.to_string(),
fields.keys().map(|f| f.to_string()).collect(),
);
}
out
}
pub fn collect_entity_status_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
EntityInfo::from_module(module)
.status_by_entity()
.into_iter()
.map(|(name, statuses)| {
(
name.to_string(),
statuses.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect()
}
struct QRef<'a> {
qualifier: &'a str,
name: &'a str,
span: Span,
collection: bool,
}
fn collect_qrefs_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<QRef<'a>>) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
collect_qrefs_from_expr(value, out);
}
BlockItemKind::Let { value, .. } => {
collect_qrefs_from_collection(value, out);
}
BlockItemKind::ForBlock {
collection,
filter,
items,
..
} => {
collect_qrefs_from_collection(collection, out);
if let Some(f) = filter {
collect_qrefs_from_expr(f, out);
}
for item in items {
collect_qrefs_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock {
branches,
else_items,
} => {
for b in branches {
collect_qrefs_from_expr(&b.condition, out);
for item in &b.items {
collect_qrefs_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_qrefs_from_item(&item.kind, out);
}
}
}
BlockItemKind::ContractsClause { entries } => {
for e in entries {
if let Some(qualifier) = &e.qualifier {
out.push(QRef { qualifier, name: &e.name.name, span: e.name.span, collection: false });
}
}
}
_ => {}
}
}
fn collect_qrefs_from_expr<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
match expr {
Expr::QualifiedName(q) => {
if let Some(qualifier) = &q.qualifier {
out.push(QRef { qualifier, name: &q.name, span: q.span, collection: false });
}
}
Expr::MemberAccess { object, field, .. }
| Expr::OptionalAccess { object, field, .. } => {
if let Expr::Ident(id) = object.as_ref() {
if starts_uppercase(&field.name) {
out.push(QRef { qualifier: &id.name, name: &field.name, span: id.span.merge(field.span), collection: false });
}
}
collect_qrefs_from_expr(object, out);
}
Expr::Call { function, args, .. } => {
collect_qrefs_from_expr(function, out);
for a in args {
match a {
CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
}
}
}
Expr::JoinLookup { entity, fields, .. } => {
collect_qrefs_from_expr(entity, out);
for f in fields {
if let Some(v) = &f.value {
collect_qrefs_from_expr(v, out);
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
collect_qrefs_from_expr(left, out);
collect_qrefs_from_expr(right, out);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => {
collect_qrefs_from_expr(operand, out);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
collect_qrefs_from_expr(element, out);
collect_qrefs_from_collection(collection, out);
}
Expr::Where { source, condition, .. }
| Expr::With {
source,
predicate: condition,
..
} => {
collect_qrefs_from_collection(source, out);
collect_qrefs_from_expr(condition, out);
}
Expr::WhenGuard { action, condition, .. } => {
collect_qrefs_from_expr(action, out);
collect_qrefs_from_expr(condition, out);
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
collect_qrefs_from_expr(value, out);
}
Expr::Block { items, .. } => {
for item in items {
collect_qrefs_from_expr(item, out);
}
}
Expr::Conditional {
branches,
else_body,
..
} => {
for b in branches {
collect_qrefs_from_expr(&b.condition, out);
collect_qrefs_from_expr(&b.body, out);
}
if let Some(body) = else_body {
collect_qrefs_from_expr(body, out);
}
}
Expr::For {
collection,
filter,
body,
..
} => {
collect_qrefs_from_collection(collection, out);
if let Some(f) = filter {
collect_qrefs_from_expr(f, out);
}
collect_qrefs_from_expr(body, out);
}
Expr::Lambda { body, .. } => {
collect_qrefs_from_expr(body, out);
}
Expr::TransitionsTo {
subject, new_state, ..
}
| Expr::Becomes {
subject, new_state, ..
} => {
collect_qrefs_from_expr(subject, out);
collect_qrefs_from_expr(new_state, out);
}
Expr::GenericType { name, args, .. } => {
collect_qrefs_from_expr(name, out);
for a in args {
collect_qrefs_from_expr(a, out);
}
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
collect_qrefs_from_expr(e, out);
}
}
Expr::ObjectLiteral { fields, .. } => {
for f in fields {
collect_qrefs_from_expr(&f.value, out);
}
}
Expr::ProjectionMap { source, .. } => {
collect_qrefs_from_expr(source, out);
}
_ => {}
}
}
fn collect_qrefs_from_collection<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
match expr {
Expr::QualifiedName(q) => {
if let Some(qualifier) = &q.qualifier {
out.push(QRef { qualifier, name: &q.name, span: q.span, collection: true });
}
}
Expr::Where { source, condition, .. }
| Expr::With {
source,
predicate: condition,
..
} => {
collect_qrefs_from_collection(source, out);
collect_qrefs_from_expr(condition, out);
}
other => collect_qrefs_from_expr(other, out),
}
}
impl Ctx<'_> {
fn check_deferred_location_hints(&mut self, source: &str) {
for d in &self.module.declarations {
let Decl::Deferred(def) = d else {
continue;
};
const LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
let bytes = source.as_bytes();
let kw_start = def.span.start;
let line_start = source[..kw_start]
.rfind(LINE_TERMINATORS)
.map_or(0, |i| {
i + source[i..].chars().next().map_or(1, char::len_utf8)
});
let mut name_start = kw_start + "deferred".len();
while bytes.get(name_start).is_some_and(u8::is_ascii_whitespace) {
name_start += 1;
}
let starts_name =
|b: &u8| b.is_ascii_alphabetic() || *b == b'_';
let continues_name =
|b: &u8| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.';
if !bytes[line_start..kw_start]
.iter()
.all(|b| b.is_ascii_whitespace())
|| name_start == kw_start + "deferred".len()
|| !bytes.get(name_start).is_some_and(starts_name)
{
continue;
}
let mut name_end = name_start + 1;
while bytes.get(name_end).is_some_and(continues_name) {
name_end += 1;
}
let line_end = source[name_end..]
.find(LINE_TERMINATORS)
.map_or(source.len(), |i| name_end + i);
let suffix = &source[name_end..line_end];
if suffix.contains('"')
|| suffix.contains("http://")
|| suffix.contains("https://")
|| suffix.contains("-- see:")
{
continue;
}
self.push(
Diagnostic::warning(
def.span,
format!(
"Deferred specification '{}' should include a location hint.",
&source[name_start..name_end],
),
)
.with_code("allium.deferred.missingLocationHint"),
);
}
}
}
impl Ctx<'_> {
fn check_rule_invalid_triggers(&mut self) {
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => &n.name,
None => continue,
};
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
if !is_valid_trigger(value) {
self.push(
Diagnostic::error(
item.span,
format!(
"Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
),
)
.with_code("allium.rule.invalidTrigger"),
);
} else if let Some((param, span)) = first_named_trigger_param(value) {
self.push(
Diagnostic::error(
span,
format!(
"Rule '{rule_name}' trigger parameter '{param}' uses a 'name: value' form. External-stimulus and chained trigger parameters are bare names (optionally suffixed with '?', or '_' to discard); the 'name: value' form is only valid in trigger emissions. Write '{param}' without the annotation.",
),
)
.with_code("allium.rule.invalidTrigger"),
);
}
}
}
}
}
fn first_named_trigger_param(expr: &Expr) -> Option<(&str, Span)> {
match expr {
Expr::Call { args, .. } => args.iter().find_map(|arg| match arg {
CallArg::Named(n) => Some((n.name.name.as_str(), n.span)),
_ => None,
}),
Expr::LogicalOp {
op: LogicalOp::Or,
left,
right,
..
} => first_named_trigger_param(left).or_else(|| first_named_trigger_param(right)),
_ => None,
}
}
fn literal_kind(expr: &Expr) -> Option<&'static str> {
match expr {
Expr::StringLiteral(_) => Some("string"),
Expr::NumberLiteral { .. } => Some("number"),
Expr::BoolLiteral { .. } => Some("boolean"),
Expr::DurationLiteral { .. } => Some("duration"),
Expr::BacktickLiteral { .. } => Some("backtick literal"),
_ => None,
}
}
impl Ctx<'_> {
fn check_list_literal_homogeneity(&mut self) {
let mut lists: Vec<(&[Expr], Span)> = Vec::new();
for d in &self.module.declarations {
match d {
Decl::Block(b) => {
for item in &b.items {
collect_list_literals_from_item(&item.kind, &mut lists);
}
}
Decl::Variant(v) => {
for item in &v.items {
collect_list_literals_from_item(&item.kind, &mut lists);
}
}
Decl::Invariant(inv) => collect_list_literals_from_expr(&inv.body, &mut lists),
Decl::Default(def) => collect_list_literals_from_expr(&def.value, &mut lists),
_ => {}
}
}
for (elements, span) in lists {
let mut first: Option<&'static str> = None;
for e in elements {
let Some(kind) = literal_kind(e) else { continue };
match first {
None => first = Some(kind),
Some(expected) if expected != kind => {
self.push(
Diagnostic::error(
span,
format!(
"List literal has elements of differing types ('{expected}' and '{kind}'); all elements of a list must share a type.",
),
)
.with_code("allium.list.mixedElementTypes"),
);
break;
}
_ => {}
}
}
}
}
}
impl Ctx<'_> {
fn check_undefined_import_aliases(&mut self) {
let aliases: HashSet<&str> = self
.module
.declarations
.iter()
.filter_map(|d| match d {
Decl::Use(u) => u.alias.as_ref().map(|a| a.name.as_str()),
_ => None,
})
.collect();
let mut refs = collect_qref_nodes(self.module);
for d in &self.module.declarations {
if let Decl::Default(def) = d {
if let (Some(a), Some(t)) = (&def.type_alias, &def.type_name) {
refs.push(QRef {
qualifier: &a.name,
name: &t.name,
span: a.span.merge(t.span),
collection: false,
});
}
}
}
for r in refs {
if !aliases.contains(r.qualifier) {
self.push(
Diagnostic::error(
r.span,
format!(
"Reference '{}/{}' uses unknown import alias '{}'.",
r.qualifier, r.name, r.qualifier
),
)
.with_code("allium.reference.undefinedImportedAlias"),
);
} else if let Some(offered) = self
.imported_referenced_triggers
.and_then(|m| m.get(r.qualifier))
{
if !r.collection && !offered.contains(r.name) {
self.push(
Diagnostic::warning(
r.span,
format!(
"Reference '{}/{}' names '{}', which imported module '{}' does not define.",
r.qualifier, r.name, r.name, r.qualifier
),
)
.with_code("allium.reference.unknownName"),
);
}
}
}
}
fn check_default_field_schemas(&mut self) {
let schemas = collect_local_type_schemas(self.module);
let mut diagnostics = Vec::new();
for d in &self.module.declarations {
let Decl::Default(def) = d else { continue };
let (Some(type_name), Expr::ObjectLiteral { fields, .. }) =
(&def.type_name, &def.value)
else {
continue;
};
match &def.type_alias {
None => {
validate_object_literal(fields, &type_name.name, &schemas, &mut diagnostics);
}
Some(alias) => {
if let Some(imported) = self
.imported_entity_fields
.and_then(|m| m.get(alias.name.as_str()))
.and_then(|types| types.get(type_name.name.as_str()))
{
for field in fields {
if !imported.contains(field.name.name.as_str()) {
diagnostics.push(
Diagnostic::error(
field.name.span,
format!(
"Default sets field '{}' which is not declared on '{}/{}'.",
field.name.name, alias.name, type_name.name
),
)
.with_code("allium.default.unknownField"),
);
}
}
}
}
}
}
for diag in diagnostics {
self.push(diag);
}
}
}
fn collect_local_type_schemas(module: &Module) -> HashMap<&str, HashMap<&str, &Expr>> {
let mut schemas: HashMap<&str, HashMap<&str, &Expr>> = HashMap::new();
for d in &module.declarations {
let Decl::Block(b) = d else { continue };
if !matches!(
b.kind,
BlockKind::Entity | BlockKind::ExternalEntity | BlockKind::Value
) {
continue;
}
let Some(name) = &b.name else { continue };
let mut fields: HashMap<&str, &Expr> = HashMap::new();
for item in &b.items {
match &item.kind {
BlockItemKind::Assignment { name: f, value }
| BlockItemKind::FieldWithWhen { name: f, value, .. } => {
fields.insert(f.name.as_str(), value);
}
_ => {}
}
}
schemas.insert(name.name.as_str(), fields);
}
schemas
}
fn is_list_type(expr: &Expr) -> bool {
match expr {
Expr::GenericType { name, .. } => matches!(name.as_ref(), Expr::Ident(id) if id.name == "List"),
Expr::TypeOptional { inner, .. } => is_list_type(inner),
_ => false,
}
}
fn base_type_name(expr: &Expr) -> Option<&str> {
match expr {
Expr::Ident(id) => Some(id.name.as_str()),
Expr::TypeOptional { inner, .. } => base_type_name(inner),
_ => None,
}
}
fn validate_object_literal<'a>(
fields: &'a [NamedArg],
type_name: &str,
schemas: &HashMap<&'a str, HashMap<&'a str, &'a Expr>>,
out: &mut Vec<Diagnostic>,
) {
let Some(schema) = schemas.get(type_name) else { return };
for field in fields {
let Some(field_type) = schema.get(field.name.name.as_str()) else {
out.push(
Diagnostic::error(
field.name.span,
format!(
"Default sets field '{}' which is not declared on '{}'.",
field.name.name, type_name
),
)
.with_code("allium.default.unknownField"),
);
continue;
};
if let Expr::ListLiteral { elements, span } = &field.value {
if elements.is_empty() && !is_list_type(field_type) {
out.push(
Diagnostic::error(
*span,
format!(
"Empty list literal has no inferable element type: target field '{}' is not a List<T>.",
field.name.name
),
)
.with_code("allium.list.emptyListNoElementType"),
);
}
}
if let Expr::ObjectLiteral { fields: nested, .. } = &field.value {
if let Some(nested_type) = base_type_name(field_type) {
validate_object_literal(nested, nested_type, schemas, out);
}
}
}
}
fn collect_list_literals_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<(&'a [Expr], Span)>) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::PathAssignment { value, .. }
| BlockItemKind::InvariantBlock { body: value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
collect_list_literals_from_expr(value, out);
}
BlockItemKind::ForBlock { collection, filter, items, .. } => {
collect_list_literals_from_expr(collection, out);
if let Some(f) = filter {
collect_list_literals_from_expr(f, out);
}
for item in items {
collect_list_literals_from_item(&item.kind, out);
}
}
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
collect_list_literals_from_expr(&b.condition, out);
for item in &b.items {
collect_list_literals_from_item(&item.kind, out);
}
}
if let Some(items) = else_items {
for item in items {
collect_list_literals_from_item(&item.kind, out);
}
}
}
_ => {}
}
}
fn collect_list_literals_from_expr<'a>(expr: &'a Expr, out: &mut Vec<(&'a [Expr], Span)>) {
if let Expr::ListLiteral { elements, span } = expr {
out.push((elements, *span));
}
walk_expr_children(expr, &mut |child| collect_list_literals_from_expr(child, out));
}
fn walk_expr_children<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) {
match expr {
Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => f(object),
Expr::Call { function, args, .. } => {
f(function);
for a in args {
match a {
CallArg::Positional(e) => f(e),
CallArg::Named(n) => f(&n.value),
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
f(left);
f(right);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => f(operand),
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
f(element);
f(collection);
}
Expr::Where { source, condition, .. }
| Expr::With { source, predicate: condition, .. } => {
f(source);
f(condition);
}
Expr::WhenGuard { action, condition, .. } => {
f(action);
f(condition);
}
Expr::Block { items, .. } => {
for item in items {
f(item);
}
}
Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => f(value),
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
f(&b.condition);
f(&b.body);
}
if let Some(body) = else_body {
f(body);
}
}
Expr::For { collection, filter, body, .. } => {
f(collection);
if let Some(filt) = filter {
f(filt);
}
f(body);
}
Expr::Lambda { body, .. } => f(body),
Expr::JoinLookup { entity, fields, .. } => {
f(entity);
for jf in fields {
if let Some(v) = &jf.value {
f(v);
}
}
}
Expr::TransitionsTo { subject, new_state, .. }
| Expr::Becomes { subject, new_state, .. } => {
f(subject);
f(new_state);
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
for e in elements {
f(e);
}
}
Expr::ObjectLiteral { fields, .. } => {
for fld in fields {
f(&fld.value);
}
}
Expr::GenericType { name, args, .. } => {
f(name);
for a in args {
f(a);
}
}
Expr::ProjectionMap { source, .. } => f(source),
_ => {}
}
}
fn is_valid_trigger(expr: &Expr) -> bool {
match expr {
Expr::Call { function, .. } => {
matches!(
function.as_ref(),
Expr::Ident(_) | Expr::MemberAccess { .. } | Expr::QualifiedName(_)
)
}
Expr::Binding { value, .. } => {
matches!(
value.as_ref(),
Expr::Becomes { .. }
| Expr::TransitionsTo { .. }
| Expr::MemberAccess { .. }
| Expr::Comparison { .. }
)
}
Expr::LogicalOp {
op: LogicalOp::Or,
left,
right,
..
} => is_valid_trigger(left) && is_valid_trigger(right),
Expr::Comparison { left, .. } => {
matches!(left.as_ref(), Expr::MemberAccess { .. })
}
_ => false,
}
}
impl Ctx<'_> {
fn check_rule_undefined_bindings(&mut self) {
let mut given_bindings: HashSet<&str> = HashSet::new();
for given in self.blocks(BlockKind::Given) {
for item in &given.items {
if let BlockItemKind::Assignment { name, .. } = &item.kind {
given_bindings.insert(&name.name);
}
}
}
let mut default_names: HashSet<&str> = HashSet::new();
for d in &self.module.declarations {
if let Decl::Default(def) = d {
default_names.insert(&def.name.name);
}
}
for rule in self.blocks(BlockKind::Rule) {
let rule_name = match &rule.name {
Some(n) => &n.name,
None => continue,
};
let mut bound: HashSet<&str> = HashSet::new();
bound.extend(&given_bindings);
bound.extend(&default_names);
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else {
continue;
};
if keyword != "when" {
continue;
}
collect_bound_names(value, &mut bound);
}
check_unbound_in_items(&rule.items, &bound, rule_name, &mut self.diagnostics);
for item in &rule.items {
let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
if keyword != "when" { continue }
let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
continue;
}
let mut found = false;
for check_item in &rule.items {
let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
if kw != "requires" && kw != "ensures" { continue }
if expr_contains_ident(v, &binding_name.name) {
self.push(
Diagnostic::error(
check_item.span,
format!(
"Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
binding_name.name
),
)
.with_code("allium.rule.undefinedBinding"),
);
found = true;
break;
}
}
if found { break; }
}
}
}
}
fn check_unbound_in_items<'a>(
items: &'a [BlockItem],
parent_bound: &HashSet<&'a str>,
rule_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut bound: HashSet<&'a str> = parent_bound.clone();
for item in items {
if let BlockItemKind::Let { name, .. } = &item.kind {
bound.insert(&name.name);
}
}
for item in items {
match &item.kind {
BlockItemKind::Clause { keyword, value } => {
if keyword == "requires" || keyword == "ensures" {
check_unbound_roots(value, &bound, rule_name, diagnostics);
}
}
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
check_unbound_in_items(&b.items, &bound, rule_name, diagnostics);
}
if let Some(else_items) = else_items {
check_unbound_in_items(else_items, &bound, rule_name, diagnostics);
}
}
BlockItemKind::ForBlock { binding, items: for_items, .. } => {
let mut inner = bound.clone();
match binding {
ForBinding::Single(id) => {
inner.insert(&id.name);
}
ForBinding::Destructured(ids, _) => {
for id in ids {
inner.insert(&id.name);
}
}
}
check_unbound_in_items(for_items, &inner, rule_name, diagnostics);
}
_ => {}
}
}
}
fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
match expr {
Expr::Binding { name, .. } => {
out.insert(&name.name);
}
Expr::Call { args, .. } => {
for arg in args {
match arg {
CallArg::Positional(Expr::Ident(id)) => {
out.insert(&id.name);
}
CallArg::Named(n) => {
out.insert(&n.name.name);
}
_ => {}
}
}
}
Expr::LogicalOp { left, right, .. } => {
collect_bound_names(left, out);
collect_bound_names(right, out);
}
_ => {}
}
}
fn check_unbound_roots(
expr: &Expr,
bound: &HashSet<&str>,
rule_name: &str,
diagnostics: &mut Vec<Diagnostic>,
) {
match expr {
Expr::MemberAccess { object, .. } => {
if let Expr::Ident(id) = object.as_ref() {
if !starts_uppercase(&id.name)
&& !bound.contains(id.name.as_str())
&& !is_builtin_name(&id.name)
{
diagnostics.push(
Diagnostic::error(
id.span,
format!(
"Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
id.name
),
)
.with_code("allium.rule.undefinedBinding"),
);
}
}
}
Expr::Comparison { left, right, .. } => {
check_unbound_roots(left, bound, rule_name, diagnostics);
check_unbound_roots(right, bound, rule_name, diagnostics);
}
Expr::LogicalOp { left, right, .. } => {
check_unbound_roots(left, bound, rule_name, diagnostics);
check_unbound_roots(right, bound, rule_name, diagnostics);
}
Expr::Block { items, .. } => {
let mut block_bound = bound.clone();
for item in items {
if let Expr::LetExpr { name, value, .. } = item {
check_unbound_roots(value, &block_bound, rule_name, diagnostics);
block_bound.insert(name.name.as_str());
} else {
check_unbound_roots(item, &block_bound, rule_name, diagnostics);
}
}
}
Expr::For { binding, collection, body, .. } => {
check_unbound_roots(collection, bound, rule_name, diagnostics);
let mut inner = bound.clone();
match binding {
ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
ForBinding::Destructured(ids, _) => {
for id in ids {
inner.insert(id.name.as_str());
}
}
}
check_unbound_roots(body, &inner, rule_name, diagnostics);
}
Expr::BinaryOp { left, right, .. } => {
check_unbound_roots(left, bound, rule_name, diagnostics);
check_unbound_roots(right, bound, rule_name, diagnostics);
}
Expr::Call { function, args, .. } => {
if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
check_unbound_roots(function, bound, rule_name, diagnostics);
}
let mut call_bound = bound.clone();
for a in args {
if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
if let Expr::Ident(id) = param.as_ref() {
call_bound.insert(id.name.as_str());
}
}
}
for a in args {
match a {
CallArg::Positional(Expr::Lambda { body, .. }) => {
check_unbound_roots(body, &call_bound, rule_name, diagnostics);
}
CallArg::Positional(e) => {
check_unbound_roots(e, &call_bound, rule_name, diagnostics);
}
CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
}
}
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. } => {
check_unbound_roots(operand, bound, rule_name, diagnostics);
}
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
check_unbound_roots(element, bound, rule_name, diagnostics);
check_unbound_roots(collection, bound, rule_name, diagnostics);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
check_unbound_roots(&b.body, bound, rule_name, diagnostics);
}
if let Some(body) = else_body {
check_unbound_roots(body, bound, rule_name, diagnostics);
}
}
_ => {}
}
}
fn is_builtin_name(name: &str) -> bool {
matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
}
impl Ctx<'_> {
fn check_duplicate_let_bindings(&mut self) {
for rule in self.blocks(BlockKind::Rule) {
let mut seen: HashMap<&str, Span> = HashMap::new();
self.check_duplicate_lets_in_items(&rule.items, &mut seen);
}
}
fn check_duplicate_lets_in_items<'b>(
&mut self,
items: &'b [BlockItem],
seen: &mut HashMap<&'b str, Span>,
) {
for item in items {
match &item.kind {
BlockItemKind::Let { name, .. } => {
if seen.contains_key(name.name.as_str()) {
self.push(
Diagnostic::error(
name.span,
format!("Duplicate let binding '{}' in this rule.", name.name),
)
.with_code("allium.let.duplicateBinding"),
);
} else {
seen.insert(&name.name, name.span);
}
}
BlockItemKind::ForBlock { items, .. } => {
self.check_duplicate_lets_in_items(items, seen);
}
BlockItemKind::IfBlock {
branches,
else_items,
} => {
for b in branches {
self.check_duplicate_lets_in_items(&b.items, seen);
}
if let Some(items) = else_items {
self.check_duplicate_lets_in_items(items, seen);
}
}
BlockItemKind::Clause { value, .. } => {
self.check_duplicate_lets_in_expr(value, seen);
}
_ => {}
}
}
}
fn check_duplicate_lets_in_expr<'b>(
&mut self,
expr: &'b Expr,
seen: &mut HashMap<&'b str, Span>,
) {
match expr {
Expr::LetExpr { name, value, .. } => {
if seen.contains_key(name.name.as_str()) {
self.push(
Diagnostic::error(
name.span,
format!("Duplicate let binding '{}' in this rule.", name.name),
)
.with_code("allium.let.duplicateBinding"),
);
} else {
seen.insert(&name.name, name.span);
}
self.check_duplicate_lets_in_expr(value, seen);
}
Expr::Block { items, .. } => {
for item in items {
self.check_duplicate_lets_in_expr(item, seen);
}
}
Expr::For { body, .. } => {
self.check_duplicate_lets_in_expr(body, seen);
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
self.check_duplicate_lets_in_expr(&b.body, seen);
}
if let Some(body) = else_body {
self.check_duplicate_lets_in_expr(body, seen);
}
}
_ => {}
}
}
}
impl Ctx<'_> {
fn check_config_undefined_references(&mut self) {
let mut config_params: HashSet<&str> = HashSet::new();
for config in self.blocks(BlockKind::Config) {
for item in &config.items {
if let BlockItemKind::Assignment { name, .. } = &item.kind {
config_params.insert(&name.name);
}
}
}
for d in &self.module.declarations {
match d {
Decl::Block(b) => {
if b.kind == BlockKind::Config {
continue;
}
for item in &b.items {
self.check_config_refs_in_item(&item.kind, &config_params);
}
}
Decl::Invariant(inv) => {
self.check_config_refs_in_expr(&inv.body, &config_params);
}
_ => {}
}
}
}
fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
match kind {
BlockItemKind::Clause { value, .. }
| BlockItemKind::Assignment { value, .. }
| BlockItemKind::ParamAssignment { value, .. }
| BlockItemKind::Let { value, .. }
| BlockItemKind::FieldWithWhen { value, .. } => {
self.check_config_refs_in_expr(value, params);
}
BlockItemKind::ForBlock { collection, filter, items, .. } => {
self.check_config_refs_in_expr(collection, params);
if let Some(f) = filter {
self.check_config_refs_in_expr(f, params);
}
for item in items {
self.check_config_refs_in_item(&item.kind, params);
}
}
BlockItemKind::IfBlock { branches, else_items } => {
for b in branches {
self.check_config_refs_in_expr(&b.condition, params);
for item in &b.items {
self.check_config_refs_in_item(&item.kind, params);
}
}
if let Some(items) = else_items {
for item in items {
self.check_config_refs_in_item(&item.kind, params);
}
}
}
_ => {}
}
}
fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
match expr {
Expr::MemberAccess { object, field, .. } => {
if let Expr::Ident(id) = object.as_ref() {
if id.name == "config" && !params.contains(field.name.as_str()) {
self.push(
Diagnostic::warning(
field.span,
format!(
"Config reference 'config.{}' is not declared in any config block.",
field.name
),
)
.with_code("allium.config.undefinedReference"),
);
return;
}
}
self.check_config_refs_in_expr(object, params);
}
Expr::Call { function, args, .. } => {
self.check_config_refs_in_expr(function, params);
for a in args {
match a {
CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
}
}
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
self.check_config_refs_in_expr(left, params);
self.check_config_refs_in_expr(right, params);
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. } => {
self.check_config_refs_in_expr(operand, params);
}
Expr::Block { items, .. } => {
for item in items {
self.check_config_refs_in_expr(item, params);
}
}
Expr::Conditional { branches, else_body, .. } => {
for b in branches {
self.check_config_refs_in_expr(&b.condition, params);
self.check_config_refs_in_expr(&b.body, params);
}
if let Some(body) = else_body {
self.check_config_refs_in_expr(body, params);
}
}
Expr::For { collection, filter, body, .. } => {
self.check_config_refs_in_expr(collection, params);
if let Some(f) = filter {
self.check_config_refs_in_expr(f, params);
}
self.check_config_refs_in_expr(body, params);
}
Expr::LetExpr { value, .. } => {
self.check_config_refs_in_expr(value, params);
}
Expr::Lambda { body, .. } => {
self.check_config_refs_in_expr(body, params);
}
_ => {}
}
}
}
fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
match kind {
BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
BlockItemKind::ForBlock {
collection,
filter,
items,
..
} => {
expr_contains_ident(collection, name)
|| filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
|| items.iter().any(|i| item_contains_ident(&i.kind, name))
}
BlockItemKind::IfBlock {
branches,
else_items,
} => {
branches.iter().any(|b| {
expr_contains_ident(&b.condition, name)
|| b.items.iter().any(|i| item_contains_ident(&i.kind, name))
}) || else_items
.as_ref()
.is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
}
BlockItemKind::PathAssignment { path, value } => {
expr_contains_ident(path, name) || expr_contains_ident(value, name)
}
BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
BlockItemKind::ContractsClause { .. }
| BlockItemKind::EnumVariant { .. }
| BlockItemKind::OpenQuestion { .. }
| BlockItemKind::Annotation(_)
| BlockItemKind::TransitionsBlock(_) => false,
}
}
fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
match expr {
Expr::Ident(id) => id.name == name,
Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
expr_contains_ident(object, name)
}
Expr::Call { function, args, .. } => {
expr_contains_ident(function, name)
|| args.iter().any(|a| match a {
CallArg::Positional(e) => expr_contains_ident(e, name),
CallArg::Named(n) => expr_contains_ident(&n.value, name),
})
}
Expr::JoinLookup { entity, fields, .. } => {
expr_contains_ident(entity, name)
|| fields
.iter()
.any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
}
Expr::BinaryOp { left, right, .. }
| Expr::Comparison { left, right, .. }
| Expr::LogicalOp { left, right, .. }
| Expr::Pipe { left, right, .. }
| Expr::NullCoalesce { left, right, .. } => {
expr_contains_ident(left, name) || expr_contains_ident(right, name)
}
Expr::Not { operand, .. }
| Expr::Exists { operand, .. }
| Expr::NotExists { operand, .. }
| Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
expr_contains_ident(element, name) || expr_contains_ident(collection, name)
}
Expr::Where {
source, condition, ..
}
| Expr::With {
source,
predicate: condition,
..
} => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
Expr::WhenGuard {
action, condition, ..
} => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
Expr::Lambda { param, body, .. } => {
expr_contains_ident(param, name) || expr_contains_ident(body, name)
}
Expr::Binding { name: n, value, .. } => {
n.name == name || expr_contains_ident(value, name)
}
Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
elements.iter().any(|e| expr_contains_ident(e, name))
}
Expr::ObjectLiteral { fields, .. } => {
fields.iter().any(|f| expr_contains_ident(&f.value, name))
}
Expr::GenericType { name: n, args, .. } => {
expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
}
Expr::Conditional {
branches,
else_body,
..
} => {
branches.iter().any(|b| {
expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
}) || else_body
.as_ref()
.is_some_and(|e| expr_contains_ident(e, name))
}
Expr::For {
collection,
filter,
body,
..
} => {
expr_contains_ident(collection, name)
|| filter
.as_ref()
.is_some_and(|f| expr_contains_ident(f, name))
|| expr_contains_ident(body, name)
}
Expr::TransitionsTo {
subject, new_state, ..
}
| Expr::Becomes {
subject, new_state, ..
} => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
Expr::QualifiedName(_)
| Expr::StringLiteral(_)
| Expr::BacktickLiteral { .. }
| Expr::NumberLiteral { .. }
| Expr::BoolLiteral { .. }
| Expr::Null { .. }
| Expr::Now { .. }
| Expr::This { .. }
| Expr::Within { .. }
| Expr::DurationLiteral { .. } => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostic::Severity;
use crate::parser::parse;
fn analyze_src(src: &str) -> Vec<Diagnostic> {
let input = if src.starts_with("-- allium:") {
src.to_string()
} else {
format!("-- allium: 3\n{src}")
};
let result = parse(&input);
analyze(&result.module, &input)
}
fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
diagnostics.iter().any(|d| d.code == Some(code))
}
fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
diagnostics.iter().filter(|d| d.code == Some(code)).count()
}
fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
let input = if src.starts_with("-- allium:") {
src.to_string()
} else {
format!("-- allium: 3\n{src}")
};
let result = parse(&input);
analyse(&result.module, &input)
}
fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
result.findings.iter().any(|f| f["type"] == finding_type)
}
#[test]
fn suppression_on_previous_line() {
let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
assert!(!has_code(&ds, "allium.field.unused"));
}
#[test]
fn suppression_all() {
let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
assert!(!has_code(&ds, "allium.field.unused"));
}
#[test]
fn related_clause_with_binding_and_guard() {
let ds = analyze_src(
"surface QuoteVersions {\n facing user: User\n}\n\n\
surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
);
assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
}
#[test]
fn related_clause_reports_unknown_surface() {
let ds = analyze_src(
"surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
);
assert!(has_code(&ds, "allium.surface.relatedUndefined"));
}
#[test]
fn v1_capitalised_inline_enum() {
let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
}
#[test]
fn discard_binding_no_warning() {
let ds = analyze_src(
"surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
);
assert!(!has_code(&ds, "allium.surface.unusedBinding"));
}
#[test]
fn variable_status_assignment_suppresses_unreachable() {
let ds = analyze_src(
"entity Quote {\n status: pending | quoted | filled\n}\n\n\
rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
ensures: update.status = new_status\n}\n",
);
assert!(!has_code(&ds, "allium.status.unreachableValue"));
assert!(!has_code(&ds, "allium.status.noExit"));
}
#[test]
fn surface_param_types_disambiguate_shared_status_values() {
let ds = analyze_src(
"entity Account {\n status: active | suspended\n}\n\n\
entity Subscription {\n status: active | expired | cancelled\n}\n\n\
surface AccountAdmin {\n facing admin: Admin\n provides:\n \
SuspendAccount(admin, account: Account)\n when account.status = active\n \
ReinstateAccount(admin, account: Account)\n when account.status = suspended\n}\n\n\
surface SubscriptionAdmin {\n facing admin: Admin\n provides:\n \
CancelSubscription(admin, sub: Subscription)\n when sub.status = active\n \
RenewSubscription(admin, sub: Subscription)\n when sub.status != active\n \
ExpireSubscription(admin, sub: Subscription)\n when sub.status = active\n}\n\n\
rule AccountSuspended {\n when: SuspendAccount(admin, account)\n \
requires: account.status = active\n ensures: account.status = suspended\n}\n\n\
rule AccountReinstated {\n when: ReinstateAccount(admin, account)\n \
requires: account.status = suspended\n ensures: account.status = active\n}\n\n\
rule SubscriptionCancelled {\n when: CancelSubscription(admin, sub)\n \
requires: sub.status = active\n ensures: sub.status = cancelled\n}\n\n\
rule SubscriptionExpired {\n when: ExpireSubscription(admin, sub)\n \
requires: sub.status = active\n ensures: sub.status = expired\n}\n\n\
rule SubscriptionRenewed {\n when: RenewSubscription(admin, sub)\n \
requires: sub.status != active\n ensures: sub.status = active\n}\n",
);
assert!(!has_code(&ds, "allium.status.unreachableValue"));
assert!(!has_code(&ds, "allium.status.noExit"));
}
#[test]
fn negated_requires_counts_as_exit_for_complement_values() {
let ds = analyze_src(
"entity Order {\n status: draft | submitted | approved | rejected\n}\n\n\
rule OrderSubmitted {\n when: SubmitOrder(clerk, order)\n \
requires: order.status = draft\n ensures: order.status = submitted\n}\n\n\
rule OrderApproved {\n when: ApproveOrder(clerk, order)\n \
requires: order.status = submitted\n ensures: order.status = approved\n}\n\n\
rule OrderRejected {\n when: RejectOrder(clerk, order)\n \
requires: order.status = submitted\n ensures: order.status = rejected\n}\n\n\
rule OrderReactivated {\n when: ReactivateOrder(clerk, order)\n \
requires: order.status != draft\n ensures: order.status = draft\n}\n",
);
assert!(!has_code(&ds, "allium.status.unreachableValue"));
assert!(!has_code(&ds, "allium.status.noExit"));
}
#[test]
fn created_with_status_suppresses_unreachable() {
let ds = analyze_src(
"entity Order {\n status: pending | confirmed\n customer: String\n \
transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
);
assert!(!has_code(&ds, "allium.status.unreachableValue"));
}
#[test]
fn created_omitting_status_warns() {
let ds = analyze_src(
"entity Order {\n status: pending | confirmed\n customer: String\n \
transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
Order.created(\n customer: customer\n )\n}\n",
);
assert!(has_code(&ds, "allium.created.missingStatus"));
}
#[test]
fn created_multiple_initial_statuses() {
let ds = analyze_src(
"entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
transitions status {\n draft -> submitted\n submitted -> reviewed\n \
terminal: reviewed\n }\n}\n\n\
rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
Proposal.created(status: draft, author: author)\n}\n\n\
rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
Proposal.created(status: submitted, author: author)\n}\n\n\
rule Review {\n when: ReviewerReviews(proposal)\n \
requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
);
assert!(!has_code(&ds, "allium.status.unreachableValue"));
}
#[test]
fn created_invalid_status_errors() {
let ds = analyze_src(
"entity Task {\n status: open | in_progress | done\n title: String\n \
transitions status {\n open -> in_progress\n in_progress -> done\n \
terminal: done\n }\n}\n\n\
rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
Task.created(status: archived, title: title)\n}\n",
);
assert!(has_code(&ds, "allium.created.invalidStatus"));
}
#[test]
fn created_without_transitions_no_missing_status_warning() {
let ds = analyze_src(
"entity Note {\n status: draft | published\n content: String\n}\n\n\
rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
Note.created(content: content)\n}\n",
);
assert!(!has_code(&ds, "allium.created.missingStatus"));
}
#[test]
fn terminal_declared_suppresses_no_exit() {
let ds = analyze_src(
"entity Subscription {\n status: active | paused | completed | cancelled\n \
transitions status {\n active -> paused\n paused -> active\n \
active -> completed\n active -> cancelled\n paused -> cancelled\n \
terminal: completed, cancelled\n }\n}\n\n\
rule Activate {\n when: UserActivates(user, subscription)\n \
requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
rule Pause {\n when: UserPauses(user, subscription)\n \
requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
rule Complete {\n when: PeriodEnds(subscription)\n \
requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
rule Cancel {\n when: UserCancels(user, subscription)\n \
requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
);
assert!(!has_code(&ds, "allium.status.noExit"));
}
#[test]
fn non_terminal_no_exit_still_warns() {
let ds = analyze_src(
"entity Ticket {\n status: open | stuck | resolved\n \
transitions status {\n open -> stuck\n open -> resolved\n \
terminal: resolved\n }\n}\n\n\
rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
rule Resolve {\n when: AgentResolves(agent, ticket)\n \
requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
);
assert!(has_code(&ds, "allium.status.noExit"));
}
#[test]
fn cross_entity_trigger_param_recognised() {
let ds = analyze_src(
"entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
transitions status {\n scheduled -> confirmed\n \
confirmed -> completed\n terminal: completed\n }\n}\n\n\
rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
InterviewSlot.created(status: scheduled)\n}\n\n\
rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
);
assert!(!ds.iter().any(|d| {
d.code == Some("allium.status.unreachableValue")
&& d.message.contains("InterviewSlot")
}));
assert!(!ds.iter().any(|d| {
d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
}));
}
#[test]
fn cross_entity_undeclared_transition() {
let ds = analyze_src(
"entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
transitions status {\n scheduled -> confirmed\n \
confirmed -> completed\n terminal: completed\n }\n}\n\n\
rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
);
assert!(has_code(&ds, "allium.status.undeclaredTransition"));
}
#[test]
fn nested_entity_status_recognised() {
let ds = analyze_src(
"entity Order {\n status: placed | paid\n payment: Payment\n \
transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
entity Payment {\n status: pending | captured | failed\n \
transitions status {\n pending -> captured\n pending -> failed\n \
terminal: captured, failed\n }\n}\n\n\
rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
requires: order.payment.status = pending\n \
ensures: order.payment.status = captured\n}\n",
);
assert!(!ds.iter().any(|d| {
(d.code == Some("allium.status.unreachableValue")
|| d.code == Some("allium.status.noExit"))
&& d.message.contains("'captured'")
}));
}
#[test]
fn dead_transition_missing_producer() {
let r = analyse_src(
"entity App {\n status: submitted | screening | approved | rejected\n \
verified: Boolean\n \
transitions status {\n submitted -> screening\n screening -> approved\n \
screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
requires:\n app.status = screening\n app.verified = true\n \
ensures: app.status = approved\n}\n\n\
rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
requires: app.status = screening\n ensures: app.status = rejected\n}\n",
);
assert!(has_finding(&r, "dead_transition"));
assert!(has_finding(&r, "missing_producer"));
}
#[test]
fn satisfied_requires_no_dead_transition() {
let r = analyse_src(
"entity App {\n status: submitted | screening | approved | rejected\n \
verified: Boolean\n \
transitions status {\n submitted -> screening\n screening -> approved\n \
screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
rule Verify {\n when: SystemVerifies(app, result)\n \
requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
requires:\n app.status = screening\n app.verified = true\n \
ensures: app.status = approved\n}\n\n\
rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
requires: app.status = screening\n ensures: app.status = rejected\n}\n",
);
assert!(!has_finding(&r, "dead_transition"));
assert!(!has_finding(&r, "missing_producer"));
}
#[test]
fn deadlock_detected() {
let r = analyse_src(
"entity Doc {\n status: submitted | review | approved | rejected\n \
reviewer_assigned: Boolean\n \
transitions status {\n submitted -> review\n review -> approved\n \
review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
rule Submit {\n when: AuthorSubmits(author, doc)\n \
requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
ensures: doc.status = approved\n}\n\n\
rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
ensures: doc.status = rejected\n}\n",
);
assert!(has_finding(&r, "deadlock"));
}
#[test]
fn no_deadlock_when_paths_open() {
let r = analyse_src(
"entity Invoice {\n status: draft | sent | paid | void\n \
transitions status {\n draft -> sent\n draft -> void\n \
sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
rule Send {\n when: AccountantSends(accountant, invoice)\n \
requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
rule Pay {\n when: PaymentReceived(invoice)\n \
requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
);
assert!(!has_finding(&r, "deadlock"));
}
#[test]
fn conflict_temporal_vs_external() {
let r = analyse_src(
"entity Membership {\n status: active | expired | extended\n \
expires_at: Timestamp\n \
transitions status {\n active -> expired\n active -> extended\n \
terminal: expired, extended\n }\n}\n\n\
rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
requires: m.status = active\n ensures: m.status = expired\n}\n\n\
rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
requires: membership.status = active\n ensures: membership.status = extended\n}\n",
);
assert!(has_finding(&r, "conflict"));
}
#[test]
fn conflict_detected_when_effect_nested_in_branch() {
let r = analyse_src(
"entity Membership {\n status: active | expired | extended\n \
expires_at: Timestamp\n \
transitions status {\n active -> expired\n active -> extended\n \
terminal: expired, extended\n }\n}\n\n\
rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
requires: m.status = active\n ensures: m.status = expired\n}\n\n\
rule ManualExtend {\n when: AdminExtends(admin, membership, flag)\n \
requires: membership.status = active\n \
if flag:\n ensures: membership.status = extended\n \
else:\n ensures: membership.status = extended\n}\n",
);
assert!(has_finding(&r, "conflict"));
}
#[test]
fn no_conflict_actor_choice() {
let r = analyse_src(
"entity LeaveRequest {\n status: pending | approved | denied\n \
transitions status {\n pending -> approved\n pending -> denied\n \
terminal: approved, denied\n }\n}\n\n\
rule Approve {\n when: ManagerApproves(manager, request)\n \
requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
rule Deny {\n when: ManagerDenies(manager, request)\n \
requires: request.status = pending\n ensures: request.status = denied\n}\n",
);
assert!(!has_finding(&r, "conflict"));
}
#[test]
fn invariant_violation_detected() {
let r = analyse_src(
"entity JobRole {\n status: open | filled\n \
candidacies: Candidacy with role = this\n \
transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
entity Candidacy {\n status: active | hired | rejected\n \
role: JobRole\n \
transitions status {\n active -> hired\n active -> rejected\n \
terminal: hired, rejected\n }\n}\n\n\
rule Hire {\n when: ManagerHires(manager, candidacy)\n \
requires: candidacy.status = active\n \
ensures: candidacy.status = hired\n}\n\n\
invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
);
assert!(has_finding(&r, "invariant_risk"));
}
#[test]
fn invariant_guarded_no_violation() {
let r = analyse_src(
"entity JobRole {\n status: open | filled\n \
candidacies: Candidacy with role = this\n \
transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
entity Candidacy {\n status: active | hired | rejected\n \
role: JobRole\n \
transitions status {\n active -> hired\n active -> rejected\n \
terminal: hired, rejected\n }\n}\n\n\
rule Hire {\n when: ManagerHires(manager, candidacy)\n \
requires:\n candidacy.status = active\n candidacy.role.status = open\n \
ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
);
assert!(!has_finding(&r, "invariant_risk"));
}
#[test]
fn external_entity_referenced_in_rules_info() {
let ds = analyze_src(
"external entity Client {\n id: String\n}\n\n\
rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
);
let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
assert!(hint.is_some());
assert_eq!(hint.unwrap().severity, Severity::Info);
}
#[test]
fn undefined_type_reference() {
let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
assert!(has_code(&ds, "allium.type.undefinedReference"));
}
#[test]
fn known_type_reference_ok() {
let ds = analyze_src("entity Foo {\n bar: String\n}\n");
assert!(!has_code(&ds, "allium.type.undefinedReference"));
}
#[test]
fn unreachable_trigger_reported() {
let ds = analyze_src(
"rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
}
fn analyze_with_imports(
src: &str,
imports: &[(&str, &[&str])],
) -> Vec<Diagnostic> {
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let imported: HashMap<String, HashSet<String>> = imports
.iter()
.map(|(alias, triggers)| {
(
alias.to_string(),
triggers.iter().map(|t| t.to_string()).collect(),
)
})
.collect();
analyze_with_cross_module(
&result.module,
&input,
&HashSet::new(),
&HashSet::new(),
&imported,
&HashMap::new(),
&AmbiguousImports::default(),
&ReverseContributions::default(),
&HashMap::new(),
)
}
fn analyze_with_ambiguous(
src: &str,
names: &[(&str, &[&str])],
triggers: &[(&str, &[&str])],
) -> Vec<Diagnostic> {
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let to_map = |entries: &[(&str, &[&str])]| -> HashMap<String, Vec<String>> {
entries
.iter()
.map(|(name, aliases)| {
(
name.to_string(),
aliases.iter().map(|a| a.to_string()).collect(),
)
})
.collect()
};
let ambiguous = AmbiguousImports {
names: to_map(names),
triggers: to_map(triggers),
};
let mut imported: HashMap<String, HashSet<String>> = HashMap::new();
for (trigger, aliases) in triggers {
for alias in *aliases {
imported
.entry(alias.to_string())
.or_default()
.insert(trigger.to_string());
}
}
analyze_with_cross_module(
&result.module,
&input,
&HashSet::new(),
&HashSet::new(),
&imported,
&HashMap::new(),
&ambiguous,
&ReverseContributions::default(),
&HashMap::new(),
)
}
#[test]
fn qualified_trigger_suppressed_in_single_file_mode() {
let ds = analyze_src(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
);
assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn qualified_trigger_reachable_via_imported_module() {
let ds = analyze_with_imports(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[("emitter", &["Pinged"])],
);
assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn qualified_trigger_unreachable_when_imported_module_lacks_it() {
let ds = analyze_with_imports(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[("emitter", &["SomethingElse"])],
);
let flagged: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
.collect();
assert_eq!(flagged.len(), 1);
assert!(flagged[0].message.contains("'emitter/Pinged'"));
assert!(flagged[0].message.contains("imported module 'emitter'"));
}
#[test]
fn qualified_trigger_suppressed_for_alias_outside_check_set() {
let ds = analyze_with_imports(
"use \"github.com/allium-specs/oauth/abc\" as oauth\n\nrule Audit {\n when: oauth/SessionCreated(session)\n ensures: Logged(session: session)\n}\n",
&[],
);
assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn unqualified_trigger_reachable_via_imported_module() {
let ds = analyze_with_imports(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[("emitter", &["Pinged"])],
);
assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn unqualified_trigger_still_flagged_when_no_import_emits_it() {
let ds = analyze_with_imports(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[("emitter", &["SomethingElse"])],
);
assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn ambiguous_trigger_subscription_warns() {
let ds = analyze_with_ambiguous(
"use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[],
&[("Pinged", &["a", "b"])],
);
let diag = ds
.iter()
.find(|d| d.code == Some("allium.use.ambiguousReference"))
.expect("ambiguous trigger subscription should warn");
assert!(diag.message.contains("'a' and 'b'"), "message: {}", diag.message);
assert!(diag.message.contains("a/Pinged"), "message: {}", diag.message);
assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
}
#[test]
fn ambiguous_trigger_not_flagged_when_emitted_locally() {
let ds = analyze_with_ambiguous(
"use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule Emit {\n when: Start(x)\n ensures: Pinged(subject: x)\n}\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[],
&[("Pinged", &["a", "b"])],
);
assert!(!has_code(&ds, "allium.use.ambiguousReference"));
}
#[test]
fn qualified_trigger_subscription_not_flagged_as_ambiguous() {
let ds = analyze_with_ambiguous(
"use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: a/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
&[],
&[("Pinged", &["a", "b"])],
);
assert!(!has_code(&ds, "allium.use.ambiguousReference"));
}
#[test]
fn ambiguous_name_reference_warns() {
let ds = analyze_with_ambiguous(
"use \"./orders.allium\" as orders\nuse \"./billing.allium\" as billing\n\nrule Process {\n when: OrderPlaced(order)\n ensures: Invoice.created(id: order.id)\n}\n",
&[("Invoice", &["billing", "orders"])],
&[],
);
let diag = ds
.iter()
.find(|d| d.code == Some("allium.use.ambiguousReference"))
.expect("ambiguous unqualified name should warn");
assert!(
diag.message.contains("'billing' and 'orders'"),
"message: {}",
diag.message
);
assert!(diag.message.contains("billing/Invoice"), "message: {}", diag.message);
}
#[test]
fn ambiguous_name_shadowed_by_local_declaration() {
let ds = analyze_with_ambiguous(
"use \"./orders.allium\" as orders\nuse \"./billing.allium\" as billing\n\nentity Invoice {\n id: String\n}\n\nrule Process {\n when: OrderPlaced(order)\n ensures: Invoice.created(id: order.id)\n}\n",
&[("Invoice", &["billing", "orders"])],
&[],
);
assert!(!has_code(&ds, "allium.use.ambiguousReference"));
}
#[test]
fn ambiguous_name_flagged_once_per_name() {
let ds = analyze_with_ambiguous(
"use \"./orders.allium\" as orders\nuse \"./billing.allium\" as billing\n\nrule Process {\n when: OrderPlaced(order)\n ensures: Invoice.created(id: order.id)\n}\n\nrule Audit {\n when: AuditRequested(req)\n ensures: Invoice.created(id: req.id)\n}\n",
&[("Invoice", &["billing", "orders"])],
&[],
);
let count = ds
.iter()
.filter(|d| d.code == Some("allium.use.ambiguousReference"))
.count();
assert_eq!(count, 1, "expected a single warning per ambiguous name");
}
#[test]
fn no_ambiguity_warnings_in_single_file_mode() {
let ds = analyze_src(
"use \"./orders.allium\" as orders\nuse \"./billing.allium\" as billing\n\nrule Process {\n when: OrderPlaced(order)\n ensures: Invoice.created(id: order.id)\n}\n",
);
assert!(!has_code(&ds, "allium.use.ambiguousReference"));
}
#[test]
fn conditional_ensures_emission_registers() {
let ds = analyze_src(
"rule AdvertRouted {\n when: AdvertReceived(envelope)\n ensures:\n if exists envelope:\n Logged(envelope: envelope)\n else:\n SensorAdvertDecoded(advert: envelope)\n}\n\nrule HandleDecoded {\n when: SensorAdvertDecoded(advert)\n ensures: Done(advert: advert)\n}\n\nrule HandleLogged {\n when: Logged(envelope)\n ensures: Done2(envelope: envelope)\n}\n",
);
let unreachable: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
.collect();
assert_eq!(unreachable.len(), 1);
assert!(unreachable[0].message.contains("'AdvertReceived'"));
}
#[test]
fn for_body_ensures_emission_registers() {
let ds = analyze_src(
"rule Fan {\n when: Broadcast(msg)\n ensures:\n for user in Users:\n Notified(user: user, msg: msg)\n}\n\nrule HandleNotified {\n when: Notified(user, msg)\n ensures: Done()\n}\n",
);
let unreachable: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
.collect();
assert_eq!(unreachable.len(), 1);
assert!(unreachable[0].message.contains("'Broadcast'"));
}
#[test]
fn collect_trigger_outputs_includes_provides_ensures_and_branches() {
let input = "-- allium: 3\nsurface S {\n provides:\n Submit(x)\n}\n\nrule R {\n when: Submit(x)\n ensures:\n if exists x:\n Accepted(x: x)\n else:\n Rejected(x: x)\n}\n";
let result = parse(input);
let outputs = collect_trigger_outputs(&result.module);
assert!(outputs.contains("Submit"));
assert!(outputs.contains("Accepted"));
assert!(outputs.contains("Rejected"));
}
#[test]
fn unused_field_reported() {
let ds = analyze_src("entity A {\n x: String\n y: String\n}\n\nrule R {\n when: Ping(a)\n ensures: a.x = \"hi\"\n}\n");
assert!(has_code(&ds, "allium.field.unused"));
let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
assert!(unused.iter().any(|d| d.message.contains("A.y")));
assert!(!unused.iter().any(|d| d.message.contains("A.x")));
}
#[test]
fn field_used_by_sibling_derived_field_not_unused() {
let ds = analyze_src("entity Widget {\n count: Integer\n is_positive: count > 0\n}\n");
let unused: Vec<_> =
ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
assert!(
!unused.iter().any(|d| d.message.contains("Widget.count")),
"count is referenced by is_positive and must not be flagged unused. Got: {:?}",
unused.iter().map(|d| &d.message).collect::<Vec<_>>()
);
assert!(
unused.iter().any(|d| d.message.contains("Widget.is_positive")),
"is_positive is unreferenced and should still warn. Got: {:?}",
unused.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn field_set_via_created_named_arg_not_unused() {
let ds = analyze_src(
"entity Widget {\n name: String\n}\n\nrule MakeWidget {\n when: MakeWidget(label)\n ensures: Widget.created(name: label)\n}\n",
);
let unused: Vec<_> =
ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
assert!(
!unused.iter().any(|d| d.message.contains("Widget.name")),
"name is set by Widget.created(name: ...) and must not be flagged unused. Got: {:?}",
unused.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn field_named_like_a_rule_binding_still_unused() {
let ds = analyze_src(
"entity Widget {\n order: String\n}\n\nrule R {\n when: Ping(order)\n ensures: Done()\n}\n",
);
let unused: Vec<_> =
ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
assert!(
unused.iter().any(|d| d.message.contains("Widget.order")),
"a rule binding named 'order' must not suppress the unused field 'Widget.order'. Got: {:?}",
unused.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
const DECIDE_BRANCH_SPEC: &str = "entity Widget {\n status: pending | approved | rejected\n transitions status {\n pending -> approved\n pending -> rejected\n terminal: approved, rejected\n }\n}\n\nrule Decide {\n when: Decide(widget, ok)\n requires: widget.status = pending\n if ok:\n ensures: widget.status = approved\n else:\n ensures: widget.status = rejected\n}\n";
#[test]
fn conditional_ensures_makes_branch_statuses_reachable() {
let ds = analyze_src(DECIDE_BRANCH_SPEC);
let unreachable: Vec<_> = ds
.iter()
.filter(|d| d.code == Some("allium.status.unreachableValue"))
.collect();
assert!(
!unreachable.iter().any(|d| d.message.contains("approved")),
"approved is assigned in the if-branch and must be reachable. Got: {:?}",
unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
);
assert!(
!unreachable.iter().any(|d| d.message.contains("rejected")),
"rejected is assigned in the else-branch and must be reachable. Got: {:?}",
unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
);
}
#[test]
fn conditional_ensures_transitions_are_witnessed_no_deadlock() {
let r = analyse_src(DECIDE_BRANCH_SPEC);
assert!(
!has_finding(&r, "deadlock"),
"branch-witnessed exits from pending must clear the deadlock. Findings: {:?}",
r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
);
}
const FOR_IN_PROVIDES_SPEC: &str = r#"external entity Person { name: String }
entity User {
person: Person
sessions: Session with user = this
}
entity Session {
user: User
status: active | ended
transitions status { active -> ended terminal: ended }
}
rule LogOut {
when: UserLogsOut(session)
requires: session.status = active
ensures: session.status = ended
}
surface AccountManagement {
facing person: Person
context user: User where person = person
exposes:
for session in user.sessions:
session.status
provides:
for session in user.sessions:
UserLogsOut(session)
}
"#;
#[test]
fn trigger_provided_in_for_block_is_reachable() {
let ds = analyze_src(FOR_IN_PROVIDES_SPEC);
assert!(
!ds.iter().any(|d| d.code == Some("allium.rule.unreachableTrigger")
&& d.message.contains("UserLogsOut")),
"UserLogsOut is provided inside a for-block and must be reachable. Got: {:?}",
ds.iter()
.filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
.map(|d| &d.message)
.collect::<Vec<_>>()
);
}
#[test]
fn trigger_provided_in_for_block_no_unreachable_finding() {
let r = analyse_src(FOR_IN_PROVIDES_SPEC);
assert!(
!has_finding(&r, "unreachable_trigger"),
"for-block-provided trigger must not yield an unreachable_trigger finding. Findings: {:?}",
r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
);
}
#[test]
fn unused_entity_reported() {
let ds = analyze_src("entity Orphan {\n x: String\n}\n");
assert!(has_code(&ds, "allium.entity.unused"));
}
#[test]
fn external_ref_suppresses_unused_entity() {
let src = "entity InputEvent {\n payload: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(!has_code(&ds, "allium.entity.unused"));
}
#[test]
fn external_ref_suppresses_unused_definition() {
let src = "value Snapshot {\n version: Integer\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(!has_code(&ds, "allium.definition.unused"));
}
#[test]
fn unreferenced_entity_still_warns_without_external_ref() {
let src = "entity InputEvent {\n payload: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(has_code(&ds, "allium.entity.unused"));
}
#[test]
fn collect_qualified_refs_from_rule_clause() {
let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
}
#[test]
fn collect_qualified_refs_from_field_type() {
let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
}
#[test]
fn collect_qualified_refs_from_requires() {
let src = "use \"./auth.allium\" as auth\n\nrule Guard {\n when: request: Request\n requires: request.token in auth/ValidTokens\n ensures: request.granted = true\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
}
#[test]
fn collect_qualified_refs_from_for_block() {
let src = "use \"./core.allium\" as core\n\nrule Batch {\n when: batch: Batch\n for item in core/ItemList:\n ensures: item.processed = true\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
}
#[test]
fn collect_qualified_refs_from_member_access() {
let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
}
#[test]
fn collect_qualified_refs_multiple_from_same_module() {
let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
}
#[test]
fn collect_qualified_refs_multiple_modules() {
let src = "use \"./core.allium\" as core\nuse \"./auth.allium\" as auth\n\nentity Handler {\n event: core/InputEvent\n session: auth/Session\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
}
#[test]
fn collect_qualified_refs_empty_when_none() {
let src = "entity Order {\n total: Decimal\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.is_empty());
}
#[test]
fn collect_qualified_refs_from_ensures() {
let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
}
#[test]
fn collect_qualified_refs_from_invariant() {
let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
}
#[test]
fn collect_qualified_refs_from_deferred() {
let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
}
#[test]
fn collect_qualified_refs_from_alias_dot_member() {
let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs = collect_qualified_references(&result.module);
assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
}
#[test]
fn collect_all_idents_includes_unqualified_entity_ref() {
let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let idents = collect_all_referenced_idents(&result.module);
assert!(idents.contains("InputPartition"));
}
#[test]
fn collect_declared_names_returns_entity_and_value_names() {
let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let names = collect_declared_names(&result.module);
assert!(names.contains("Order"));
assert!(names.contains("Money"));
assert!(names.contains("Status"));
}
#[test]
fn external_ref_only_suppresses_matching_name() {
let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
&& d.message.contains("Used")));
assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
&& d.message.contains("Orphan")));
}
#[test]
fn external_ref_suppresses_unused_external_entity() {
let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(!has_code(&ds, "allium.entity.unused"));
}
#[test]
fn external_ref_suppresses_unused_enum() {
let src = "enum Priority {\n low\n medium\n high\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
let ds = analyze_with_external_refs(&result.module, &input, &refs);
assert!(!has_code(&ds, "allium.definition.unused"));
}
#[test]
fn empty_external_refs_same_as_plain_analyze() {
let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let plain = analyze(&result.module, &input);
let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
assert_eq!(plain.len(), with_empty.len());
for (a, b) in plain.iter().zip(with_empty.iter()) {
assert_eq!(a.code, b.code);
assert_eq!(a.message, b.message);
}
}
#[test]
fn resolved_use_path_no_warning() {
let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
assert!(!has_code(&ds, "allium.use.unresolvedPath"));
}
#[test]
fn unresolved_use_path_warns() {
let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
assert!(has_code(&ds, "allium.use.unresolvedPath"));
}
#[test]
fn unresolved_use_path_skipped_in_single_file_mode() {
let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
assert!(!has_code(&ds, "allium.use.unresolvedPath"));
}
#[test]
fn unresolved_use_path_fires_with_empty_resolved_set() {
let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new(), &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
assert!(has_code(&ds, "allium.use.unresolvedPath"));
}
#[test]
fn unresolved_use_path_message_includes_path() {
let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
}
#[test]
fn unresolved_use_path_suppressible() {
let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
assert!(!has_code(&ds, "allium.use.unresolvedPath"));
}
#[test]
fn multiple_use_paths_mixed_resolution() {
let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
let input = format!("-- allium: 3\n{src}");
let result = parse(&input);
let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
let unresolved: Vec<_> = ds.iter()
.filter(|d| d.code == Some("allium.use.unresolvedPath"))
.collect();
assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
assert!(unresolved[0].message.contains("lost.allium"));
}
#[test]
fn deferred_missing_location_hint() {
let ds = analyze_src("deferred Foo.bar\n");
assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_with_quoted_path_hint_ok() {
let ds = analyze_src("deferred Foo.bar \"detailed/foo.allium\"\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_with_see_comment_hint_ok() {
let ds = analyze_src("deferred Foo.bar -- see: detailed/foo.allium\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_with_url_hint_ok() {
let ds = analyze_src("deferred Foo.bar -- https://example.com/foo.allium\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_with_url_glued_to_path_warns() {
assert!(has_code(
&analyze_src("deferred Foohttps://x\n"),
"allium.deferred.missingLocationHint"
));
assert!(has_code(
&analyze_src("deferred Foohttp://x\n"),
"allium.deferred.missingLocationHint"
));
}
#[test]
fn deferred_with_non_hint_comment_warns() {
let ds = analyze_src("deferred Foo.bar -- TODO write this\n");
assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_expression_path_with_quote_suppresses() {
let ds = analyze_src("deferred Foo(\"x\")\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
let ds = analyze_src("deferred Foo = \"x\"\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_trailing_dot_warns_with_captured_name() {
let ds = analyze_src("deferred Dangling.\n");
let hints: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
.collect();
assert_eq!(hints.len(), 1);
assert!(hints[0].message.contains("'Dangling.'"));
}
#[test]
fn deferred_lone_cr_is_a_line_boundary() {
let ds = analyze_src("deferred Foo\rdeferred Bar\n");
let hints: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
.collect();
assert_eq!(hints.len(), 2, "both CR-separated declarations warn");
let ds = analyze_src("deferred Foo\r-- see: x.allium\n");
assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_unmatchable_path_stays_silent() {
let ds = analyze_src("deferred (Foo)\n");
assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
}
#[test]
fn deferred_qualified_path_warns_with_flat_name() {
let ds = analyze_src("deferred billing/InvoiceWorkflow\n");
let hints: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
.collect();
assert_eq!(hints.len(), 1);
assert!(hints[0].message.contains("'billing'"));
}
#[test]
fn deferred_location_hint_is_per_line() {
let ds = analyze_src(
"deferred A.one -- see: a.allium\ndeferred B.two\ndeferred C.three \"c.allium\"\n",
);
let hints: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
.collect();
assert_eq!(hints.len(), 1);
assert!(hints[0].message.contains("B.two"));
}
#[test]
fn valid_trigger_ok() {
let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
}
#[test]
fn qualified_trigger_call_is_valid() {
let ds = analyze_src(
"use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
);
assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
}
#[test]
fn typed_trigger_param_reported_at_trigger() {
let ds = analyze_src(
"entity Account { name: String }\nentity Greeting { label: String }\n\nrule TypedParam {\n when: AccountSeen(account: Account)\n ensures: Greeting.created(label: account.name)\n}\n",
);
let invalid: Vec<&Diagnostic> = ds
.iter()
.filter(|d| d.code == Some("allium.rule.invalidTrigger"))
.collect();
assert_eq!(invalid.len(), 1, "exactly one invalidTrigger diagnostic");
assert!(invalid[0].message.contains("'account'"));
assert!(invalid[0].message.contains("bare names"));
assert!(
!has_code(&ds, "allium.rule.undefinedBinding"),
"typed trigger param must not also fire undefinedBinding on the body"
);
}
#[test]
fn untyped_trigger_param_ok() {
let ds = analyze_src(
"entity Account { name: String }\nentity Greeting { label: String }\n\nrule UntypedParam {\n when: AccountSeen(account)\n ensures: Greeting.created(label: account.name)\n}\n",
);
assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
}
#[test]
fn homogeneous_list_literal_ok() {
let ds = analyze_src("default E e = { items: [\"a\", \"b\", \"c\"] }");
assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
}
#[test]
fn empty_list_literal_ok() {
let ds = analyze_src("default E e = { items: [] }");
assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
}
#[test]
fn heterogeneous_list_literal_flagged() {
let ds = analyze_src("default E e = { items: [\"a\", 5] }");
assert!(has_code(&ds, "allium.list.mixedElementTypes"));
}
#[test]
fn list_literal_of_identifiers_not_flagged() {
let ds = analyze_src("default E e = { items: [foo, bar] }");
assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
}
#[test]
fn qualified_default_known_alias_ok() {
let ds = analyze_src(
"use \"./p.allium\" as gp\n\ndefault gp/Policy my_policy = { id: \"x\" }",
);
assert!(!has_code(&ds, "allium.reference.undefinedImportedAlias"));
}
#[test]
fn qualified_default_unknown_alias_flagged() {
let ds = analyze_src("default zz/Policy my_policy = { id: \"x\" }");
assert!(has_code(&ds, "allium.reference.undefinedImportedAlias"));
}
#[test]
fn default_unknown_field_flagged() {
let ds = analyze_src(
"entity Policy { id: String }\ndefault Policy p = { id: \"x\", naem: \"typo\" }",
);
assert!(has_code(&ds, "allium.default.unknownField"));
}
#[test]
fn default_known_fields_ok() {
let ds = analyze_src(
"entity Policy { id: String\n label: String }\ndefault Policy p = { id: \"x\", label: \"y\" }",
);
assert!(!has_code(&ds, "allium.default.unknownField"));
}
#[test]
fn default_nested_object_unknown_field_flagged() {
let ds = analyze_src(
"value Predicate { clause_order: List<String> }\nentity Policy { id: String\n predicate: Predicate }\ndefault Policy p = { id: \"x\", predicate: { bogus: 5 } }",
);
assert!(has_code(&ds, "allium.default.unknownField"));
}
#[test]
fn empty_list_in_list_field_ok() {
let ds = analyze_src(
"entity E { tags: List<String> }\ndefault E e = { tags: [] }",
);
assert!(!has_code(&ds, "allium.list.emptyListNoElementType"));
}
#[test]
fn empty_list_in_non_list_field_flagged() {
let ds = analyze_src(
"entity E { id: String }\ndefault E e = { id: [] }",
);
assert!(has_code(&ds, "allium.list.emptyListNoElementType"));
}
#[test]
fn qualified_default_fields_not_validated() {
let ds = analyze_src(
"use \"./p.allium\" as gp\n\ndefault gp/Policy p = { anything: 1, goes: 2 }",
);
assert!(!has_code(&ds, "allium.default.unknownField"));
}
#[test]
fn duplicate_let_binding() {
let ds = analyze_src(
"rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.let.duplicateBinding"));
}
#[test]
fn config_undefined_reference() {
let ds = analyze_src(
"config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
);
assert!(has_code(&ds, "allium.config.undefinedReference"));
}
#[test]
fn config_valid_reference_ok() {
let ds = analyze_src(
"config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
);
assert!(!has_code(&ds, "allium.config.undefinedReference"));
}
fn module_of(src: &str) -> Module {
parse(&format!("-- allium: 3\n{src}")).module
}
#[test]
fn reverse_contributions_credit_qualified_creation() {
let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("open")));
assert!(rc.witnessed_transitions.is_empty());
assert!(rc.provided_triggers.is_empty());
}
#[test]
fn reverse_contributions_credit_qualified_provides() {
let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
let importer = module_of(
"use \"./t.allium\" as tickets\nsurface Intake {\n provides:\n tickets/OpenTicket()\n tickets/CloseTicket(ticket)\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc.provided_triggers.contains("OpenTicket"));
assert!(rc.provided_triggers.contains("CloseTicket"));
}
#[test]
fn reverse_contributions_credit_witnessed_transition() {
let imported = module_of(
"entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n\nsurface Desk {\n provides:\n ArchiveTicketRequested(ticket: Ticket)\n when ticket.status = closed\n}\n",
);
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Archive {\n when: tickets/ArchiveTicketRequested(ticket)\n requires: ticket.status = closed\n ensures: ticket.status = archived\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc
.witnessed_transitions
.get("Ticket")
.is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
}
#[test]
fn reverse_contributions_credit_importer_owned_trigger_via_qualified_context() {
let imported = module_of(
"entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
);
let importer = module_of(
"use \"./t.allium\" as tickets\nsurface Desk {\n context t: tickets/Ticket\n provides:\n ArchiveTicketRequested(t)\n when t.status = closed\n}\nrule Archive {\n when: ArchiveTicketRequested(ticket)\n requires: ticket.status = closed\n ensures: ticket.status = archived\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(
rc.witnessed_transitions
.get("Ticket")
.is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))),
"importer-owned trigger typed via qualified context must witness the transition. Got: {:?}",
rc.witnessed_transitions
);
assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
}
#[test]
fn reverse_contributions_credit_becomes_transition_trigger_witness() {
let imported = module_of(
"entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
);
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Archive {\n when: t: tickets/Ticket.status becomes closed\n ensures: t.status = archived\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc
.witnessed_transitions
.get("Ticket")
.is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
}
#[test]
fn reverse_contributions_require_the_matching_alias() {
let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
);
let rc = collect_reverse_contributions(&importer, "other", &imported);
assert!(rc.is_empty());
}
#[test]
fn reverse_contributions_credit_qualified_field_reference() {
let imported =
module_of("entity Ticket {\n status: open | closed\n due_at: Timestamp\n}\n");
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Sweep {\n when: t: tickets/Ticket.due_at <= now\n requires: t.status = open\n ensures: t.status = closed\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc.referenced_fields.contains("due_at"));
let other = collect_reverse_contributions(&importer, "other", &imported);
assert!(!other.referenced_fields.contains("due_at"));
}
#[test]
fn reverse_contributions_filter_undeclared_status() {
let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
let importer = module_of(
"use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: pending)\n}\n",
);
let rc = collect_reverse_contributions(&importer, "tickets", &imported);
assert!(rc.is_empty());
}
}