1use std::collections::{HashMap, HashSet};
9
10use crate::ast::*;
11use crate::diagnostic::{Diagnostic, Finding};
12use crate::lexer::SourceMap;
13use crate::Span;
14
15pub fn analyze(module: &Module, source: &str) -> Vec<Diagnostic> {
18 let empty = HashSet::new();
19 analyze_with_external_refs(module, source, &empty)
20}
21
22pub fn analyze_with_external_refs(
28 module: &Module,
29 source: &str,
30 external_refs: &HashSet<String>,
31) -> Vec<Diagnostic> {
32 run_checks(Ctx::new(module, external_refs, None, None, None), source)
33}
34
35#[derive(Debug, Default)]
42pub struct AmbiguousImports {
43 pub names: HashMap<String, Vec<String>>,
45 pub triggers: HashMap<String, Vec<String>>,
47}
48
49#[derive(Debug, Default, Clone)]
58pub struct ReverseContributions {
59 pub provided_triggers: HashSet<String>,
62 pub assigned_statuses: HashMap<String, HashSet<String>>,
66 pub witnessed_transitions: HashMap<String, HashSet<(String, String)>>,
70}
71
72impl ReverseContributions {
73 pub fn is_empty(&self) -> bool {
75 self.provided_triggers.is_empty()
76 && self.assigned_statuses.is_empty()
77 && self.witnessed_transitions.is_empty()
78 }
79
80 pub fn merge(&mut self, other: ReverseContributions) {
82 self.provided_triggers.extend(other.provided_triggers);
83 for (entity, statuses) in other.assigned_statuses {
84 self.assigned_statuses.entry(entity).or_default().extend(statuses);
85 }
86 for (entity, edges) in other.witnessed_transitions {
87 self.witnessed_transitions.entry(entity).or_default().extend(edges);
88 }
89 }
90}
91
92#[allow(clippy::too_many_arguments)] pub fn analyze_with_cross_module(
106 module: &Module,
107 source: &str,
108 external_refs: &HashSet<String>,
109 resolved_use_paths: &HashSet<String>,
110 imported_triggers: &HashMap<String, HashSet<String>>,
111 imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
112 ambiguous_imports: &AmbiguousImports,
113 reverse: &ReverseContributions,
114) -> Vec<Diagnostic> {
115 let mut ctx = Ctx::new(
116 module,
117 external_refs,
118 Some(resolved_use_paths),
119 Some(imported_triggers),
120 Some(ambiguous_imports),
121 );
122 ctx.imported_entity_fields = Some(imported_entity_fields);
123 ctx.reverse_contributions = Some(reverse);
124 run_checks(ctx, source)
125}
126
127fn run_checks(mut ctx: Ctx<'_>, source: &str) -> Vec<Diagnostic> {
128 ctx.check_related_surface_references();
129 ctx.check_discriminator_variants();
130 ctx.check_surface_binding_usage();
131 ctx.check_status_state_machine();
132 ctx.check_external_entity_source_hints();
133 ctx.check_type_references();
134 ctx.check_unreachable_triggers();
135 ctx.check_unused_fields();
136 ctx.check_unused_entities();
137 ctx.check_unused_definitions();
138 ctx.check_unresolved_use_paths();
139 ctx.check_ambiguous_imported_names();
140 ctx.check_deferred_location_hints(source);
141 ctx.check_rule_invalid_triggers();
142 ctx.check_rule_undefined_bindings();
143 ctx.check_duplicate_let_bindings();
144 ctx.check_config_undefined_references();
145 ctx.check_list_literal_homogeneity();
146 ctx.check_qualified_default_aliases();
147 ctx.check_default_field_schemas();
148
149 apply_suppressions(ctx.diagnostics, source)
150}
151
152pub fn analyse(module: &Module, source: &str) -> crate::diagnostic::AnalyseResult {
155 let empty = HashSet::new();
156 analyse_with_external_refs(module, source, &empty)
157}
158
159pub fn analyse_with_external_refs(
162 module: &Module,
163 source: &str,
164 external_refs: &HashSet<String>,
165) -> crate::diagnostic::AnalyseResult {
166 let diagnostics = analyze_with_external_refs(module, source, external_refs);
167 let findings = find_process_issues(module, None, None);
168 crate::diagnostic::AnalyseResult {
169 diagnostics,
170 findings,
171 }
172}
173
174#[allow(clippy::too_many_arguments)] pub fn analyse_with_cross_module(
178 module: &Module,
179 source: &str,
180 external_refs: &HashSet<String>,
181 resolved_use_paths: &HashSet<String>,
182 imported_triggers: &HashMap<String, HashSet<String>>,
183 imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
184 ambiguous_imports: &AmbiguousImports,
185 reverse: &ReverseContributions,
186) -> crate::diagnostic::AnalyseResult {
187 let diagnostics = analyze_with_cross_module(
188 module,
189 source,
190 external_refs,
191 resolved_use_paths,
192 imported_triggers,
193 imported_entity_fields,
194 ambiguous_imports,
195 reverse,
196 );
197 let findings = find_process_issues(module, Some(imported_triggers), Some(reverse));
198 crate::diagnostic::AnalyseResult {
199 diagnostics,
200 findings,
201 }
202}
203
204struct EntityInfo<'a> {
206 status_values: HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
208 field_types: HashMap<&'a str, HashMap<&'a str, &'a str>>,
210 graph_edges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
212 terminals: HashMap<&'a str, HashSet<&'a str>>,
214}
215
216impl<'a> EntityInfo<'a> {
217 fn from_module(module: &'a Module) -> Self {
218 let mut status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = HashMap::new();
219 let mut field_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
220 let mut graph_edges: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
221 let mut terminals: HashMap<&str, HashSet<&str>> = HashMap::new();
222
223 let entities = module.declarations.iter().filter_map(|d| match d {
224 Decl::Block(b) if b.kind == BlockKind::Entity => Some(b),
225 _ => None,
226 });
227 for entity in entities {
228 let name = match &entity.name {
229 Some(n) => n.name.as_str(),
230 None => continue,
231 };
232 for item in &entity.items {
233 match &item.kind {
234 BlockItemKind::Assignment { name: f, value } if f.name == "status" => {
235 let mut idents = Vec::new();
236 collect_pipe_idents(value, &mut idents);
237 if idents.len() >= 2
238 && !idents.iter().any(|id| starts_uppercase(&id.name))
239 {
240 let set: HashSet<&str> =
241 idents.iter().map(|id| id.name.as_str()).collect();
242 status_values.insert(name, (set, idents));
243 }
244 }
245 BlockItemKind::Assignment { name: f, value } => {
246 if let Some(t) = extract_field_entity_type(value) {
247 field_types.entry(name).or_default().insert(f.name.as_str(), t);
248 }
249 }
250 BlockItemKind::TransitionsBlock(graph) => {
251 let edges: Vec<(&str, &str)> = graph
252 .edges
253 .iter()
254 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
255 .collect();
256 graph_edges.insert(name, edges);
257 let terms: HashSet<&str> =
258 graph.terminal.iter().map(|t| t.name.as_str()).collect();
259 if !terms.is_empty() {
260 terminals.insert(name, terms);
261 }
262 }
263 _ => {}
264 }
265 }
266 }
267
268 Self { status_values, field_types, graph_edges, terminals }
269 }
270
271 fn status_by_entity(&self) -> HashMap<&'a str, HashSet<&'a str>> {
273 self.status_values
274 .iter()
275 .map(|(k, (set, _))| (*k, set.clone()))
276 .collect()
277 }
278}
279
280fn find_process_issues(
282 module: &Module,
283 imported_triggers: Option<&HashMap<String, HashSet<String>>>,
284 reverse: Option<&ReverseContributions>,
285) -> Vec<crate::diagnostic::Finding> {
286 let empty = HashSet::new();
287 let mut ctx = Ctx::new(module, &empty, None, imported_triggers, None);
288 ctx.reverse_contributions = reverse;
289 let info = EntityInfo::from_module(module);
290 ctx.collect_process_findings(&info);
291 ctx.collect_conflict_findings(&info);
292 ctx.collect_invariant_findings(&info);
293 std::mem::take(&mut ctx.findings)
294}
295
296fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
301 if diagnostics.is_empty() {
302 return diagnostics;
303 }
304 let sm = SourceMap::new(source);
305 let directives = collect_suppression_directives(source, &sm);
306 if directives.is_empty() {
307 return diagnostics;
308 }
309 diagnostics
310 .into_iter()
311 .filter(|d| {
312 let (line, _) = sm.line_col(d.span.start);
313 let line = line as i64;
314 let active = directives
315 .get(&(line as u32))
316 .or_else(|| directives.get(&((line - 1).max(0) as u32)));
317 match (active, d.code) {
318 (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
319 (Some(codes), None) => !codes.contains("all"),
320 _ => true,
321 }
322 })
323 .collect()
324}
325
326fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
327 let mut directives = HashMap::new();
328 let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
329 for m in pattern.find_iter(source) {
330 let text = m.as_str();
331 let (line, _) = sm.line_col(m.start());
332 if let Some(idx) = text.find("allium-ignore") {
334 let offset = m.start() + idx + "allium-ignore".len();
335 let source_after = &source[offset..m.end()];
336 let codes: HashSet<&'a str> = source_after
337 .split(',')
338 .map(|c| c.trim())
339 .filter(|c| !c.is_empty())
340 .collect();
341 directives.insert(line, codes);
342 }
343 }
344 directives
345}
346
347struct Ctx<'a> {
352 module: &'a Module,
353 external_refs: &'a HashSet<String>,
354 resolved_use_paths: Option<&'a HashSet<String>>,
358 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
363 ambiguous_imports: Option<&'a AmbiguousImports>,
367 imported_entity_fields: Option<&'a HashMap<String, HashMap<String, HashSet<String>>>>,
372 reverse_contributions: Option<&'a ReverseContributions>,
376 diagnostics: Vec<Diagnostic>,
377 findings: Vec<crate::diagnostic::Finding>,
378}
379
380impl<'a> Ctx<'a> {
381 fn new(
382 module: &'a Module,
383 external_refs: &'a HashSet<String>,
384 resolved_use_paths: Option<&'a HashSet<String>>,
385 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
386 ambiguous_imports: Option<&'a AmbiguousImports>,
387 ) -> Self {
388 Self {
389 module,
390 external_refs,
391 resolved_use_paths,
392 imported_triggers,
393 ambiguous_imports,
394 imported_entity_fields: None,
395 reverse_contributions: None,
396 diagnostics: Vec::new(),
397 findings: Vec::new(),
398 }
399 }
400
401 fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
402 self.module.declarations.iter().filter_map(move |d| match d {
403 Decl::Block(b) if b.kind == kind => Some(b),
404 _ => None,
405 })
406 }
407
408 fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
409 self.module
410 .declarations
411 .iter()
412 .filter_map(|d| match d {
413 Decl::Variant(v) => Some(v),
414 _ => None,
415 })
416 }
417
418 fn has_use_imports(&self) -> bool {
419 self.module
420 .declarations
421 .iter()
422 .any(|d| matches!(d, Decl::Use(_)))
423 }
424
425 fn push(&mut self, d: Diagnostic) {
426 self.diagnostics.push(d);
427 }
428
429 fn push_finding(&mut self, finding: Finding) {
430 self.findings.push(finding);
431 }
432
433 fn declared_type_names(&self) -> HashSet<&'a str> {
436 let mut names = HashSet::new();
437 for d in &self.module.declarations {
438 match d {
439 Decl::Block(b) => {
440 if matches!(
441 b.kind,
442 BlockKind::Entity
443 | BlockKind::ExternalEntity
444 | BlockKind::Value
445 | BlockKind::Enum
446 | BlockKind::Actor
447 ) {
448 if let Some(n) = &b.name {
449 names.insert(n.name.as_str());
450 }
451 }
452 }
453 Decl::Variant(v) => {
454 names.insert(v.name.name.as_str());
455 }
456 _ => {}
457 }
458 }
459 for t in &[
461 "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
462 "List", "Set", "Map", "Any", "Void",
463 ] {
464 names.insert(t);
465 }
466 for d in &self.module.declarations {
468 if let Decl::Use(u) = d {
469 if let Some(alias) = &u.alias {
470 names.insert(alias.name.as_str());
471 }
472 }
473 }
474 names
475 }
476
477 fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
479 let mut names = HashSet::new();
480 for d in &self.module.declarations {
481 match d {
482 Decl::Block(b) => {
483 for item in &b.items {
484 collect_accessed_fields_from_item(&item.kind, &mut names);
485 }
486 if matches!(b.kind, BlockKind::Entity | BlockKind::ExternalEntity) {
492 let field_names: HashSet<&str> = b
493 .items
494 .iter()
495 .filter_map(|it| match &it.kind {
496 BlockItemKind::Assignment { name, .. }
497 | BlockItemKind::FieldWithWhen { name, .. } => {
498 Some(name.name.as_str())
499 }
500 _ => None,
501 })
502 .collect();
503 let mut idents = HashSet::new();
504 for item in &b.items {
505 collect_idents_from_item(&item.kind, &mut idents);
506 }
507 names.extend(idents.intersection(&field_names).copied());
508 }
509 }
510 Decl::Invariant(inv) => {
511 collect_accessed_fields_from_expr(&inv.body, &mut names);
512 }
513 _ => {}
514 }
515 }
516 names
517 }
518}
519
520impl Ctx<'_> {
525 fn check_related_surface_references(&mut self) {
526 let surface_names: HashSet<&str> = self
527 .blocks(BlockKind::Surface)
528 .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
529 .collect();
530
531 for surface in self.blocks(BlockKind::Surface) {
532 let surface_name = match &surface.name {
533 Some(n) => &n.name,
534 None => continue,
535 };
536
537 for item in &surface.items {
538 let BlockItemKind::Clause { keyword, value } = &item.kind else {
539 continue;
540 };
541 if keyword != "related" {
542 continue;
543 }
544
545 let refs = extract_related_surface_names(value);
546 for ident in refs {
547 if !surface_names.contains(ident.name.as_str()) {
548 self.push(
549 Diagnostic::error(
550 ident.span,
551 format!(
552 "Surface '{surface_name}' references unknown related surface '{}'.",
553 ident.name
554 ),
555 )
556 .with_code("allium.surface.relatedUndefined"),
557 );
558 }
559 }
560 }
561 }
562 }
563}
564
565fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
566 match expr {
567 Expr::Ident(id) => vec![id],
568 Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
569 Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
570 Expr::Block { items, .. } => items
571 .iter()
572 .flat_map(extract_related_surface_names)
573 .collect(),
574 _ => vec![],
575 }
576}
577
578fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
579 match expr {
580 Expr::Ident(id) => Some(id),
581 Expr::MemberAccess { object, .. } => extract_leading_ident(object),
582 _ => None,
583 }
584}
585
586impl Ctx<'_> {
591 fn check_discriminator_variants(&mut self) {
592 let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
593 for v in self.variants() {
594 let base_name = expr_as_ident(&v.base).or_else(|| {
595 if let Expr::JoinLookup { entity, .. } = &v.base {
597 expr_as_ident(entity)
598 } else {
599 None
600 }
601 });
602 if let Some(base_name) = base_name {
603 variants_by_base
604 .entry(base_name)
605 .or_default()
606 .insert(&v.name.name);
607 }
608 }
609
610 for entity in self.blocks(BlockKind::Entity) {
611 let entity_name = match &entity.name {
612 Some(n) => &n.name,
613 None => continue,
614 };
615
616 for item in &entity.items {
617 let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
618 continue;
619 };
620
621 let mut pipe_idents = Vec::new();
622 collect_pipe_idents(value, &mut pipe_idents);
623 if pipe_idents.len() < 2 {
624 continue;
625 }
626
627 let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
628 if !has_capitalised {
629 continue;
630 }
631
632 let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
633 if !all_capitalised {
634 self.push(
635 Diagnostic::error(
636 value.span(),
637 format!(
638 "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
639 field_name.name
640 ),
641 )
642 .with_code("allium.sum.invalidDiscriminator"),
643 );
644 continue;
645 }
646
647 let declared = variants_by_base
648 .get(entity_name.as_str())
649 .cloned()
650 .unwrap_or_default();
651
652 let missing: Vec<&&Ident> = pipe_idents
653 .iter()
654 .filter(|id| !declared.contains(id.name.as_str()))
655 .collect();
656
657 if missing.len() == pipe_idents.len() && declared.is_empty() {
658 self.push(
659 Diagnostic::error(
660 value.span(),
661 format!(
662 "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
663 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
664 declarations. Use lowercase values for a plain enum.",
665 field_name.name
666 ),
667 )
668 .with_code("allium.sum.v1InlineEnum"),
669 );
670 } else {
671 for id in missing {
672 self.push(
673 Diagnostic::error(
674 id.span,
675 format!(
676 "Entity '{entity_name}' discriminator references '{}' without matching \
677 'variant {} : {entity_name}'.",
678 id.name, id.name
679 ),
680 )
681 .with_code("allium.sum.discriminatorUnknownVariant"),
682 );
683 }
684 }
685 }
686 }
687 }
688}
689
690fn starts_uppercase(s: &str) -> bool {
691 s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
692}
693
694fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
695 match expr {
696 Expr::Ident(id) => out.push(id),
697 Expr::Pipe { left, right, .. } => {
698 collect_pipe_idents(left, out);
699 collect_pipe_idents(right, out);
700 }
701 _ => {}
702 }
703}
704
705fn expr_as_ident(expr: &Expr) -> Option<&str> {
706 match expr {
707 Expr::Ident(id) => Some(&id.name),
708 _ => None,
709 }
710}
711
712impl Ctx<'_> {
717 fn check_surface_binding_usage(&mut self) {
718 for surface in self.blocks(BlockKind::Surface) {
719 let surface_name = match &surface.name {
720 Some(n) => &n.name,
721 None => continue,
722 };
723
724 let has_provides = surface
726 .items
727 .iter()
728 .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
729
730 let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
732 let BlockItemKind::Clause { keyword, value } = &item.kind else {
733 continue;
734 };
735 if keyword != "facing" && keyword != "context" {
736 continue;
737 }
738 if let Expr::Binding { name, .. } = value {
739 bindings.push((&name.name, name.span, keyword == "facing"));
740 }
741 }
742
743 for (name, span, is_facing) in &bindings {
744 if *name == "_" {
745 continue;
746 }
747 if *is_facing && !has_provides {
749 continue;
750 }
751 let used = surface.items.iter().any(|item| {
752 let BlockItemKind::Clause { keyword, value } = &item.kind else {
753 return item_contains_ident(&item.kind, name);
754 };
755 if keyword == "facing" || keyword == "context" {
756 if let Expr::Binding {
757 name: binding_name, ..
758 } = value
759 {
760 if binding_name.name == *name {
761 return false;
762 }
763 }
764 }
765 expr_contains_ident(value, name)
766 });
767
768 if !used {
769 self.push(
770 Diagnostic::warning(
771 *span,
772 format!(
773 "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
774 ),
775 )
776 .with_code("allium.surface.unusedBinding"),
777 );
778 }
779 }
780 }
781 }
782}
783
784fn for_each_rule_clause<'a>(items: &'a [BlockItem], f: &mut impl FnMut(&'a str, &'a Expr)) {
792 for item in items {
793 match &item.kind {
794 BlockItemKind::Clause { keyword, value } => f(keyword, value),
795 BlockItemKind::IfBlock { branches, else_items } => {
796 for b in branches {
797 for_each_rule_clause(&b.items, f);
798 }
799 if let Some(else_items) = else_items {
800 for_each_rule_clause(else_items, f);
801 }
802 }
803 BlockItemKind::ForBlock { items, .. } => {
804 for_each_rule_clause(items, f);
805 }
806 _ => {}
807 }
808 }
809}
810
811impl Ctx<'_> {
812 fn check_status_state_machine(&mut self) {
813 let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
814 let mut terminal_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
815 let mut has_transitions: HashSet<&str> = HashSet::new();
816 let mut declared_edges: HashMap<&str, HashSet<(&str, &str)>> = HashMap::new();
817 let mut field_entity_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
818 for entity in self.blocks(BlockKind::Entity) {
819 let entity_name = match &entity.name {
820 Some(n) => n.name.as_str(),
821 None => continue,
822 };
823 for item in &entity.items {
824 match &item.kind {
825 BlockItemKind::Assignment { name, value } if name.name == "status" => {
826 let mut idents = Vec::new();
827 collect_pipe_idents(value, &mut idents);
828 if idents.len() < 2 {
829 continue;
830 }
831 if idents.iter().any(|id| starts_uppercase(&id.name)) {
832 continue;
833 }
834 let set: HashSet<&str> =
835 idents.iter().map(|id| id.name.as_str()).collect();
836 status_by_entity.insert(entity_name, (idents, set));
837 }
838 BlockItemKind::Assignment { name, value } => {
839 if let Some(type_name) = extract_field_entity_type(value) {
841 field_entity_types
842 .entry(entity_name)
843 .or_default()
844 .insert(name.name.as_str(), type_name);
845 }
846 }
847 BlockItemKind::TransitionsBlock(graph) => {
848 has_transitions.insert(entity_name);
849 let terminals: HashSet<&str> =
850 graph.terminal.iter().map(|t| t.name.as_str()).collect();
851 if !terminals.is_empty() {
852 terminal_by_entity.insert(entity_name, terminals);
853 }
854 let edges: HashSet<(&str, &str)> = graph
855 .edges
856 .iter()
857 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
858 .collect();
859 declared_edges.insert(entity_name, edges);
860 }
861 _ => {}
862 }
863 }
864 }
865 for fields in field_entity_types.values_mut() {
867 fields.retain(|_, type_name| status_by_entity.contains_key(type_name));
868 }
869
870 if status_by_entity.is_empty() {
871 return;
872 }
873
874 let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
879 for surface in self.blocks(BlockKind::Surface) {
880 for item in &surface.items {
881 let BlockItemKind::Clause { keyword, value } = &item.kind else {
882 continue;
883 };
884 if keyword == "provides" {
885 collect_command_param_types(value, &status_by_entity, &mut command_param_types);
886 }
887 }
888 }
889
890 let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
891 let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
892 HashMap::new();
893 let mut created_issues: Vec<Diagnostic> = Vec::new();
894
895 for rule in self.blocks(BlockKind::Rule) {
896 let mut binding_types = collect_rule_binding_types(rule, &status_by_entity);
897 augment_binding_types_from_commands(rule, &command_param_types, &mut binding_types);
898 let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
899
900 for_each_rule_clause(&rule.items, &mut |keyword, value| {
901 if keyword != "requires" {
902 return;
903 }
904 visit_status_comparisons(
905 value,
906 &binding_types,
907 &status_by_entity,
908 &field_entity_types,
909 &mut |binding, status| {
910 requires_by_binding
911 .entry(binding)
912 .or_default()
913 .insert(status);
914 },
915 );
916 });
917
918 for_each_rule_clause(&rule.items, &mut |keyword, value| {
919 if keyword != "ensures" {
920 return;
921 }
922 visit_status_assignments(
923 value,
924 &binding_types,
925 &status_by_entity,
926 &field_entity_types,
927 &mut |binding, target, entity| {
928 assigned_by_entity
929 .entry(entity)
930 .or_default()
931 .insert(target);
932
933 if let Some(sources) = requires_by_binding.get(binding) {
934 let entity_transitions =
935 transitions_by_entity.entry(entity).or_default();
936 for source in sources {
937 entity_transitions
938 .entry(source)
939 .or_default()
940 .insert(target);
941 }
942 }
943 },
944 );
945 visit_created_calls(
946 value,
947 &status_by_entity,
948 &has_transitions,
949 &mut |entity, status| {
950 assigned_by_entity
951 .entry(entity)
952 .or_default()
953 .insert(status);
954 },
955 &mut created_issues,
956 );
957 });
958 }
959
960 if let Some(rev) = self.reverse_contributions {
965 for (entity, statuses) in &rev.assigned_statuses {
966 if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
967 let set = assigned_by_entity.entry(*key).or_default();
968 for s in statuses {
969 if values.contains(s.as_str()) {
970 set.insert(s.as_str());
971 }
972 }
973 }
974 }
975 for (entity, edges) in &rev.witnessed_transitions {
976 if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
977 for (from, to) in edges {
978 if values.contains(from.as_str()) && values.contains(to.as_str()) {
979 transitions_by_entity
980 .entry(*key)
981 .or_default()
982 .entry(from.as_str())
983 .or_default()
984 .insert(to.as_str());
985 assigned_by_entity.entry(*key).or_default().insert(to.as_str());
986 }
987 }
988 }
989 }
990 }
991
992 for (entity_name, (idents, values)) in &status_by_entity {
993 let assigned = assigned_by_entity.get(entity_name);
994 let transitions = transitions_by_entity.get(entity_name);
995
996 if let Some(assigned) = assigned {
997 if assigned.iter().any(|v| !values.contains(v)) {
998 continue;
999 }
1000 }
1001
1002 let assigned_set = assigned.cloned().unwrap_or_default();
1003 let transition_map = transitions.cloned().unwrap_or_default();
1004
1005 for id in idents {
1006 if !assigned_set.contains(id.name.as_str()) {
1007 self.push(
1008 Diagnostic::warning(
1009 id.span,
1010 format!(
1011 "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
1012 id.name
1013 ),
1014 )
1015 .with_code("allium.status.unreachableValue"),
1016 );
1017 }
1018
1019 let is_terminal = terminal_by_entity
1020 .get(entity_name)
1021 .map_or_else(
1022 || is_likely_terminal(&id.name),
1023 |terminals| terminals.contains(id.name.as_str()),
1024 );
1025 if is_terminal {
1026 continue;
1027 }
1028 let exits = transition_map.get(id.name.as_str());
1029 if exits.is_some_and(|e| !e.is_empty()) {
1030 continue;
1031 }
1032 self.push(
1033 Diagnostic::warning(
1034 id.span,
1035 format!(
1036 "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
1037 id.name
1038 ),
1039 )
1040 .with_code("allium.status.noExit"),
1041 );
1042 }
1043 }
1044
1045 for (entity_name, transition_map) in &transitions_by_entity {
1047 if let Some(edges) = declared_edges.get(entity_name) {
1048 if let Some((idents, _)) = status_by_entity.get(entity_name) {
1049 for (from, targets) in transition_map {
1050 for to in targets {
1051 if from != to && !edges.contains(&(*from, *to)) {
1052 let span = idents
1054 .iter()
1055 .find(|id| id.name == *from)
1056 .map(|id| id.span)
1057 .unwrap_or(idents[0].span);
1058 self.push(
1059 Diagnostic::warning(
1060 span,
1061 format!(
1062 "Rule produces transition '{from}' → '{to}' on entity '{entity_name}', but this edge is not in the declared transition graph.",
1063 ),
1064 )
1065 .with_code("allium.status.undeclaredTransition"),
1066 );
1067 }
1068 }
1069 }
1070 }
1071 }
1072 }
1073
1074 for issue in created_issues {
1075 self.push(issue);
1076 }
1077 }
1078}
1079
1080impl Ctx<'_> {
1085 fn collect_process_findings(&mut self, info: &EntityInfo<'_>) {
1086 let status_values = &info.status_values;
1087 let field_types = &info.field_types;
1088 let graph_edges = &info.graph_edges;
1089 let terminals = &info.terminals;
1090
1091 if status_values.is_empty() {
1092 return;
1093 }
1094
1095 let mut surface_triggers: HashSet<&str> = HashSet::new();
1097 let mut surface_names: Vec<String> = Vec::new();
1098 for surface in self.blocks(BlockKind::Surface) {
1099 if let Some(n) = &surface.name {
1100 surface_names.push(n.name.clone());
1101 }
1102 for item in &surface.items {
1103 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1104 continue;
1105 };
1106 if keyword == "provides" {
1107 collect_call_names(value, &mut surface_triggers);
1108 }
1109 }
1110 }
1111 if let Some(rev) = self.reverse_contributions {
1114 for t in &rev.provided_triggers {
1115 surface_triggers.insert(t.as_str());
1116 }
1117 }
1118
1119 let mut emitted_triggers: HashSet<&str> = HashSet::new();
1121 for rule in self.blocks(BlockKind::Rule) {
1122 for item in &rule.items {
1123 collect_emitted_trigger_from_item(&item.kind, &mut emitted_triggers);
1124 }
1125 }
1126
1127 let mut assigned_fields: HashSet<String> = HashSet::new();
1129 if let Some(rev) = self.reverse_contributions {
1133 for (entity, statuses) in &rev.assigned_statuses {
1134 for s in statuses {
1135 assigned_fields.insert(format!("{entity}.status.{s}"));
1136 }
1137 }
1138 for (entity, edges) in &rev.witnessed_transitions {
1139 for (_from, to) in edges {
1140 assigned_fields.insert(format!("{entity}.status.{to}"));
1141 }
1142 }
1143 }
1144
1145 struct RuleData<'b> {
1146 name: &'b str,
1147 trigger_reachable: bool,
1148 requires_fields: Vec<(String, String, String)>,
1149 transitions: Vec<(String, String, String)>,
1150 field_assignments: HashSet<String>,
1151 entity_bindings: Vec<String>,
1152 }
1153 let mut rules: Vec<RuleData> = Vec::new();
1154
1155 for rule in self.blocks(BlockKind::Rule) {
1156 let rule_name = match &rule.name {
1157 Some(n) => n.name.as_str(),
1158 None => continue,
1159 };
1160 let mut trigger_ref: Option<TriggerRef<'_>> = None;
1161 let mut requires_statuses: HashMap<&str, HashSet<&str>> = HashMap::new();
1162 let mut requires_fields: Vec<(String, String, String)> = Vec::new();
1163 let mut ensures_statuses: Vec<(&str, &str)> = Vec::new();
1164 let mut rule_assigned: HashSet<String> = HashSet::new();
1165
1166 for item in &rule.items {
1167 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1168 continue;
1169 };
1170 if keyword == "when" {
1171 let mut refs = extract_trigger_refs(value);
1172 if !refs.is_empty() {
1173 trigger_ref = Some(refs.remove(0));
1174 }
1175 }
1176 }
1177
1178 let trigger_reachable = trigger_ref.as_ref().map_or(true, |t| {
1182 self.trigger_reachability(t, &surface_triggers, &emitted_triggers)
1183 .unwrap_or(true)
1184 });
1185
1186 let binding_types = collect_rule_binding_types(rule, &status_values_for_binding(&status_values));
1187
1188 let entity_bindings: Vec<String> = binding_types
1190 .values()
1191 .map(|v| v.to_string())
1192 .collect::<HashSet<_>>()
1193 .into_iter()
1194 .collect();
1195
1196 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1197 if keyword != "requires" {
1198 return;
1199 }
1200 collect_requires_conditions(
1201 value,
1202 &binding_types,
1203 status_values,
1204 &mut |binding, field, val| {
1205 if field == "status" {
1206 requires_statuses
1207 .entry(binding)
1208 .or_default()
1209 .insert(val);
1210 } else {
1211 let entity = resolve_binding_entity_from_status(
1212 binding, None, &binding_types, &status_values,
1213 );
1214 if let Some(e) = entity {
1215 requires_fields.push((
1216 e.to_string(),
1217 field.to_string(),
1218 val.to_string(),
1219 ));
1220 }
1221 }
1222 },
1223 );
1224 });
1225
1226 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1227 if keyword != "ensures" {
1228 return;
1229 }
1230 collect_field_assignments(
1231 value,
1232 &binding_types,
1233 &status_values,
1234 &field_types,
1235 &mut |entity, field, value| {
1236 let key = format!("{entity}.{field}");
1237 assigned_fields.insert(key.clone());
1238 rule_assigned.insert(key);
1239 if field == "status" && value != "_variable_" {
1240 assigned_fields.insert(format!("{entity}.status.{value}"));
1241 }
1242 },
1243 );
1244 collect_ensures_status(
1245 value,
1246 &binding_types,
1247 &status_values,
1248 &field_types,
1249 &mut |binding, target| {
1250 ensures_statuses.push((binding, target));
1251 },
1252 );
1253 });
1254
1255 let mut transitions = Vec::new();
1256 for (binding, target) in &ensures_statuses {
1257 let entity = resolve_binding_entity_from_status(
1258 binding,
1259 Some(target),
1260 &binding_types,
1261 &status_values,
1262 );
1263 if let Some(e) = entity {
1264 if let Some(sources) = requires_statuses.get(binding) {
1265 for source in sources {
1266 transitions.push((
1267 e.to_string(),
1268 source.to_string(),
1269 target.to_string(),
1270 ));
1271 }
1272 }
1273 }
1274 }
1275
1276 rules.push(RuleData {
1277 name: rule_name,
1278 trigger_reachable,
1279 requires_fields,
1280 transitions,
1281 field_assignments: rule_assigned,
1282 entity_bindings,
1283 });
1284 }
1285
1286 let mut created_fields: HashSet<String> = HashSet::new();
1288 for rule in self.blocks(BlockKind::Rule) {
1289 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1290 if keyword != "ensures" {
1291 return;
1292 }
1293 collect_created_field_assignments(value, &status_values, &mut assigned_fields);
1294 collect_created_field_assignments(value, &status_values, &mut created_fields);
1295 });
1296 }
1297
1298 let mut surface_provided_fields: HashSet<String> = HashSet::new();
1300 for surface in self.blocks(BlockKind::Surface) {
1301 for item in &surface.items {
1302 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1303 continue;
1304 };
1305 if keyword == "provides" {
1306 collect_surface_provided_fields(value, &status_values, &mut surface_provided_fields);
1307 }
1308 }
1309 }
1310
1311 let build_searched = |entity: &str, field: &str| -> Vec<serde_json::Value> {
1313 let key = format!("{entity}.{field}");
1314 let mut searched = Vec::new();
1315
1316 let matching_rule: Option<&RuleData> = rules.iter().find(|r| {
1318 r.field_assignments.contains(&key)
1319 });
1320 if let Some(r) = matching_rule {
1321 if !r.trigger_reachable {
1322 searched.push(serde_json::json!({
1323 "kind": "rule_ensures",
1324 "found": r.name,
1325 "but": "trigger has no providing surface"
1326 }));
1327 } else {
1328 searched.push(serde_json::json!({
1329 "kind": "rule_ensures",
1330 "found": r.name
1331 }));
1332 }
1333 } else {
1334 searched.push(serde_json::json!({
1335 "kind": "rule_ensures",
1336 "found": false
1337 }));
1338 }
1339
1340 searched.push(serde_json::json!({
1342 "kind": "surface_provides",
1343 "found": surface_provided_fields.contains(&key)
1344 }));
1345
1346 searched.push(serde_json::json!({
1348 "kind": "created_calls",
1349 "found": created_fields.contains(&key)
1350 }));
1351
1352 searched
1353 };
1354
1355 for (entity, edges) in graph_edges {
1357 let _statuses = match status_values.get(entity) {
1358 Some(v) => v,
1359 None => continue,
1360 };
1361
1362 for (from, to) in edges {
1363 let witnesses: Vec<&RuleData> = rules
1364 .iter()
1365 .filter(|r| {
1366 r.transitions
1367 .iter()
1368 .any(|(e, f, t)| e == *entity && f == *from && t == *to)
1369 })
1370 .collect();
1371
1372 if witnesses.is_empty() {
1373 continue;
1374 }
1375
1376 let any_achievable = witnesses.iter().any(|r| {
1377 r.requires_fields.iter().all(|(e, f, _v)| {
1378 assigned_fields.contains(&format!("{e}.{f}"))
1379 })
1380 });
1381
1382 if !any_achievable {
1383 let witness_names: Vec<String> =
1384 witnesses.iter().map(|r| r.name.to_string()).collect();
1385 let unsatisfiable: Vec<serde_json::Value> = witnesses
1386 .iter()
1387 .flat_map(|r| {
1388 r.requires_fields.iter().filter(|(e, f, _)| {
1389 !assigned_fields.contains(&format!("{e}.{f}"))
1390 })
1391 })
1392 .map(|(e, f, v)| {
1393 serde_json::json!({
1394 "entity": e,
1395 "field": f,
1396 "value": v,
1397 "searched": build_searched(e, f),
1398 })
1399 })
1400 .collect();
1401
1402 self.push_finding(serde_json::json!({
1403 "type": "dead_transition",
1404 "summary": format!(
1405 "Transition '{from}' → '{to}' on entity '{entity}' is declared but unachievable"
1406 ),
1407 "edge": {"entity": entity, "from": from, "to": to},
1408 "witnessing_rules": witness_names,
1409 "unsatisfiable_requires": unsatisfiable,
1410 "affected_entities": [entity],
1411 }));
1412 }
1413 }
1414 }
1415
1416 for r in &rules {
1418 for (entity, field, value) in &r.requires_fields {
1419 let key = format!("{entity}.{field}");
1420 if !assigned_fields.contains(&key) {
1421 self.push_finding(serde_json::json!({
1422 "type": "missing_producer",
1423 "summary": format!("Nothing establishes {entity}.{field} = {value}"),
1424 "requires": {"rule": r.name, "field": field, "value": value},
1425 "searched": build_searched(entity, field),
1426 "affected_entities": [entity],
1427 }));
1428 }
1429 }
1430 }
1431
1432 for (entity, edges) in graph_edges {
1434 let entity_terminals = match terminals.get(entity) {
1435 Some(t) => t,
1436 None => continue,
1437 };
1438 let (statuses, _idents) = match status_values.get(entity) {
1439 Some(v) => v,
1440 None => continue,
1441 };
1442
1443 let achievable_edges: HashSet<(&str, &str)> = edges
1444 .iter()
1445 .filter(|(_from, to)| {
1446 let producers: Vec<&RuleData> = rules
1447 .iter()
1448 .filter(|r| {
1449 r.transitions
1450 .iter()
1451 .any(|(e, _f, t)| e == *entity && t == *to)
1452 })
1453 .collect();
1454 if producers.is_empty() {
1455 return assigned_fields.contains(&format!("{entity}.status.{to}"));
1456 }
1457 producers.iter().any(|r| {
1458 r.requires_fields.iter().all(|(e, f, _v)| {
1459 assigned_fields.contains(&format!("{e}.{f}"))
1460 })
1461 })
1462 })
1463 .copied()
1464 .collect();
1465
1466 for status in statuses {
1467 if entity_terminals.contains(status) {
1468 continue;
1469 }
1470 let mut visited = HashSet::new();
1471 let mut queue = vec![*status];
1472 let mut found_terminal = false;
1473 while let Some(current) = queue.pop() {
1474 if !visited.insert(current) {
1475 continue;
1476 }
1477 if entity_terminals.contains(current) {
1478 found_terminal = true;
1479 break;
1480 }
1481 for (from, to) in &achievable_edges {
1482 if *from == current {
1483 queue.push(to);
1484 }
1485 }
1486 }
1487 if !found_terminal {
1488 let has_inbound = achievable_edges
1489 .iter()
1490 .any(|(_, to)| *to == *status);
1491
1492 if has_inbound || statuses.len() <= 6 {
1493 let outbound: Vec<serde_json::Value> = edges
1495 .iter()
1496 .filter(|(f, _)| *f == *status)
1497 .map(|(f, t)| {
1498 let witness_rules: Vec<(&str, &[(String, String, String)])> =
1499 rules
1500 .iter()
1501 .filter(|r| {
1502 r.transitions.iter().any(|(e, _ef, et)| {
1503 e == *entity && et == *t
1504 })
1505 })
1506 .map(|r| {
1507 (r.name, r.requires_fields.as_slice())
1508 })
1509 .collect();
1510 let reason = edge_blocked_reason(
1511 &witness_rules, &assigned_fields,
1512 );
1513 serde_json::json!({
1514 "from": f,
1515 "to": t,
1516 "reason": reason,
1517 })
1518 })
1519 .collect();
1520
1521 let cycle = detect_cycle(*status, &achievable_edges);
1523
1524 self.push_finding(serde_json::json!({
1525 "type": "deadlock",
1526 "summary": format!(
1527 "Entity '{entity}' can reach state '{status}' but has no achievable path to any terminal state"
1528 ),
1529 "state": status,
1530 "outbound_edges": outbound,
1531 "cycle": cycle,
1532 "affected_entities": [entity],
1533 }));
1534 }
1535 }
1536 }
1537 }
1538
1539 let mut unreachable_by_trigger: HashMap<String, Vec<(&str, Vec<String>)>> = HashMap::new();
1541 for rule in self.blocks(BlockKind::Rule) {
1542 let rule_name = match &rule.name {
1543 Some(n) => n.name.as_str(),
1544 None => continue,
1545 };
1546 for item in &rule.items {
1547 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1548 continue;
1549 };
1550 if keyword != "when" {
1551 continue;
1552 }
1553 for tref in extract_trigger_refs(value) {
1554 if self.trigger_reachability(&tref, &surface_triggers, &emitted_triggers)
1555 == Some(false)
1556 {
1557 let rule_data = rules.iter().find(|r| r.name == rule_name);
1559 let bindings = rule_data
1560 .map(|r| r.entity_bindings.clone())
1561 .unwrap_or_default();
1562 unreachable_by_trigger
1563 .entry(tref.display())
1564 .or_default()
1565 .push((rule_name, bindings));
1566 }
1567 }
1568 }
1569 }
1570 for (trigger, rule_entries) in &unreachable_by_trigger {
1571 let listening_rules: Vec<&str> = rule_entries.iter().map(|(n, _)| *n).collect();
1572 let affected_entities: Vec<String> = rule_entries
1573 .iter()
1574 .flat_map(|(_, bindings)| bindings.iter().cloned())
1575 .collect::<HashSet<_>>()
1576 .into_iter()
1577 .collect();
1578 self.push_finding(serde_json::json!({
1579 "type": "unreachable_trigger",
1580 "summary": format!(
1581 "Trigger '{trigger}' is not provided by any surface"
1582 ),
1583 "trigger": trigger,
1584 "listening_rules": listening_rules,
1585 "surfaces_checked": surface_names,
1586 "affected_entities": affected_entities,
1587 }));
1588 }
1589 }
1590
1591 fn collect_conflict_findings(&mut self, info: &EntityInfo<'_>) {
1592 let status_by_entity = info.status_by_entity();
1593
1594 if status_by_entity.is_empty() {
1595 return;
1596 }
1597
1598 struct ConflictRule<'b> {
1599 name: &'b str,
1600 trigger_kind: ConflictTriggerKind<'b>,
1601 requires_statuses: HashMap<String, HashSet<String>>,
1602 ensures_statuses: HashMap<String, String>,
1603 }
1604
1605 let mut conflict_rules: Vec<ConflictRule> = Vec::new();
1606
1607 for rule in self.blocks(BlockKind::Rule) {
1608 let rule_name = match &rule.name {
1609 Some(n) => n.name.as_str(),
1610 None => continue,
1611 };
1612 let binding_types = collect_rule_binding_types(rule, &HashMap::new());
1615
1616 let mut trigger_kind = ConflictTriggerKind::Unknown;
1617 let mut requires_statuses: HashMap<String, HashSet<String>> = HashMap::new();
1618 let mut ensures_statuses: HashMap<String, String> = HashMap::new();
1619
1620 for item in &rule.items {
1621 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1622 continue;
1623 };
1624 match keyword.as_str() {
1625 "when" => {
1626 trigger_kind = classify_trigger(value);
1627 }
1628 "requires" => {
1629 collect_requires_statuses_for_conflict(
1630 value,
1631 &binding_types,
1632 &status_by_entity,
1633 &mut requires_statuses,
1634 );
1635 }
1636 "ensures" => {
1637 collect_ensures_statuses_for_conflict(
1638 value,
1639 &binding_types,
1640 &status_by_entity,
1641 &mut ensures_statuses,
1642 );
1643 }
1644 _ => {}
1645 }
1646 }
1647
1648 conflict_rules.push(ConflictRule {
1649 name: rule_name,
1650 trigger_kind,
1651 requires_statuses,
1652 ensures_statuses,
1653 });
1654 }
1655
1656 let mut reported: HashSet<(usize, usize)> = HashSet::new();
1658 for i in 0..conflict_rules.len() {
1659 for j in (i + 1)..conflict_rules.len() {
1660 let a = &conflict_rules[i];
1661 let b = &conflict_rules[j];
1662
1663 if matches!(
1664 (&a.trigger_kind, &b.trigger_kind),
1665 (ConflictTriggerKind::Call(_), ConflictTriggerKind::Call(_))
1666 ) {
1667 continue;
1668 }
1669
1670 let mut overlap_state: Option<(&str, &str)> = None;
1672 let mut compatible = false;
1673 for (entity, a_statuses) in &a.requires_statuses {
1674 if let Some(b_statuses) = b.requires_statuses.get(entity) {
1675 let intersection: Vec<&String> =
1676 a_statuses.intersection(b_statuses).collect();
1677 if !intersection.is_empty() {
1678 compatible = true;
1679 overlap_state = Some((entity.as_str(), intersection[0].as_str()));
1680 break;
1681 }
1682 }
1683 }
1684 if !compatible {
1685 continue;
1686 }
1687
1688 for (entity, a_target) in &a.ensures_statuses {
1689 if let Some(b_target) = b.ensures_statuses.get(entity) {
1690 if a_target != b_target && !reported.contains(&(i, j)) {
1691 reported.insert((i, j));
1692 let state = overlap_state
1693 .map(|(_, s)| s.to_string())
1694 .unwrap_or_default();
1695 let mut values = serde_json::Map::new();
1696 values.insert(a.name.to_string(), serde_json::json!(a_target));
1697 values.insert(b.name.to_string(), serde_json::json!(b_target));
1698
1699 self.push_finding(serde_json::json!({
1700 "type": "conflict",
1701 "summary": format!(
1702 "Rules '{}' and '{}' can both fire when entity '{entity}' is in state '{state}', setting status to conflicting values",
1703 a.name, b.name,
1704 ),
1705 "rule_a": a.name,
1706 "rule_b": b.name,
1707 "field": "status",
1708 "state": state,
1709 "values": values,
1710 "affected_entities": [entity],
1711 }));
1712 }
1713 }
1714 }
1715 }
1716 }
1717 }
1718
1719 fn collect_invariant_findings(&mut self, info: &EntityInfo<'_>) {
1720 let status_by_entity = info.status_by_entity();
1721 let field_types = &info.field_types;
1722
1723 struct RuleEffect<'b> {
1724 name: &'b str,
1725 status_sets: Vec<(String, String)>,
1726 field_sets: HashSet<String>,
1727 requires: Vec<(String, String, String)>,
1728 }
1729
1730 let binding_map: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
1731 .iter()
1732 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
1733 .collect();
1734 let binding_map_for_types: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = status_by_entity
1735 .iter()
1736 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1737 .collect();
1738 let mut rule_effects: Vec<RuleEffect> = Vec::new();
1739
1740 for rule in self.blocks(BlockKind::Rule) {
1741 let rule_name = match &rule.name {
1742 Some(n) => n.name.as_str(),
1743 None => continue,
1744 };
1745 let binding_types = collect_rule_binding_types(rule, &binding_map_for_types);
1746 let mut status_sets = Vec::new();
1747 let mut field_sets = HashSet::new();
1748 let mut requires = Vec::new();
1749
1750 for item in &rule.items {
1751 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1752 continue;
1753 };
1754 match keyword.as_str() {
1755 "ensures" => {
1756 collect_rule_effects(
1757 value,
1758 &binding_types,
1759 &status_by_entity,
1760 &field_types,
1761 &mut status_sets,
1762 &mut field_sets,
1763 );
1764 }
1765 "requires" => {
1766 collect_requires_conditions(
1767 value,
1768 &binding_types,
1769 &binding_map,
1770 &mut |binding, field, val| {
1771 let entity = resolve_binding_entity(
1772 binding,
1773 None,
1774 &binding_types,
1775 &binding_map_for_types,
1776 );
1777 if let Some(e) = entity {
1778 requires.push((
1779 e.to_string(),
1780 field.to_string(),
1781 val.to_string(),
1782 ));
1783 }
1784 },
1785 );
1786 }
1787 _ => {}
1788 }
1789 }
1790
1791 rule_effects.push(RuleEffect {
1792 name: rule_name,
1793 status_sets,
1794 field_sets,
1795 requires,
1796 });
1797 }
1798
1799 for decl in &self.module.declarations {
1801 let Decl::Invariant(inv) = decl else {
1802 continue;
1803 };
1804
1805 if let Some(pattern) = extract_uniqueness_invariant(&inv.body) {
1806 let key_entity_type: Option<&str> = status_by_entity
1807 .keys()
1808 .find_map(|entity_name| {
1809 field_types
1810 .get(entity_name)
1811 .and_then(|fields| fields.get(pattern.key_field).copied())
1812 });
1813
1814 for effect in &rule_effects {
1815 for (entity, target) in &effect.status_sets {
1816 if target == pattern.prohibited_status {
1817 let has_guard = key_entity_type.map_or(false, |ket| {
1818 effect.field_sets.iter().any(|f| {
1819 f.starts_with(&format!("{ket}."))
1820 }) || effect.requires.iter().any(|(e, _f, _v)| {
1821 e == ket
1822 })
1823 });
1824
1825 if !has_guard {
1826 let needed = format!(
1827 "Rule should set {}.status to prevent concurrent {} states",
1828 key_entity_type.unwrap_or("related entity"),
1829 pattern.prohibited_status,
1830 );
1831 self.push_finding(serde_json::json!({
1832 "type": "invariant_risk",
1833 "summary": format!(
1834 "Rule '{}' could violate invariant '{}'",
1835 effect.name, inv.name.name,
1836 ),
1837 "rule": effect.name,
1838 "invariant": inv.name.name,
1839 "mechanism": format!(
1840 "Sets {entity}.status to '{target}' without preventing concurrent instances"
1841 ),
1842 "guard_analysis": {
1843 "has_guard": false,
1844 "needed": needed,
1845 },
1846 "affected_entities": [entity],
1847 }));
1848 }
1849 }
1850 }
1851 }
1852 }
1853 }
1854 }
1855}
1856
1857fn edge_blocked_reason(
1862 witness_rules: &[(&str, &[(String, String, String)])],
1863 assigned_fields: &HashSet<String>,
1864) -> String {
1865 if witness_rules.is_empty() {
1866 return "no witnessing rule".to_string();
1867 }
1868
1869 for (name, requires_fields) in witness_rules {
1870 for (e, f, v) in *requires_fields {
1871 if !assigned_fields.contains(&format!("{e}.{f}")) {
1872 return format!(
1873 "rule {name} requires {e}.{f} = {v}, never established",
1874 );
1875 }
1876 }
1877 }
1878
1879 "no achievable witnessing rule".to_string()
1880}
1881
1882fn detect_cycle<'a>(
1885 start: &'a str,
1886 edges: &HashSet<(&'a str, &'a str)>,
1887) -> Option<Vec<&'a str>> {
1888 let mut stack: Vec<(&str, Vec<&str>)> = vec![(start, vec![start])];
1890 let mut visited: HashSet<&str> = HashSet::new();
1891
1892 while let Some((current, path)) = stack.pop() {
1893 if !visited.insert(current) {
1894 continue;
1895 }
1896 for (from, to) in edges {
1897 if *from != current {
1898 continue;
1899 }
1900 if let Some(pos) = path.iter().position(|s| *s == *to) {
1901 let mut cycle: Vec<&str> = path[pos..].to_vec();
1903 cycle.push(to);
1904 return Some(cycle);
1905 }
1906 let mut next_path = path.clone();
1907 next_path.push(to);
1908 visited.remove(to);
1910 stack.push((to, next_path));
1911 }
1912 }
1913 None
1914}
1915
1916fn collect_surface_provided_fields(
1918 expr: &Expr,
1919 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
1920 out: &mut HashSet<String>,
1921) {
1922 match expr {
1923 Expr::Call { function, args, .. } => {
1924 if let Expr::Ident(fn_name) = function.as_ref() {
1925 for arg in args {
1928 if let CallArg::Positional(Expr::Ident(binding)) = arg {
1929 if status_values.contains_key(binding.name.as_str()) {
1931 out.insert(format!("{}.status", binding.name));
1933 }
1934 }
1935 if let CallArg::Named(named) = arg {
1936 if let Expr::Ident(val) = &named.value {
1937 if status_values.contains_key(val.name.as_str()) {
1938 out.insert(format!("{}.{}", val.name, named.name.name));
1939 }
1940 }
1941 }
1942 }
1943 let _ = fn_name;
1945 }
1946 }
1947 Expr::Block { items, .. } => {
1948 for item in items {
1949 collect_surface_provided_fields(item, status_values, out);
1950 }
1951 }
1952 Expr::WhenGuard { action, .. } => {
1953 collect_surface_provided_fields(action, status_values, out);
1954 }
1955 Expr::Conditional { branches, else_body, .. } => {
1956 for b in branches {
1957 collect_surface_provided_fields(&b.body, status_values, out);
1958 }
1959 if let Some(body) = else_body {
1960 collect_surface_provided_fields(body, status_values, out);
1961 }
1962 }
1963 _ => {}
1964 }
1965}
1966
1967fn collect_rule_effects(
1969 expr: &Expr,
1970 binding_types: &HashMap<&str, &str>,
1971 status_by_entity: &HashMap<&str, HashSet<&str>>,
1972 field_types: &HashMap<&str, HashMap<&str, &str>>,
1973 status_sets: &mut Vec<(String, String)>,
1974 field_sets: &mut HashSet<String>,
1975) {
1976 match expr {
1977 Expr::Comparison {
1978 left,
1979 op: ComparisonOp::Eq,
1980 right,
1981 ..
1982 } => {
1983 if let Some(target) = expr_as_ident(right) {
1984 if let Some((binding, field)) = expr_as_member_access(left) {
1985 let entity = resolve_binding_entity(
1986 binding,
1987 if field == "status" { Some(target) } else { None },
1988 binding_types,
1989 &status_by_entity
1990 .iter()
1991 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1992 .collect(),
1993 );
1994 if let Some(e) = entity {
1995 if field == "status" {
1996 status_sets.push((e.to_string(), target.to_string()));
1997 }
1998 field_sets.insert(format!("{e}.{field}"));
1999 }
2000 }
2001 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2003 let root_entity = resolve_binding_entity(
2004 root,
2005 None,
2006 binding_types,
2007 &status_by_entity
2008 .iter()
2009 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2010 .collect(),
2011 );
2012 if let Some(re) = root_entity {
2013 if let Some(nested) =
2014 field_types.get(re).and_then(|f| f.get(mid).copied())
2015 {
2016 if field == "status" {
2017 status_sets.push((nested.to_string(), target.to_string()));
2018 }
2019 field_sets.insert(format!("{nested}.{field}"));
2020 }
2021 }
2022 }
2023 }
2024 }
2025 Expr::Block { items, .. } => {
2026 for item in items {
2027 collect_rule_effects(
2028 item, binding_types, status_by_entity, field_types, status_sets, field_sets,
2029 );
2030 }
2031 }
2032 Expr::Conditional {
2033 branches,
2034 else_body,
2035 ..
2036 } => {
2037 for branch in branches {
2038 collect_rule_effects(
2039 &branch.body, binding_types, status_by_entity, field_types, status_sets,
2040 field_sets,
2041 );
2042 }
2043 if let Some(body) = else_body {
2044 collect_rule_effects(
2045 body, binding_types, status_by_entity, field_types, status_sets, field_sets,
2046 );
2047 }
2048 }
2049 _ => {}
2050 }
2051}
2052
2053struct UniquenessPattern<'a> {
2056 prohibited_status: &'a str,
2057 key_field: &'a str,
2058}
2059
2060fn extract_uniqueness_invariant<'a>(expr: &'a Expr) -> Option<UniquenessPattern<'a>> {
2062 let Expr::For { body, .. } = expr else {
2064 return None;
2065 };
2066 let Expr::For { body: inner_body, .. } = body.as_ref() else {
2067 return None;
2068 };
2069
2070 let Expr::LogicalOp {
2072 op: LogicalOp::Implies,
2073 left: premise,
2074 right: conclusion,
2075 ..
2076 } = inner_body.as_ref()
2077 else {
2078 return None;
2079 };
2080
2081 let Expr::Not { operand, .. } = conclusion.as_ref() else {
2083 return None;
2084 };
2085
2086 let prohibited = extract_prohibited_status(operand)?;
2088
2089 let key_field = extract_key_field(premise)?;
2091
2092 Some(UniquenessPattern {
2093 prohibited_status: prohibited,
2094 key_field,
2095 })
2096}
2097
2098fn extract_prohibited_status(expr: &Expr) -> Option<&str> {
2100 let Expr::LogicalOp {
2101 op: LogicalOp::And,
2102 left,
2103 right,
2104 ..
2105 } = expr
2106 else {
2107 return None;
2108 };
2109
2110 let l_status = extract_status_value(left)?;
2112 let r_status = extract_status_value(right)?;
2113
2114 if l_status == r_status {
2115 Some(l_status)
2116 } else {
2117 None
2118 }
2119}
2120
2121fn extract_status_value(expr: &Expr) -> Option<&str> {
2122 if let Expr::Comparison {
2123 left,
2124 op: ComparisonOp::Eq,
2125 right,
2126 ..
2127 } = expr
2128 {
2129 if let Some((_, "status")) = expr_as_member_access(left) {
2130 return expr_as_ident(right);
2131 }
2132 }
2133 None
2134}
2135
2136fn extract_key_field(expr: &Expr) -> Option<&str> {
2138 let Expr::LogicalOp {
2139 op: LogicalOp::And,
2140 left: _,
2141 right,
2142 ..
2143 } = expr
2144 else {
2145 return None;
2146 };
2147
2148 if let Expr::Comparison {
2150 left,
2151 op: ComparisonOp::Eq,
2152 right: _,
2153 ..
2154 } = right.as_ref()
2155 {
2156 if let Some((_, field)) = expr_as_member_access(left) {
2157 return Some(field);
2161 }
2162 }
2163 None
2164}
2165
2166#[derive(PartialEq)]
2167enum ConflictTriggerKind<'a> {
2168 Call(&'a str),
2169 Temporal,
2170 Unknown,
2171}
2172
2173fn classify_trigger(expr: &Expr) -> ConflictTriggerKind<'_> {
2174 match expr {
2175 Expr::Call { function, .. } => {
2176 if let Expr::Ident(id) = function.as_ref() {
2177 return ConflictTriggerKind::Call(&id.name);
2178 }
2179 ConflictTriggerKind::Unknown
2180 }
2181 Expr::Binding { value, .. } => classify_trigger(value),
2182 Expr::Comparison { .. }
2183 | Expr::Becomes { .. }
2184 | Expr::TransitionsTo { .. } => ConflictTriggerKind::Temporal,
2185 _ => ConflictTriggerKind::Unknown,
2186 }
2187}
2188
2189fn collect_requires_statuses_for_conflict(
2190 expr: &Expr,
2191 binding_types: &HashMap<&str, &str>,
2192 status_by_entity: &HashMap<&str, HashSet<&str>>,
2193 out: &mut HashMap<String, HashSet<String>>,
2194) {
2195 match expr {
2196 Expr::Comparison {
2197 left,
2198 op: ComparisonOp::Eq,
2199 right,
2200 ..
2201 } => {
2202 if let (Some((binding, "status")), Some(target)) =
2203 (expr_as_member_access(left), expr_as_ident(right))
2204 {
2205 let entity = resolve_binding_entity(
2206 binding,
2207 Some(target),
2208 binding_types,
2209 &status_by_entity
2210 .iter()
2211 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2212 .collect(),
2213 );
2214 if let Some(e) = entity {
2215 out.entry(e.to_string()).or_default().insert(target.to_string());
2216 }
2217 }
2218 }
2219 Expr::LogicalOp { left, right, .. } => {
2220 collect_requires_statuses_for_conflict(left, binding_types, status_by_entity, out);
2221 collect_requires_statuses_for_conflict(right, binding_types, status_by_entity, out);
2222 }
2223 Expr::Block { items, .. } => {
2224 for item in items {
2225 collect_requires_statuses_for_conflict(item, binding_types, status_by_entity, out);
2226 }
2227 }
2228 _ => {}
2229 }
2230}
2231
2232fn collect_ensures_statuses_for_conflict(
2233 expr: &Expr,
2234 binding_types: &HashMap<&str, &str>,
2235 status_by_entity: &HashMap<&str, HashSet<&str>>,
2236 out: &mut HashMap<String, String>,
2237) {
2238 match expr {
2239 Expr::Comparison {
2240 left,
2241 op: ComparisonOp::Eq,
2242 right,
2243 ..
2244 } => {
2245 if let (Some((binding, "status")), Some(target)) =
2246 (expr_as_member_access(left), expr_as_ident(right))
2247 {
2248 let entity = resolve_binding_entity(
2249 binding,
2250 Some(target),
2251 binding_types,
2252 &status_by_entity
2253 .iter()
2254 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2255 .collect(),
2256 );
2257 if let Some(e) = entity {
2258 out.insert(e.to_string(), target.to_string());
2259 }
2260 }
2261 }
2262 Expr::Block { items, .. } => {
2263 for item in items {
2264 collect_ensures_statuses_for_conflict(item, binding_types, status_by_entity, out);
2265 }
2266 }
2267 Expr::Conditional {
2268 branches,
2269 else_body,
2270 ..
2271 } => {
2272 for branch in branches {
2273 collect_ensures_statuses_for_conflict(
2274 &branch.body, binding_types, status_by_entity, out,
2275 );
2276 }
2277 if let Some(body) = else_body {
2278 collect_ensures_statuses_for_conflict(body, binding_types, status_by_entity, out);
2279 }
2280 }
2281 _ => {}
2282 }
2283}
2284
2285fn status_values_for_binding<'a>(
2287 status_values: &'a HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
2288) -> HashMap<&'a str, (Vec<&'a Ident>, HashSet<&'a str>)> {
2289 status_values
2290 .iter()
2291 .map(|(k, (set, idents))| (*k, (idents.clone(), set.clone())))
2292 .collect()
2293}
2294
2295fn resolve_binding_entity_from_status<'a>(
2298 binding: &str,
2299 target: Option<&str>,
2300 binding_types: &HashMap<&'a str, &'a str>,
2301 status_values: &HashMap<&'a str, (HashSet<&'a str>, Vec<&Ident>)>,
2302) -> Option<&'a str> {
2303 binding_types
2304 .get(binding)
2305 .copied()
2306 .or_else(|| {
2307 status_values
2308 .keys()
2309 .find(|name| name.eq_ignore_ascii_case(binding))
2310 .copied()
2311 })
2312 .or_else(|| {
2313 let target = target?;
2314 let mut candidates = status_values
2315 .iter()
2316 .filter(|(_, (values, _))| values.contains(target));
2317 let first = candidates.next()?;
2318 if candidates.next().is_none() {
2319 Some(first.0)
2320 } else {
2321 None
2322 }
2323 })
2324}
2325
2326fn collect_requires_conditions<'a>(
2328 expr: &'a Expr,
2329 binding_types: &HashMap<&'a str, &'a str>,
2330 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2331 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2332) {
2333 match expr {
2334 Expr::Comparison {
2335 left,
2336 op: ComparisonOp::Eq,
2337 right,
2338 ..
2339 } => {
2340 if let Some(target) = expr_as_ident(right) {
2341 if let Some((binding, field)) = expr_as_member_access(left) {
2342 cb(binding, field, target);
2343 } else if let Some((root, _mid, field)) =
2344 expr_as_nested_member_access(left)
2345 {
2346 if field == "status" {
2347 cb(root, "status", target);
2348 }
2349 }
2350 }
2351 if let Expr::BoolLiteral { value: true, .. } = right.as_ref() {
2353 if let Some((binding, field)) = expr_as_member_access(left) {
2354 cb(binding, field, "true");
2355 }
2356 }
2357 }
2358 Expr::Comparison {
2359 op: ComparisonOp::GtEq,
2360 ..
2361 } => {
2362 }
2364 Expr::LogicalOp { left, right, .. } => {
2365 collect_requires_conditions(left, binding_types, status_values, cb);
2366 collect_requires_conditions(right, binding_types, status_values, cb);
2367 }
2368 Expr::Block { items, .. } => {
2369 for item in items {
2370 collect_requires_conditions(item, binding_types, status_values, cb);
2371 }
2372 }
2373 _ => {}
2374 }
2375}
2376
2377fn collect_field_assignments<'a>(
2379 expr: &'a Expr,
2380 binding_types: &HashMap<&'a str, &'a str>,
2381 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2382 field_types: &HashMap<&str, HashMap<&str, &str>>,
2383 cb: &mut impl FnMut(&str, &str, &str),
2384) {
2385 match expr {
2386 Expr::Comparison {
2387 left,
2388 op: ComparisonOp::Eq,
2389 right,
2390 ..
2391 } => {
2392 if let Some((binding, field)) = expr_as_member_access(left) {
2393 let entity = resolve_binding_entity_from_status(
2394 binding, None, binding_types, status_values,
2395 );
2396 if let Some(entity) = entity {
2397 let val = expr_as_ident(right).unwrap_or("_variable_");
2398 cb(entity, field, val);
2399 }
2400 }
2401 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2403 let root_entity = resolve_binding_entity_from_status(
2404 root, None, binding_types, status_values,
2405 );
2406 if let Some(root_entity) = root_entity {
2407 if let Some(nested) =
2408 field_types.get(root_entity).and_then(|f| f.get(mid).copied())
2409 {
2410 let val = expr_as_ident(right).unwrap_or("_variable_");
2411 cb(nested, field, val);
2412 }
2413 }
2414 }
2415 }
2416 Expr::Block { items, .. } => {
2417 for item in items {
2418 collect_field_assignments(item, binding_types, status_values, field_types, cb);
2419 }
2420 }
2421 Expr::Conditional {
2422 branches,
2423 else_body,
2424 ..
2425 } => {
2426 for branch in branches {
2427 collect_field_assignments(
2428 &branch.body, binding_types, status_values, field_types, cb,
2429 );
2430 }
2431 if let Some(body) = else_body {
2432 collect_field_assignments(body, binding_types, status_values, field_types, cb);
2433 }
2434 }
2435 _ => {}
2436 }
2437}
2438
2439fn collect_ensures_status<'a>(
2441 expr: &'a Expr,
2442 binding_types: &HashMap<&'a str, &'a str>,
2443 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2444 field_types: &HashMap<&str, HashMap<&str, &str>>,
2445 cb: &mut impl FnMut(&'a str, &'a str),
2446) {
2447 match expr {
2448 Expr::Comparison {
2449 left,
2450 op: ComparisonOp::Eq,
2451 right,
2452 ..
2453 } => {
2454 if let Some(target) = expr_as_ident(right) {
2455 if let Some((binding, "status")) = expr_as_member_access(left) {
2458 cb(binding, target);
2459 }
2460 }
2461 }
2462 Expr::Block { items, .. } => {
2463 for item in items {
2464 collect_ensures_status(item, binding_types, status_values, field_types, cb);
2465 }
2466 }
2467 Expr::Conditional {
2468 branches,
2469 else_body,
2470 ..
2471 } => {
2472 for branch in branches {
2473 collect_ensures_status(
2474 &branch.body, binding_types, status_values, field_types, cb,
2475 );
2476 }
2477 if let Some(body) = else_body {
2478 collect_ensures_status(body, binding_types, status_values, field_types, cb);
2479 }
2480 }
2481 _ => {}
2482 }
2483}
2484
2485fn collect_created_field_assignments<'a>(
2487 expr: &'a Expr,
2488 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2489 assigned: &mut HashSet<String>,
2490) {
2491 match expr {
2492 Expr::Call { function, args, .. } => {
2493 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2494 if field.name == "created" {
2495 if let Expr::Ident(entity_id) = object.as_ref() {
2496 let entity = entity_id.name.as_str();
2497 if status_values.contains_key(entity) {
2498 for arg in args {
2499 if let CallArg::Named(named) = arg {
2500 assigned.insert(format!(
2501 "{entity}.{}", named.name.name
2502 ));
2503 if named.name.name == "status" {
2505 if let Expr::Ident(val) = &named.value {
2506 assigned.insert(format!(
2507 "{entity}.status.{}", val.name
2508 ));
2509 }
2510 }
2511 }
2512 }
2513 }
2514 }
2515 }
2516 }
2517 }
2518 Expr::Block { items, .. } => {
2519 for item in items {
2520 collect_created_field_assignments(item, status_values, assigned);
2521 }
2522 }
2523 Expr::Conditional {
2524 branches,
2525 else_body,
2526 ..
2527 } => {
2528 for branch in branches {
2529 collect_created_field_assignments(&branch.body, status_values, assigned);
2530 }
2531 if let Some(body) = else_body {
2532 collect_created_field_assignments(body, status_values, assigned);
2533 }
2534 }
2535 _ => {}
2536 }
2537}
2538
2539fn collect_rule_binding_types<'a>(
2540 rule: &'a BlockDecl,
2541 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2542) -> HashMap<&'a str, &'a str> {
2543 let mut types = HashMap::new();
2544 for item in &rule.items {
2545 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2546 continue;
2547 };
2548 if keyword != "when" {
2549 continue;
2550 }
2551 collect_binding_types_from_expr(value, status_by_entity, &mut types);
2552 }
2553 types
2554}
2555
2556fn collect_binding_types_from_expr<'a>(
2557 expr: &'a Expr,
2558 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2559 out: &mut HashMap<&'a str, &'a str>,
2560) {
2561 match expr {
2562 Expr::Binding { name, value, .. } => {
2563 if let Some(entity_name) = extract_entity_from_trigger(value) {
2564 if status_by_entity.contains_key(entity_name) {
2565 out.insert(&name.name, entity_name);
2566 }
2567 }
2568 }
2569 Expr::Call { function, args, .. } => {
2570 if let Expr::Ident(fn_name) = function.as_ref() {
2571 for arg in args {
2572 if let CallArg::Positional(Expr::Ident(binding)) = arg {
2573 if status_by_entity.contains_key(fn_name.name.as_str()) {
2574 out.insert(&binding.name, &fn_name.name);
2575 }
2576 }
2577 }
2578 }
2579 }
2580 Expr::LogicalOp { left, right, .. } => {
2581 collect_binding_types_from_expr(left, status_by_entity, out);
2582 collect_binding_types_from_expr(right, status_by_entity, out);
2583 }
2584 _ => {}
2585 }
2586}
2587
2588fn collect_command_param_types<'a, V>(
2592 expr: &'a Expr,
2593 status_by_entity: &HashMap<&str, V>,
2594 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
2595) {
2596 match expr {
2597 Expr::Call { function, args, .. } => {
2598 if let Expr::Ident(fn_name) = function.as_ref() {
2599 let params: Vec<Option<&str>> = args
2600 .iter()
2601 .map(|arg| match arg {
2602 CallArg::Named(named) => match &named.value {
2603 Expr::Ident(val)
2604 if status_by_entity.contains_key(val.name.as_str()) =>
2605 {
2606 Some(val.name.as_str())
2607 }
2608 _ => None,
2609 },
2610 CallArg::Positional(_) => None,
2611 })
2612 .collect();
2613 if params.iter().any(Option::is_some) {
2614 out.insert(&fn_name.name, params);
2615 }
2616 }
2617 }
2618 Expr::WhenGuard { action, .. } => {
2619 collect_command_param_types(action, status_by_entity, out);
2620 }
2621 Expr::Block { items, .. } => {
2622 for item in items {
2623 collect_command_param_types(item, status_by_entity, out);
2624 }
2625 }
2626 Expr::Conditional {
2627 branches,
2628 else_body,
2629 ..
2630 } => {
2631 for branch in branches {
2632 collect_command_param_types(&branch.body, status_by_entity, out);
2633 }
2634 if let Some(body) = else_body {
2635 collect_command_param_types(body, status_by_entity, out);
2636 }
2637 }
2638 _ => {}
2639 }
2640}
2641
2642fn augment_binding_types_from_commands<'a>(
2646 rule: &'a BlockDecl,
2647 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2648 out: &mut HashMap<&'a str, &'a str>,
2649) {
2650 for item in &rule.items {
2651 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2652 continue;
2653 };
2654 if keyword != "when" {
2655 continue;
2656 }
2657 augment_binding_types_from_call(value, command_param_types, out);
2658 }
2659}
2660
2661fn augment_binding_types_from_call<'a>(
2662 expr: &'a Expr,
2663 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2664 out: &mut HashMap<&'a str, &'a str>,
2665) {
2666 match expr {
2667 Expr::Call { function, args, .. } => {
2668 if let Expr::Ident(fn_name) = function.as_ref() {
2669 if let Some(params) = command_param_types.get(fn_name.name.as_str()) {
2670 for (arg, param_type) in args.iter().zip(params) {
2671 if let (CallArg::Positional(Expr::Ident(binding)), Some(entity)) =
2672 (arg, param_type)
2673 {
2674 out.entry(&binding.name).or_insert(entity);
2675 }
2676 }
2677 }
2678 }
2679 }
2680 Expr::LogicalOp { left, right, .. } => {
2681 augment_binding_types_from_call(left, command_param_types, out);
2682 augment_binding_types_from_call(right, command_param_types, out);
2683 }
2684 _ => {}
2685 }
2686}
2687
2688fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
2689 match expr {
2690 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
2691 extract_entity_from_member(subject)
2692 }
2693 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2694 _ => None,
2695 }
2696}
2697
2698fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
2699 match expr {
2700 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2701 _ => None,
2702 }
2703}
2704
2705fn visit_status_assignments<'a>(
2706 expr: &'a Expr,
2707 binding_types: &HashMap<&'a str, &'a str>,
2708 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2709 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2710 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2711) {
2712 match expr {
2713 Expr::Comparison {
2714 left,
2715 op: ComparisonOp::Eq,
2716 right,
2717 ..
2718 } => {
2719 if let Some(target) = expr_as_ident(right) {
2720 if let Some((binding, "status")) = expr_as_member_access(left) {
2722 let entity = resolve_binding_entity(
2723 binding,
2724 Some(target),
2725 binding_types,
2726 status_by_entity,
2727 );
2728 if let Some(entity) = entity {
2729 cb(binding, target, entity);
2730 }
2731 }
2732 else if let Some((root, field, "status")) =
2736 expr_as_nested_member_access(left)
2737 {
2738 let root_entity = resolve_binding_entity(
2739 root, None, binding_types, status_by_entity,
2740 );
2741 if let Some(root_entity) = root_entity {
2742 if let Some(nested_entity) = field_entity_types
2743 .get(root_entity)
2744 .and_then(|fields| fields.get(field).copied())
2745 {
2746 cb("_nested_", target, nested_entity);
2749 }
2750 }
2751 }
2752 }
2753 }
2754 Expr::Block { items, .. } => {
2755 for item in items {
2756 visit_status_assignments(
2757 item,
2758 binding_types,
2759 status_by_entity,
2760 field_entity_types,
2761 cb,
2762 );
2763 }
2764 }
2765 Expr::Conditional {
2766 branches,
2767 else_body,
2768 ..
2769 } => {
2770 for branch in branches {
2771 visit_status_assignments(
2772 &branch.body,
2773 binding_types,
2774 status_by_entity,
2775 field_entity_types,
2776 cb,
2777 );
2778 }
2779 if let Some(body) = else_body {
2780 visit_status_assignments(
2781 body,
2782 binding_types,
2783 status_by_entity,
2784 field_entity_types,
2785 cb,
2786 );
2787 }
2788 }
2789 _ => {}
2790 }
2791}
2792
2793fn visit_created_calls<'a>(
2797 expr: &'a Expr,
2798 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2799 has_transitions: &HashSet<&'a str>,
2800 on_status: &mut impl FnMut(&'a str, &'a str),
2801 issues: &mut Vec<Diagnostic>,
2802) {
2803 match expr {
2804 Expr::Call {
2805 function, args, span, ..
2806 } => {
2807 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2808 if field.name == "created" {
2809 if let Expr::Ident(entity_ident) = object.as_ref() {
2810 let entity_name = entity_ident.name.as_str();
2811 if let Some((_, values)) = status_by_entity.get(entity_name) {
2812 let status_arg = args.iter().find_map(|arg| {
2813 if let CallArg::Named(named) = arg {
2814 if named.name.name == "status" {
2815 return Some(named);
2816 }
2817 }
2818 None
2819 });
2820
2821 match status_arg {
2822 Some(named) => {
2823 if let Expr::Ident(status_ident) = &named.value {
2824 let status = status_ident.name.as_str();
2825 if values.contains(status) {
2826 on_status(entity_name, status);
2827 } else {
2828 issues.push(
2829 Diagnostic::error(
2830 named.value.span(),
2831 format!(
2832 ".created() on entity '{entity_name}' sets status to '{status}', which is not a declared status value.",
2833 ),
2834 )
2835 .with_code("allium.created.invalidStatus"),
2836 );
2837 }
2838 }
2839 }
2840 None => {
2841 if has_transitions.contains(entity_name) {
2842 issues.push(
2843 Diagnostic::warning(
2844 *span,
2845 format!(
2846 ".created() on entity '{entity_name}' omits the status field, but the entity has a transition graph. The initial state is unspecified.",
2847 ),
2848 )
2849 .with_code("allium.created.missingStatus"),
2850 );
2851 }
2852 }
2853 }
2854 }
2855 }
2856 }
2857 }
2858 }
2859 Expr::Block { items, .. } => {
2860 for item in items {
2861 visit_created_calls(item, status_by_entity, has_transitions, on_status, issues);
2862 }
2863 }
2864 Expr::Conditional {
2865 branches,
2866 else_body,
2867 ..
2868 } => {
2869 for branch in branches {
2870 visit_created_calls(
2871 &branch.body,
2872 status_by_entity,
2873 has_transitions,
2874 on_status,
2875 issues,
2876 );
2877 }
2878 if let Some(body) = else_body {
2879 visit_created_calls(body, status_by_entity, has_transitions, on_status, issues);
2880 }
2881 }
2882 _ => {}
2883 }
2884}
2885
2886fn visit_status_comparisons<'a>(
2887 expr: &'a Expr,
2888 binding_types: &HashMap<&'a str, &'a str>,
2889 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2890 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2891 cb: &mut impl FnMut(&'a str, &'a str),
2892) {
2893 match expr {
2894 Expr::Comparison {
2895 left,
2896 op: ComparisonOp::Eq,
2897 right,
2898 ..
2899 } => {
2900 if let Some(target) = expr_as_ident(right) {
2901 if let Some((binding, "status")) = expr_as_member_access(left) {
2903 let known = resolve_binding_entity(
2904 binding,
2905 Some(target),
2906 binding_types,
2907 status_by_entity,
2908 )
2909 .is_some();
2910 if known {
2911 cb(binding, target);
2912 }
2913 }
2914 }
2919 }
2920 Expr::Comparison {
2921 left,
2922 op: ComparisonOp::NotEq,
2923 right,
2924 ..
2925 } => {
2926 if let Some(target) = expr_as_ident(right) {
2929 if let Some((binding, "status")) = expr_as_member_access(left) {
2930 if let Some(entity) = resolve_binding_entity(
2931 binding,
2932 Some(target),
2933 binding_types,
2934 status_by_entity,
2935 ) {
2936 if let Some((_, values)) = status_by_entity.get(entity) {
2937 if values.contains(target) {
2938 for value in values.iter().filter(|v| **v != target) {
2939 cb(binding, value);
2940 }
2941 }
2942 }
2943 }
2944 }
2945 }
2946 }
2947 Expr::LogicalOp { left, right, .. } => {
2948 visit_status_comparisons(left, binding_types, status_by_entity, field_entity_types, cb);
2949 visit_status_comparisons(right, binding_types, status_by_entity, field_entity_types, cb);
2950 }
2951 Expr::Block { items, .. } => {
2952 for item in items {
2953 visit_status_comparisons(item, binding_types, status_by_entity, field_entity_types, cb);
2954 }
2955 }
2956 _ => {}
2957 }
2958}
2959
2960fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
2961 match expr {
2962 Expr::MemberAccess { object, field, .. } => {
2963 expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
2964 }
2965 _ => None,
2966 }
2967}
2968
2969fn expr_as_nested_member_access(expr: &Expr) -> Option<(&str, &str, &str)> {
2971 if let Expr::MemberAccess {
2972 object, field: last, ..
2973 } = expr
2974 {
2975 if let Expr::MemberAccess {
2976 object: root_obj,
2977 field: mid,
2978 ..
2979 } = object.as_ref()
2980 {
2981 if let Expr::Ident(root) = root_obj.as_ref() {
2982 return Some((&root.name, &mid.name, &last.name));
2983 }
2984 }
2985 }
2986 None
2987}
2988
2989fn resolve_binding_entity<'a>(
2994 binding: &str,
2995 target: Option<&str>,
2996 binding_types: &HashMap<&'a str, &'a str>,
2997 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2998) -> Option<&'a str> {
2999 binding_types
3000 .get(binding)
3001 .copied()
3002 .or_else(|| {
3003 status_by_entity
3004 .keys()
3005 .find(|name| name.eq_ignore_ascii_case(binding))
3006 .copied()
3007 })
3008 .or_else(|| {
3009 let target = target?;
3011 let mut candidates = status_by_entity
3012 .iter()
3013 .filter(|(_, (_, values))| values.contains(target));
3014 let first = candidates.next()?;
3015 if candidates.next().is_none() {
3016 Some(first.0)
3017 } else {
3018 None
3019 }
3020 })
3021}
3022
3023fn extract_field_entity_type(expr: &Expr) -> Option<&str> {
3026 match expr {
3027 Expr::Ident(id) if starts_uppercase(&id.name) => Some(&id.name),
3028 Expr::JoinLookup { entity, .. } => {
3029 if let Expr::Ident(id) = entity.as_ref() {
3030 if starts_uppercase(&id.name) {
3031 return Some(&id.name);
3032 }
3033 }
3034 None
3035 }
3036 _ => None,
3037 }
3038}
3039
3040fn is_likely_terminal(status: &str) -> bool {
3041 matches!(
3042 status,
3043 "completed"
3044 | "cancelled"
3045 | "canceled"
3046 | "expired"
3047 | "closed"
3048 | "deleted"
3049 | "archived"
3050 | "failed"
3051 | "rejected"
3052 | "done"
3053 )
3054}
3055
3056impl Ctx<'_> {
3061 fn check_external_entity_source_hints(&mut self) {
3062 if self.has_use_imports() {
3063 return;
3064 }
3065
3066 let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
3067
3068 for entity in self.blocks(BlockKind::ExternalEntity) {
3069 let name = match &entity.name {
3070 Some(n) => n,
3071 None => continue,
3072 };
3073
3074 let referenced_in_rules = rule_blocks
3075 .iter()
3076 .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
3077
3078 let msg = format!(
3079 "External entity '{}' has no obvious governing specification import in this module.",
3080 name.name
3081 );
3082 if referenced_in_rules {
3083 self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
3084 } else {
3085 self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
3086 }
3087 }
3088 }
3089}
3090
3091impl Ctx<'_> {
3096 fn check_unresolved_use_paths(&mut self) {
3100 let Some(resolved) = self.resolved_use_paths else {
3101 return;
3102 };
3103 for d in &self.module.declarations {
3104 let Decl::Use(u) = d else { continue };
3105 let path_text = u.path.text();
3106 if !resolved.contains(&path_text) {
3107 self.push(
3108 Diagnostic::warning(
3109 u.path.span,
3110 format!(
3111 "Use path \"{path_text}\" does not resolve to a file in the current check set.",
3112 ),
3113 )
3114 .with_code("allium.use.unresolvedPath"),
3115 );
3116 }
3117 }
3118 }
3119}
3120
3121impl Ctx<'_> {
3126 fn check_ambiguous_imported_names(&mut self) {
3133 let Some(ambiguous) = self.ambiguous_imports else {
3134 return;
3135 };
3136 if ambiguous.names.is_empty() {
3137 return;
3138 }
3139 let mut local = self.declared_type_names();
3144 for b in self.blocks(BlockKind::Contract) {
3145 if let Some(n) = &b.name {
3146 local.insert(n.name.as_str());
3147 }
3148 }
3149
3150 let mut flagged: HashSet<&str> = HashSet::new();
3151 let mut findings = Vec::new();
3152 for id in collect_referenced_ident_nodes(self.module) {
3153 if id.qualified || local.contains(id.name) || flagged.contains(id.name) {
3154 continue;
3155 }
3156 let Some(aliases) = ambiguous.names.get(id.name) else {
3157 continue;
3158 };
3159 flagged.insert(id.name);
3161 findings.push(
3162 Diagnostic::warning(
3163 id.span,
3164 format!(
3165 "Unqualified reference '{}' is ambiguous: it is declared in imported modules {}. Use a qualified name (e.g. '{}/{}').",
3166 id.name,
3167 format_alias_list(aliases),
3168 aliases[0],
3169 id.name,
3170 ),
3171 )
3172 .with_code("allium.use.ambiguousReference"),
3173 );
3174 }
3175 self.diagnostics.extend(findings);
3176 }
3177}
3178
3179fn format_alias_list(aliases: &[String]) -> String {
3181 let quoted: Vec<String> = aliases.iter().map(|a| format!("'{a}'")).collect();
3182 match quoted.split_last() {
3183 Some((last, rest)) if !rest.is_empty() => {
3184 format!("{} and {last}", rest.join(", "))
3185 }
3186 _ => quoted.join(", "),
3187 }
3188}
3189
3190impl Ctx<'_> {
3195 fn check_type_references(&mut self) {
3196 let known = self.declared_type_names();
3197
3198 for d in &self.module.declarations {
3199 let block = match d {
3200 Decl::Block(b)
3201 if matches!(
3202 b.kind,
3203 BlockKind::Entity
3204 | BlockKind::ExternalEntity
3205 | BlockKind::Value
3206 ) =>
3207 {
3208 b
3209 }
3210 Decl::Variant(v) => {
3211 for item in &v.items {
3213 self.check_type_ref_in_item(item, &known);
3214 }
3215 continue;
3216 }
3217 _ => continue,
3218 };
3219
3220 for item in &block.items {
3221 self.check_type_ref_in_item(item, &known);
3222 }
3223 }
3224
3225 for rule in self.blocks(BlockKind::Rule) {
3227 for item in &rule.items {
3228 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3229 continue;
3230 };
3231 if keyword == "when" || keyword == "ensures" || keyword == "requires" {
3232 self.check_type_refs_in_rule_expr(value, &known);
3233 }
3234 }
3235 }
3236 }
3237
3238 fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
3239 match &item.kind {
3240 BlockItemKind::Assignment { value, .. }
3241 | BlockItemKind::FieldWithWhen { value, .. } => {
3242 self.check_type_refs_in_value(value, known);
3243 }
3244 _ => {}
3245 }
3246 }
3247
3248 fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
3249 match expr {
3250 Expr::Ident(id) if starts_uppercase(&id.name) => {
3251 if !known.contains(id.name.as_str()) {
3252 self.push(
3253 Diagnostic::error(
3254 id.span,
3255 format!(
3256 "Type reference '{}' is not declared locally or imported.",
3257 id.name
3258 ),
3259 )
3260 .with_code("allium.type.undefinedReference"),
3261 );
3262 }
3263 }
3264 Expr::GenericType { name, args, .. } => {
3265 self.check_type_refs_in_value(name, known);
3266 for arg in args {
3267 self.check_type_refs_in_value(arg, known);
3268 }
3269 }
3270 Expr::Pipe { left, right, .. } => {
3271 self.check_type_refs_in_value(left, known);
3272 self.check_type_refs_in_value(right, known);
3273 }
3274 Expr::TypeOptional { inner, .. } => {
3275 self.check_type_refs_in_value(inner, known);
3276 }
3277 _ => {}
3278 }
3279 }
3280
3281 fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
3282 match expr {
3283 Expr::Binding { value, .. } => {
3285 self.check_type_refs_in_rule_expr(value, known);
3286 }
3287 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
3288 if let Expr::MemberAccess { object, .. } = subject.as_ref() {
3289 if let Expr::Ident(id) = object.as_ref() {
3290 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3291 self.push(
3292 Diagnostic::error(
3293 id.span,
3294 format!(
3295 "Type reference '{}' is not declared locally or imported.",
3296 id.name
3297 ),
3298 )
3299 .with_code("allium.rule.undefinedTypeReference"),
3300 );
3301 }
3302 }
3303 }
3304 }
3305 Expr::Call { function, .. } => {
3307 if let Expr::MemberAccess { object, .. } = function.as_ref() {
3308 if let Expr::Ident(id) = object.as_ref() {
3309 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3310 self.push(
3311 Diagnostic::error(
3312 id.span,
3313 format!(
3314 "Type reference '{}' is not declared locally or imported.",
3315 id.name
3316 ),
3317 )
3318 .with_code("allium.rule.undefinedTypeReference"),
3319 );
3320 }
3321 }
3322 }
3323 }
3324 Expr::MemberAccess { object, .. } => {
3326 if let Expr::Ident(id) = object.as_ref() {
3327 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3328 self.push(
3329 Diagnostic::error(
3330 id.span,
3331 format!(
3332 "Type reference '{}' is not declared locally or imported.",
3333 id.name
3334 ),
3335 )
3336 .with_code("allium.rule.undefinedTypeReference"),
3337 );
3338 }
3339 }
3340 }
3341 Expr::Block { items, .. } => {
3342 for item in items {
3343 self.check_type_refs_in_rule_expr(item, known);
3344 }
3345 }
3346 Expr::LogicalOp { left, right, .. } => {
3347 self.check_type_refs_in_rule_expr(left, known);
3348 self.check_type_refs_in_rule_expr(right, known);
3349 }
3350 _ => {}
3351 }
3352 }
3353}
3354
3355impl Ctx<'_> {
3360 fn check_unreachable_triggers(&mut self) {
3361 let mut provided: HashSet<&str> = HashSet::new();
3363 if let Some(rev) = self.reverse_contributions {
3366 for t in &rev.provided_triggers {
3367 provided.insert(t.as_str());
3368 }
3369 }
3370 for surface in self.blocks(BlockKind::Surface) {
3371 for item in &surface.items {
3372 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3373 continue;
3374 };
3375 if keyword != "provides" {
3376 continue;
3377 }
3378 collect_call_names(value, &mut provided);
3379 }
3380 }
3381
3382 let mut emitted: HashSet<&str> = HashSet::new();
3386 for rule in self.blocks(BlockKind::Rule) {
3387 for item in &rule.items {
3388 collect_emitted_trigger_from_item(&item.kind, &mut emitted);
3389 }
3390 }
3391
3392 for rule in self.blocks(BlockKind::Rule) {
3393 let rule_name = match &rule.name {
3394 Some(n) => &n.name,
3395 None => continue,
3396 };
3397 for item in &rule.items {
3398 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3399 continue;
3400 };
3401 if keyword != "when" {
3402 continue;
3403 }
3404 for tref in extract_trigger_refs(value) {
3405 if tref.qualifier.is_none()
3409 && !provided.contains(tref.name)
3410 && !emitted.contains(tref.name)
3411 {
3412 if let Some(aliases) = self
3413 .ambiguous_imports
3414 .and_then(|a| a.triggers.get(tref.name))
3415 {
3416 let message = format!(
3417 "Rule '{rule_name}' listens for trigger '{}', which is provided or emitted by imported modules {}. Use a qualified name (e.g. '{}/{}').",
3418 tref.name,
3419 format_alias_list(aliases),
3420 aliases[0],
3421 tref.name,
3422 );
3423 self.push(
3424 Diagnostic::warning(tref.span, message)
3425 .with_code("allium.use.ambiguousReference"),
3426 );
3427 }
3428 }
3429 if self.trigger_reachability(&tref, &provided, &emitted) != Some(false) {
3430 continue;
3431 }
3432 let message = match tref.qualifier {
3433 None => format!(
3434 "Rule '{rule_name}' listens for trigger '{}' but no local surface provides or rule emits it.",
3435 tref.name,
3436 ),
3437 Some(q) => format!(
3438 "Rule '{rule_name}' listens for trigger '{q}/{}' but imported module '{q}' does not provide or emit it.",
3439 tref.name,
3440 ),
3441 };
3442 self.push(
3443 Diagnostic::info(tref.span, message)
3444 .with_code("allium.rule.unreachableTrigger"),
3445 );
3446 }
3447 }
3448 }
3449 }
3450
3451 fn trigger_reachability(
3458 &self,
3459 tref: &TriggerRef<'_>,
3460 provided: &HashSet<&str>,
3461 emitted: &HashSet<&str>,
3462 ) -> Option<bool> {
3463 match tref.qualifier {
3464 None => {
3465 if provided.contains(tref.name) || emitted.contains(tref.name) {
3466 return Some(true);
3467 }
3468 if let Some(imports) = self.imported_triggers {
3469 if imports.values().any(|set| set.contains(tref.name)) {
3470 return Some(true);
3471 }
3472 }
3473 Some(false)
3474 }
3475 Some(q) => self
3476 .imported_triggers?
3477 .get(q)
3478 .map(|set| set.contains(tref.name)),
3479 }
3480 }
3481}
3482
3483fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3486 match kind {
3487 BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
3488 collect_leading_ensures_call(value, out);
3489 }
3490 BlockItemKind::ForBlock { items, .. } => {
3491 for item in items {
3492 collect_emitted_trigger_from_item(&item.kind, out);
3493 }
3494 }
3495 BlockItemKind::IfBlock { branches, else_items, .. } => {
3496 for b in branches {
3497 for item in &b.items {
3498 collect_emitted_trigger_from_item(&item.kind, out);
3499 }
3500 }
3501 if let Some(items) = else_items {
3502 for item in items {
3503 collect_emitted_trigger_from_item(&item.kind, out);
3504 }
3505 }
3506 }
3507 _ => {}
3508 }
3509}
3510
3511fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3518 match expr {
3519 Expr::Call { function, .. } => {
3520 if let Expr::Ident(id) = function.as_ref() {
3521 if starts_uppercase(&id.name) {
3522 out.insert(&id.name);
3523 }
3524 }
3525 }
3526 Expr::Block { items, .. } => {
3527 if let Some(first) = items.first() {
3528 collect_leading_ensures_call(first, out);
3529 }
3530 }
3531 Expr::Conditional {
3532 branches,
3533 else_body,
3534 ..
3535 } => {
3536 for b in branches {
3537 collect_leading_ensures_call(&b.body, out);
3538 }
3539 if let Some(body) = else_body {
3540 collect_leading_ensures_call(body, out);
3541 }
3542 }
3543 Expr::For { body, .. } => {
3544 collect_leading_ensures_call(body, out);
3545 }
3546 _ => {}
3547 }
3548}
3549
3550fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3551 match expr {
3552 Expr::Call { function, .. } => {
3553 if let Expr::Ident(id) = function.as_ref() {
3554 if starts_uppercase(&id.name) {
3555 out.insert(&id.name);
3556 }
3557 }
3558 }
3559 Expr::Block { items, .. } => {
3560 for item in items {
3561 collect_call_names(item, out);
3562 }
3563 }
3564 Expr::WhenGuard { action, .. } => {
3565 collect_call_names(action, out);
3566 }
3567 Expr::Conditional { branches, else_body, .. } => {
3568 for b in branches {
3569 collect_call_names(&b.body, out);
3570 }
3571 if let Some(body) = else_body {
3572 collect_call_names(body, out);
3573 }
3574 }
3575 Expr::For { body, .. } => {
3576 collect_call_names(body, out);
3579 }
3580 _ => {}
3581 }
3582}
3583
3584struct TriggerRef<'a> {
3587 qualifier: Option<&'a str>,
3588 name: &'a str,
3589 span: Span,
3590}
3591
3592impl TriggerRef<'_> {
3593 fn display(&self) -> String {
3595 match self.qualifier {
3596 Some(q) => format!("{q}/{}", self.name),
3597 None => self.name.to_string(),
3598 }
3599 }
3600}
3601
3602fn extract_trigger_refs(expr: &Expr) -> Vec<TriggerRef<'_>> {
3603 match expr {
3604 Expr::Call { function, .. } => match function.as_ref() {
3605 Expr::Ident(id) if starts_uppercase(&id.name) => vec![TriggerRef {
3606 qualifier: None,
3607 name: &id.name,
3608 span: id.span,
3609 }],
3610 Expr::QualifiedName(q) if starts_uppercase(&q.name) => vec![TriggerRef {
3611 qualifier: q.qualifier.as_deref(),
3612 name: &q.name,
3613 span: q.span,
3614 }],
3615 _ => vec![],
3616 },
3617 Expr::Binding { .. } => {
3618 vec![]
3620 }
3621 Expr::LogicalOp { left, right, .. } => {
3622 let mut out = extract_trigger_refs(left);
3623 out.extend(extract_trigger_refs(right));
3624 out
3625 }
3626 _ => vec![],
3627 }
3628}
3629
3630impl Ctx<'_> {
3635 fn check_unused_fields(&mut self) {
3636 let accessed = self.collect_all_accessed_field_names();
3637
3638 for d in &self.module.declarations {
3639 let block = match d {
3640 Decl::Block(b)
3641 if matches!(
3642 b.kind,
3643 BlockKind::Entity | BlockKind::ExternalEntity
3644 ) =>
3645 {
3646 b
3647 }
3648 Decl::Variant(v) => {
3649 let entity_name = &v.name.name;
3650 for item in &v.items {
3651 if let BlockItemKind::Assignment { name, .. }
3652 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3653 {
3654 if !accessed.contains(name.name.as_str()) {
3655 self.push(
3656 Diagnostic::info(
3657 name.span,
3658 format!(
3659 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3660 name.name
3661 ),
3662 )
3663 .with_code("allium.field.unused"),
3664 );
3665 }
3666 }
3667 }
3668 continue;
3669 }
3670 _ => continue,
3671 };
3672
3673 let entity_name = match &block.name {
3674 Some(n) => &n.name,
3675 None => continue,
3676 };
3677
3678 for item in &block.items {
3679 if let BlockItemKind::Assignment { name, .. }
3680 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3681 {
3682 if !accessed.contains(name.name.as_str()) {
3683 self.push(
3684 Diagnostic::info(
3685 name.span,
3686 format!(
3687 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3688 name.name
3689 ),
3690 )
3691 .with_code("allium.field.unused"),
3692 );
3693 }
3694 }
3695 }
3696 }
3697 }
3698}
3699
3700fn collect_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3705 match kind {
3706 BlockItemKind::Clause { value, .. }
3707 | BlockItemKind::Assignment { value, .. }
3708 | BlockItemKind::ParamAssignment { value, .. }
3709 | BlockItemKind::Let { value, .. }
3710 | BlockItemKind::PathAssignment { value, .. }
3711 | BlockItemKind::InvariantBlock { body: value, .. }
3712 | BlockItemKind::FieldWithWhen { value, .. } => collect_idents_from_expr(value, out),
3713 BlockItemKind::ForBlock { collection, filter, items, .. } => {
3714 collect_idents_from_expr(collection, out);
3715 if let Some(f) = filter {
3716 collect_idents_from_expr(f, out);
3717 }
3718 for item in items {
3719 collect_idents_from_item(&item.kind, out);
3720 }
3721 }
3722 BlockItemKind::IfBlock { branches, else_items } => {
3723 for b in branches {
3724 collect_idents_from_expr(&b.condition, out);
3725 for item in &b.items {
3726 collect_idents_from_item(&item.kind, out);
3727 }
3728 }
3729 if let Some(items) = else_items {
3730 for item in items {
3731 collect_idents_from_item(&item.kind, out);
3732 }
3733 }
3734 }
3735 _ => {}
3736 }
3737}
3738
3739fn collect_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3743 match expr {
3744 Expr::Ident(id) => {
3745 out.insert(&id.name);
3746 }
3747 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
3748 collect_idents_from_expr(object, out);
3749 }
3750 Expr::Call { function, args, .. } => {
3751 collect_idents_from_expr(function, out);
3752 for a in args {
3753 match a {
3754 CallArg::Positional(e) => collect_idents_from_expr(e, out),
3755 CallArg::Named(n) => collect_idents_from_expr(&n.value, out),
3756 }
3757 }
3758 }
3759 Expr::BinaryOp { left, right, .. }
3760 | Expr::Comparison { left, right, .. }
3761 | Expr::LogicalOp { left, right, .. }
3762 | Expr::Pipe { left, right, .. }
3763 | Expr::NullCoalesce { left, right, .. } => {
3764 collect_idents_from_expr(left, out);
3765 collect_idents_from_expr(right, out);
3766 }
3767 Expr::Not { operand, .. }
3768 | Expr::Exists { operand, .. }
3769 | Expr::NotExists { operand, .. }
3770 | Expr::TypeOptional { inner: operand, .. } => {
3771 collect_idents_from_expr(operand, out);
3772 }
3773 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3774 collect_idents_from_expr(element, out);
3775 collect_idents_from_expr(collection, out);
3776 }
3777 Expr::Where { source, condition, .. }
3778 | Expr::With { source, predicate: condition, .. } => {
3779 collect_idents_from_expr(source, out);
3780 collect_idents_from_expr(condition, out);
3781 }
3782 Expr::WhenGuard { action, condition, .. } => {
3783 collect_idents_from_expr(action, out);
3784 collect_idents_from_expr(condition, out);
3785 }
3786 Expr::Block { items, .. } => {
3787 for item in items {
3788 collect_idents_from_expr(item, out);
3789 }
3790 }
3791 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3792 collect_idents_from_expr(value, out)
3793 }
3794 Expr::Conditional { branches, else_body, .. } => {
3795 for b in branches {
3796 collect_idents_from_expr(&b.condition, out);
3797 collect_idents_from_expr(&b.body, out);
3798 }
3799 if let Some(body) = else_body {
3800 collect_idents_from_expr(body, out);
3801 }
3802 }
3803 Expr::For { collection, filter, body, .. } => {
3804 collect_idents_from_expr(collection, out);
3805 if let Some(f) = filter {
3806 collect_idents_from_expr(f, out);
3807 }
3808 collect_idents_from_expr(body, out);
3809 }
3810 Expr::Lambda { body, .. } => collect_idents_from_expr(body, out),
3811 Expr::TransitionsTo { subject, new_state, .. }
3812 | Expr::Becomes { subject, new_state, .. } => {
3813 collect_idents_from_expr(subject, out);
3814 collect_idents_from_expr(new_state, out);
3815 }
3816 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
3817 for e in elements {
3818 collect_idents_from_expr(e, out);
3819 }
3820 }
3821 Expr::ObjectLiteral { fields, .. } => {
3822 for f in fields {
3823 collect_idents_from_expr(&f.value, out);
3824 }
3825 }
3826 Expr::GenericType { name, args, .. } => {
3827 collect_idents_from_expr(name, out);
3828 for a in args {
3829 collect_idents_from_expr(a, out);
3830 }
3831 }
3832 Expr::ProjectionMap { source, .. } => collect_idents_from_expr(source, out),
3833 Expr::JoinLookup { entity, fields, .. } => {
3834 collect_idents_from_expr(entity, out);
3835 for f in fields {
3836 if let Some(v) = &f.value {
3837 collect_idents_from_expr(v, out);
3838 }
3839 }
3840 }
3841 _ => {}
3842 }
3843}
3844
3845fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3846 match kind {
3847 BlockItemKind::Clause { value, .. }
3848 | BlockItemKind::Assignment { value, .. }
3849 | BlockItemKind::ParamAssignment { value, .. }
3850 | BlockItemKind::Let { value, .. }
3851 | BlockItemKind::PathAssignment { value, .. }
3852 | BlockItemKind::InvariantBlock { body: value, .. }
3853 | BlockItemKind::FieldWithWhen { value, .. } => {
3854 collect_accessed_fields_from_expr(value, out);
3855 }
3856 BlockItemKind::ForBlock {
3857 collection,
3858 filter,
3859 items,
3860 ..
3861 } => {
3862 collect_accessed_fields_from_expr(collection, out);
3863 if let Some(f) = filter {
3864 collect_accessed_fields_from_expr(f, out);
3865 }
3866 for item in items {
3867 collect_accessed_fields_from_item(&item.kind, out);
3868 }
3869 }
3870 BlockItemKind::IfBlock {
3871 branches,
3872 else_items,
3873 } => {
3874 for b in branches {
3875 collect_accessed_fields_from_expr(&b.condition, out);
3876 for item in &b.items {
3877 collect_accessed_fields_from_item(&item.kind, out);
3878 }
3879 }
3880 if let Some(items) = else_items {
3881 for item in items {
3882 collect_accessed_fields_from_item(&item.kind, out);
3883 }
3884 }
3885 }
3886 _ => {}
3887 }
3888}
3889
3890fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3891 match expr {
3892 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
3893 out.insert(&field.name);
3894 collect_accessed_fields_from_expr(object, out);
3895 }
3896 Expr::Call { function, args, .. } => {
3897 collect_accessed_fields_from_expr(function, out);
3898 let is_created = matches!(
3902 function.as_ref(),
3903 Expr::MemberAccess { field, .. } if field.name == "created"
3904 );
3905 for a in args {
3906 match a {
3907 CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
3908 CallArg::Named(n) => {
3909 if is_created {
3910 out.insert(&n.name.name);
3911 }
3912 collect_accessed_fields_from_expr(&n.value, out);
3913 }
3914 }
3915 }
3916 }
3917 Expr::BinaryOp { left, right, .. }
3918 | Expr::Comparison { left, right, .. }
3919 | Expr::LogicalOp { left, right, .. }
3920 | Expr::Pipe { left, right, .. }
3921 | Expr::NullCoalesce { left, right, .. } => {
3922 collect_accessed_fields_from_expr(left, out);
3923 collect_accessed_fields_from_expr(right, out);
3924 }
3925 Expr::Not { operand, .. }
3926 | Expr::Exists { operand, .. }
3927 | Expr::NotExists { operand, .. }
3928 | Expr::TypeOptional { inner: operand, .. } => {
3929 collect_accessed_fields_from_expr(operand, out);
3930 }
3931 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3932 collect_accessed_fields_from_expr(element, out);
3933 collect_accessed_fields_from_expr(collection, out);
3934 }
3935 Expr::Where { source, condition, .. }
3936 | Expr::With {
3937 source,
3938 predicate: condition,
3939 ..
3940 } => {
3941 collect_accessed_fields_from_expr(source, out);
3942 collect_accessed_fields_from_expr(condition, out);
3943 }
3944 Expr::WhenGuard { action, condition, .. } => {
3945 collect_accessed_fields_from_expr(action, out);
3946 collect_accessed_fields_from_expr(condition, out);
3947 }
3948 Expr::Block { items, .. } => {
3949 for item in items {
3950 collect_accessed_fields_from_expr(item, out);
3951 }
3952 }
3953 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3954 collect_accessed_fields_from_expr(value, out);
3955 }
3956 Expr::Conditional { branches, else_body, .. } => {
3957 for b in branches {
3958 collect_accessed_fields_from_expr(&b.condition, out);
3959 collect_accessed_fields_from_expr(&b.body, out);
3960 }
3961 if let Some(body) = else_body {
3962 collect_accessed_fields_from_expr(body, out);
3963 }
3964 }
3965 Expr::For { collection, filter, body, .. } => {
3966 collect_accessed_fields_from_expr(collection, out);
3967 if let Some(f) = filter {
3968 collect_accessed_fields_from_expr(f, out);
3969 }
3970 collect_accessed_fields_from_expr(body, out);
3971 }
3972 Expr::Lambda { body, .. } => {
3973 collect_accessed_fields_from_expr(body, out);
3974 }
3975 Expr::JoinLookup { entity, fields, .. } => {
3976 collect_accessed_fields_from_expr(entity, out);
3977 for f in fields {
3978 out.insert(&f.field.name);
3979 if let Some(v) = &f.value {
3980 collect_accessed_fields_from_expr(v, out);
3981 }
3982 }
3983 }
3984 Expr::TransitionsTo { subject, new_state, .. }
3985 | Expr::Becomes { subject, new_state, .. } => {
3986 collect_accessed_fields_from_expr(subject, out);
3987 collect_accessed_fields_from_expr(new_state, out);
3988 }
3989 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
3990 for e in elements {
3991 collect_accessed_fields_from_expr(e, out);
3992 }
3993 }
3994 Expr::ObjectLiteral { fields, .. } => {
3995 for f in fields {
3996 collect_accessed_fields_from_expr(&f.value, out);
3997 }
3998 }
3999 Expr::GenericType { name, args, .. } => {
4000 collect_accessed_fields_from_expr(name, out);
4001 for a in args {
4002 collect_accessed_fields_from_expr(a, out);
4003 }
4004 }
4005 Expr::ProjectionMap { source, .. } => {
4006 collect_accessed_fields_from_expr(source, out);
4007 }
4008 _ => {}
4009 }
4010}
4011
4012impl Ctx<'_> {
4017 fn check_unused_entities(&mut self) {
4018 let mut all_idents = self.collect_all_referenced_idents();
4019 for name in self.external_refs {
4021 all_idents.insert(name.as_str());
4022 }
4023 for v in self.variants() {
4025 let base = expr_as_ident(&v.base).or_else(|| {
4026 if let Expr::JoinLookup { entity, .. } = &v.base {
4027 expr_as_ident(entity)
4028 } else {
4029 None
4030 }
4031 });
4032 if let Some(name) = base {
4033 all_idents.insert(name);
4034 }
4035 }
4036 let mut findings = Vec::new();
4037
4038 for d in &self.module.declarations {
4039 let block = match d {
4040 Decl::Block(b)
4041 if matches!(
4042 b.kind,
4043 BlockKind::Entity | BlockKind::ExternalEntity
4044 ) =>
4045 {
4046 b
4047 }
4048 _ => continue,
4049 };
4050 let name = match &block.name {
4051 Some(n) => n,
4052 None => continue,
4053 };
4054 if !all_idents.contains(name.name.as_str()) {
4055 findings.push(
4056 Diagnostic::warning(
4057 name.span,
4058 format!(
4059 "Entity '{}' is declared but not referenced elsewhere in this specification.",
4060 name.name
4061 ),
4062 )
4063 .with_code("allium.entity.unused"),
4064 );
4065 }
4066 }
4067 self.diagnostics.extend(findings);
4068 }
4069
4070 fn check_unused_definitions(&mut self) {
4071 let mut all_idents = self.collect_all_referenced_idents();
4072 for name in self.external_refs {
4074 all_idents.insert(name.as_str());
4075 }
4076 let mut findings = Vec::new();
4077
4078 for d in &self.module.declarations {
4079 match d {
4080 Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
4081 let name = match &b.name {
4082 Some(n) => n,
4083 None => continue,
4084 };
4085 if !all_idents.contains(name.name.as_str()) {
4086 findings.push(
4087 Diagnostic::warning(
4088 name.span,
4089 format!(
4090 "Value '{}' is declared but not referenced elsewhere.",
4091 name.name
4092 ),
4093 )
4094 .with_code("allium.definition.unused"),
4095 );
4096 }
4097 }
4098 _ => {}
4099 }
4100 }
4101 self.diagnostics.extend(findings);
4102 }
4103
4104 fn collect_all_referenced_idents(&self) -> HashSet<&str> {
4107 collect_referenced_ident_nodes(self.module)
4108 .into_iter()
4109 .map(|id| id.name)
4110 .collect()
4111 }
4112}
4113
4114fn collect_uppercase_idents_from_item<'a>(
4115 kind: &'a BlockItemKind,
4116 out: &mut Vec<ReferencedIdent<'a>>,
4117) {
4118 match kind {
4119 BlockItemKind::Clause { value, .. }
4120 | BlockItemKind::Assignment { value, .. }
4121 | BlockItemKind::ParamAssignment { value, .. }
4122 | BlockItemKind::Let { value, .. }
4123 | BlockItemKind::PathAssignment { value, .. }
4124 | BlockItemKind::InvariantBlock { body: value, .. }
4125 | BlockItemKind::FieldWithWhen { value, .. } => {
4126 collect_uppercase_idents_from_expr(value, out);
4127 }
4128 BlockItemKind::ForBlock {
4129 collection,
4130 filter,
4131 items,
4132 ..
4133 } => {
4134 collect_uppercase_idents_from_expr(collection, out);
4135 if let Some(f) = filter {
4136 collect_uppercase_idents_from_expr(f, out);
4137 }
4138 for item in items {
4139 collect_uppercase_idents_from_item(&item.kind, out);
4140 }
4141 }
4142 BlockItemKind::IfBlock {
4143 branches,
4144 else_items,
4145 } => {
4146 for b in branches {
4147 collect_uppercase_idents_from_expr(&b.condition, out);
4148 for item in &b.items {
4149 collect_uppercase_idents_from_item(&item.kind, out);
4150 }
4151 }
4152 if let Some(items) = else_items {
4153 for item in items {
4154 collect_uppercase_idents_from_item(&item.kind, out);
4155 }
4156 }
4157 }
4158 BlockItemKind::ContractsClause { entries } => {
4159 for e in entries {
4160 if e.qualifier.is_none() {
4164 out.push(ReferencedIdent::unqualified(&e.name));
4165 }
4166 }
4167 }
4168 _ => {}
4169 }
4170}
4171
4172fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut Vec<ReferencedIdent<'a>>) {
4173 match expr {
4174 Expr::Ident(id) if starts_uppercase(&id.name) => {
4175 out.push(ReferencedIdent::unqualified(id));
4176 }
4177 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
4178 collect_uppercase_idents_from_expr(object, out);
4179 }
4180 Expr::Call { function, args, .. } => {
4181 collect_uppercase_idents_from_expr(function, out);
4182 for a in args {
4183 match a {
4184 CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
4185 CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
4186 }
4187 }
4188 }
4189 Expr::JoinLookup { entity, fields, .. } => {
4190 collect_uppercase_idents_from_expr(entity, out);
4191 for f in fields {
4192 if let Some(v) = &f.value {
4193 collect_uppercase_idents_from_expr(v, out);
4194 }
4195 }
4196 }
4197 Expr::BinaryOp { left, right, .. }
4198 | Expr::Comparison { left, right, .. }
4199 | Expr::LogicalOp { left, right, .. }
4200 | Expr::Pipe { left, right, .. }
4201 | Expr::NullCoalesce { left, right, .. } => {
4202 collect_uppercase_idents_from_expr(left, out);
4203 collect_uppercase_idents_from_expr(right, out);
4204 }
4205 Expr::Not { operand, .. }
4206 | Expr::Exists { operand, .. }
4207 | Expr::NotExists { operand, .. }
4208 | Expr::TypeOptional { inner: operand, .. } => {
4209 collect_uppercase_idents_from_expr(operand, out);
4210 }
4211 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4212 collect_uppercase_idents_from_expr(element, out);
4213 collect_uppercase_idents_from_expr(collection, out);
4214 }
4215 Expr::Where { source, condition, .. }
4216 | Expr::With {
4217 source,
4218 predicate: condition,
4219 ..
4220 } => {
4221 collect_uppercase_idents_from_expr(source, out);
4222 collect_uppercase_idents_from_expr(condition, out);
4223 }
4224 Expr::WhenGuard { action, condition, .. } => {
4225 collect_uppercase_idents_from_expr(action, out);
4226 collect_uppercase_idents_from_expr(condition, out);
4227 }
4228 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
4229 collect_uppercase_idents_from_expr(value, out);
4230 }
4231 Expr::Block { items, .. } => {
4232 for item in items {
4233 collect_uppercase_idents_from_expr(item, out);
4234 }
4235 }
4236 Expr::Conditional { branches, else_body, .. } => {
4237 for b in branches {
4238 collect_uppercase_idents_from_expr(&b.condition, out);
4239 collect_uppercase_idents_from_expr(&b.body, out);
4240 }
4241 if let Some(body) = else_body {
4242 collect_uppercase_idents_from_expr(body, out);
4243 }
4244 }
4245 Expr::For { collection, filter, body, .. } => {
4246 collect_uppercase_idents_from_expr(collection, out);
4247 if let Some(f) = filter {
4248 collect_uppercase_idents_from_expr(f, out);
4249 }
4250 collect_uppercase_idents_from_expr(body, out);
4251 }
4252 Expr::Lambda { body, .. } => {
4253 collect_uppercase_idents_from_expr(body, out);
4254 }
4255 Expr::TransitionsTo { subject, new_state, .. }
4256 | Expr::Becomes { subject, new_state, .. } => {
4257 collect_uppercase_idents_from_expr(subject, out);
4258 collect_uppercase_idents_from_expr(new_state, out);
4259 }
4260 Expr::GenericType { name, args, .. } => {
4261 collect_uppercase_idents_from_expr(name, out);
4262 for a in args {
4263 collect_uppercase_idents_from_expr(a, out);
4264 }
4265 }
4266 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
4267 for e in elements {
4268 collect_uppercase_idents_from_expr(e, out);
4269 }
4270 }
4271 Expr::ObjectLiteral { fields, .. } => {
4272 for f in fields {
4273 collect_uppercase_idents_from_expr(&f.value, out);
4274 }
4275 }
4276 Expr::ProjectionMap { source, .. } => {
4277 collect_uppercase_idents_from_expr(source, out);
4278 }
4279 Expr::QualifiedName(q) => {
4280 out.push(ReferencedIdent {
4281 name: &q.name,
4282 span: q.span,
4283 qualified: q.qualifier.is_some(),
4284 });
4285 }
4286 _ => {}
4287 }
4288}
4289
4290pub fn collect_qualified_references(module: &Module) -> Vec<(String, String)> {
4300 let mut refs = Vec::new();
4301 for d in &module.declarations {
4302 match d {
4303 Decl::Block(b) => {
4304 for item in &b.items {
4305 collect_qrefs_from_item(&item.kind, &mut refs);
4306 }
4307 }
4308 Decl::Variant(v) => {
4309 collect_qrefs_from_expr(&v.base, &mut refs);
4310 for item in &v.items {
4311 collect_qrefs_from_item(&item.kind, &mut refs);
4312 }
4313 }
4314 Decl::Invariant(inv) => {
4315 collect_qrefs_from_expr(&inv.body, &mut refs);
4316 }
4317 Decl::Default(def) => {
4318 collect_qrefs_from_expr(&def.value, &mut refs);
4319 }
4320 Decl::Deferred(def) => {
4321 collect_qrefs_from_expr(&def.path, &mut refs);
4322 }
4323 _ => {}
4325 }
4326 }
4327 refs
4328}
4329
4330pub fn collect_all_referenced_idents(module: &Module) -> HashSet<String> {
4336 collect_referenced_ident_nodes(module)
4337 .into_iter()
4338 .map(|id| id.name.to_string())
4339 .collect()
4340}
4341
4342struct ReferencedIdent<'a> {
4345 name: &'a str,
4346 span: Span,
4347 qualified: bool,
4351}
4352
4353impl<'a> ReferencedIdent<'a> {
4354 fn unqualified(id: &'a Ident) -> Self {
4355 Self {
4356 name: &id.name,
4357 span: id.span,
4358 qualified: false,
4359 }
4360 }
4361
4362 fn qualified(id: &'a Ident) -> Self {
4363 Self {
4364 name: &id.name,
4365 span: id.span,
4366 qualified: true,
4367 }
4368 }
4369}
4370
4371fn collect_referenced_ident_nodes(module: &Module) -> Vec<ReferencedIdent<'_>> {
4376 let mut idents: Vec<ReferencedIdent<'_>> = Vec::new();
4377 for d in &module.declarations {
4378 match d {
4379 Decl::Block(b) => {
4380 for item in &b.items {
4381 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4382 }
4383 }
4384 Decl::Variant(v) => {
4385 if let Expr::Ident(id) = &v.base {
4386 idents.push(ReferencedIdent::unqualified(id));
4387 }
4388 for item in &v.items {
4389 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4390 }
4391 }
4392 Decl::Invariant(inv) => {
4393 collect_uppercase_idents_from_expr(&inv.body, &mut idents);
4394 }
4395 Decl::Default(def) => {
4396 if let Some(tn) = &def.type_name {
4397 if def.type_alias.is_some() {
4402 idents.push(ReferencedIdent::qualified(tn));
4403 } else {
4404 idents.push(ReferencedIdent::unqualified(tn));
4405 }
4406 }
4407 collect_uppercase_idents_from_expr(&def.value, &mut idents);
4408 }
4409 _ => {}
4410 }
4411 }
4412 idents
4413}
4414
4415pub fn collect_declared_names(module: &Module) -> HashSet<String> {
4422 let mut names = HashSet::new();
4423 for d in &module.declarations {
4424 match d {
4425 Decl::Block(b) => {
4426 if matches!(
4427 b.kind,
4428 BlockKind::Entity
4429 | BlockKind::ExternalEntity
4430 | BlockKind::Value
4431 | BlockKind::Enum
4432 | BlockKind::Actor
4433 | BlockKind::Contract
4434 ) {
4435 if let Some(n) = &b.name {
4436 names.insert(n.name.clone());
4437 }
4438 }
4439 }
4440 Decl::Variant(v) => {
4441 names.insert(v.name.name.clone());
4442 }
4443 _ => {}
4444 }
4445 }
4446 names
4447}
4448
4449pub fn collect_trigger_outputs(module: &Module) -> HashSet<String> {
4456 let mut names: HashSet<&str> = HashSet::new();
4457 for d in &module.declarations {
4458 let Decl::Block(b) = d else { continue };
4459 match b.kind {
4460 BlockKind::Surface => {
4461 for item in &b.items {
4462 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4463 if keyword == "provides" {
4464 collect_call_names(value, &mut names);
4465 }
4466 }
4467 }
4468 }
4469 BlockKind::Rule => {
4470 for item in &b.items {
4471 collect_emitted_trigger_from_item(&item.kind, &mut names);
4472 }
4473 }
4474 _ => {}
4475 }
4476 }
4477 names.into_iter().map(str::to_string).collect()
4478}
4479
4480pub fn collect_reverse_contributions<'a>(
4485 importer: &'a Module,
4486 alias: &str,
4487 imported: &'a Module,
4488) -> ReverseContributions {
4489 let mut out = ReverseContributions::default();
4490
4491 let imported_info = EntityInfo::from_module(imported);
4493 let status_by_entity = imported_info.status_by_entity();
4494
4495 let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
4502 for b in module_blocks(imported, BlockKind::Surface) {
4503 for item in &b.items {
4504 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4505 if keyword == "provides" {
4506 collect_command_param_types(value, &status_by_entity, &mut command_param_types);
4507 }
4508 }
4509 }
4510 }
4511 collect_importer_command_param_types(
4512 importer, alias, &status_by_entity, &mut command_param_types,
4513 );
4514
4515 for b in module_blocks(importer, BlockKind::Surface) {
4517 for item in &b.items {
4518 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4519 if keyword == "provides" {
4520 collect_qualified_provides(value, alias, &mut out.provided_triggers);
4521 }
4522 }
4523 }
4524 }
4525
4526 for rule in module_blocks(importer, BlockKind::Rule) {
4528 for item in &rule.items {
4529 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4530 if keyword == "ensures" {
4531 collect_qualified_created(
4532 value, alias, &status_by_entity, &mut out.assigned_statuses,
4533 );
4534 }
4535 }
4536 }
4537 collect_witnessed_transition(
4538 rule, alias, &command_param_types, &status_by_entity, &mut out,
4539 );
4540 }
4541
4542 out
4543}
4544
4545fn module_blocks(module: &Module, kind: BlockKind) -> impl Iterator<Item = &BlockDecl> {
4548 module.declarations.iter().filter_map(move |d| match d {
4549 Decl::Block(b) if b.kind == kind => Some(b),
4550 _ => None,
4551 })
4552}
4553
4554fn collect_qualified_provides(expr: &Expr, alias: &str, out: &mut HashSet<String>) {
4561 match expr {
4562 Expr::Call { function, .. } => {
4563 if let Expr::QualifiedName(q) = function.as_ref() {
4564 if q.qualifier.as_deref() == Some(alias) && starts_uppercase(&q.name) {
4565 out.insert(q.name.clone());
4566 }
4567 }
4568 }
4569 Expr::Block { items, .. } => {
4570 for item in items {
4571 collect_qualified_provides(item, alias, out);
4572 }
4573 }
4574 Expr::WhenGuard { action, .. } => collect_qualified_provides(action, alias, out),
4575 Expr::Conditional { branches, else_body, .. } => {
4576 for b in branches {
4577 collect_qualified_provides(&b.body, alias, out);
4578 }
4579 if let Some(body) = else_body {
4580 collect_qualified_provides(body, alias, out);
4581 }
4582 }
4583 Expr::For { body, .. } => collect_qualified_provides(body, alias, out),
4584 _ => {}
4585 }
4586}
4587
4588fn collect_importer_command_param_types<'a>(
4594 importer: &'a Module,
4595 alias: &str,
4596 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4597 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
4598) {
4599 for surface in module_blocks(importer, BlockKind::Surface) {
4600 let mut context_types: HashMap<&str, &str> = HashMap::new();
4602 for item in &surface.items {
4603 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4604 if keyword == "context" || keyword == "facing" {
4605 qualified_context_binding(value, alias, status_by_entity, &mut context_types);
4606 }
4607 }
4608 }
4609 for item in &surface.items {
4610 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4611 if keyword == "provides" {
4612 collect_provides_param_types(
4613 value, alias, &context_types, status_by_entity, out,
4614 );
4615 }
4616 }
4617 }
4618 }
4619}
4620
4621fn qualified_context_binding<'a>(
4624 expr: &'a Expr,
4625 alias: &str,
4626 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4627 out: &mut HashMap<&'a str, &'a str>,
4628) {
4629 match expr {
4630 Expr::Binding { name, value, .. } => {
4631 if let Expr::QualifiedName(q) = value.as_ref() {
4632 if q.qualifier.as_deref() == Some(alias) {
4633 if let Some((entity, _)) = status_by_entity.get_key_value(q.name.as_str()) {
4634 out.insert(&name.name, entity);
4635 }
4636 }
4637 }
4638 }
4639 Expr::Block { items, .. } => {
4640 for item in items {
4641 qualified_context_binding(item, alias, status_by_entity, out);
4642 }
4643 }
4644 _ => {}
4645 }
4646}
4647
4648fn collect_provides_param_types<'a>(
4651 expr: &'a Expr,
4652 alias: &str,
4653 context_types: &HashMap<&'a str, &'a str>,
4654 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4655 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
4656) {
4657 match expr {
4658 Expr::Call { function, args, .. } => {
4659 if let Expr::Ident(fn_name) = function.as_ref() {
4660 let params: Vec<Option<&str>> = args
4661 .iter()
4662 .map(|arg| match arg {
4663 CallArg::Positional(Expr::Ident(id)) => {
4664 context_types.get(id.name.as_str()).copied()
4665 }
4666 CallArg::Named(n) => match &n.value {
4667 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => {
4668 status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
4669 }
4670 Expr::Ident(v) => context_types.get(v.name.as_str()).copied(),
4671 _ => None,
4672 },
4673 _ => None,
4674 })
4675 .collect();
4676 if params.iter().any(Option::is_some) {
4677 out.insert(&fn_name.name, params);
4678 }
4679 }
4680 }
4681 Expr::WhenGuard { action, .. } => {
4682 collect_provides_param_types(action, alias, context_types, status_by_entity, out)
4683 }
4684 Expr::Block { items, .. } => {
4685 for item in items {
4686 collect_provides_param_types(item, alias, context_types, status_by_entity, out);
4687 }
4688 }
4689 Expr::Conditional { branches, else_body, .. } => {
4690 for b in branches {
4691 collect_provides_param_types(&b.body, alias, context_types, status_by_entity, out);
4692 }
4693 if let Some(body) = else_body {
4694 collect_provides_param_types(body, alias, context_types, status_by_entity, out);
4695 }
4696 }
4697 Expr::For { body, .. } => {
4698 collect_provides_param_types(body, alias, context_types, status_by_entity, out)
4699 }
4700 _ => {}
4701 }
4702}
4703
4704fn collect_qualified_created(
4707 expr: &Expr,
4708 alias: &str,
4709 status_by_entity: &HashMap<&str, HashSet<&str>>,
4710 out: &mut HashMap<String, HashSet<String>>,
4711) {
4712 match expr {
4713 Expr::Call { function, args, .. } => {
4714 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
4715 if field.name == "created" {
4716 if let Expr::QualifiedName(q) = object.as_ref() {
4717 if q.qualifier.as_deref() == Some(alias) {
4718 if let Some(statuses) = status_by_entity.get(q.name.as_str()) {
4719 for arg in args {
4720 if let CallArg::Named(named) = arg {
4721 if named.name.name == "status" {
4722 if let Expr::Ident(val) = &named.value {
4723 if statuses.contains(val.name.as_str()) {
4724 out.entry(q.name.clone())
4725 .or_default()
4726 .insert(val.name.clone());
4727 }
4728 }
4729 }
4730 }
4731 }
4732 }
4733 }
4734 }
4735 }
4736 }
4737 }
4738 Expr::Block { items, .. } => {
4739 for item in items {
4740 collect_qualified_created(item, alias, status_by_entity, out);
4741 }
4742 }
4743 Expr::Conditional { branches, else_body, .. } => {
4744 for b in branches {
4745 collect_qualified_created(&b.body, alias, status_by_entity, out);
4746 }
4747 if let Some(body) = else_body {
4748 collect_qualified_created(body, alias, status_by_entity, out);
4749 }
4750 }
4751 _ => {}
4752 }
4753}
4754
4755fn collect_witnessed_transition(
4766 rule: &BlockDecl,
4767 alias: &str,
4768 command_param_types: &HashMap<&str, Vec<Option<&str>>>,
4769 status_by_entity: &HashMap<&str, HashSet<&str>>,
4770 out: &mut ReverseContributions,
4771) {
4772 let mut binding_entity: HashMap<&str, &str> = HashMap::new();
4779 let mut trigger_source: HashMap<&str, &str> = HashMap::new();
4780 for item in &rule.items {
4781 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4782 continue;
4783 };
4784 if keyword != "when" {
4785 continue;
4786 }
4787 match value {
4788 Expr::Call { function, args, .. } => {
4789 let trigger_name = match function.as_ref() {
4795 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => Some(&q.name),
4796 Expr::Ident(id) => Some(&id.name),
4797 _ => None,
4798 };
4799 if let Some(name) = trigger_name {
4800 if let Some(params) = command_param_types.get(name.as_str()) {
4801 for (arg, param) in args.iter().zip(params) {
4802 if let (CallArg::Positional(Expr::Ident(b)), Some(entity)) = (arg, param)
4803 {
4804 binding_entity.insert(b.name.as_str(), entity);
4805 }
4806 }
4807 }
4808 }
4809 }
4810 Expr::Binding { name, value: inner, .. } => {
4811 if let Some((entity, source)) =
4812 qualified_transition_trigger(inner, alias, status_by_entity)
4813 {
4814 binding_entity.insert(name.name.as_str(), entity);
4815 trigger_source.insert(name.name.as_str(), source);
4816 }
4817 }
4818 _ => {}
4819 }
4820 }
4821 if binding_entity.is_empty() {
4822 return;
4823 }
4824
4825 let mut froms: HashMap<&str, HashSet<&str>> = HashMap::new();
4828 let mut tos: HashMap<&str, HashSet<&str>> = HashMap::new();
4829 for (binding, source) in &trigger_source {
4830 froms.entry(binding).or_default().insert(source);
4831 }
4832 for item in &rule.items {
4833 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4834 continue;
4835 };
4836 let target = match keyword.as_str() {
4837 "requires" => &mut froms,
4838 "ensures" => &mut tos,
4839 _ => continue,
4840 };
4841 collect_binding_status_eq(value, &mut |binding, status| {
4842 target.entry(binding).or_default().insert(status);
4843 });
4844 }
4845
4846 for (binding, entity) in &binding_entity {
4847 let Some(valid) = status_by_entity.get(*entity) else {
4848 continue;
4849 };
4850 let Some(to_set) = tos.get(binding) else {
4851 continue;
4852 };
4853 for to in to_set {
4854 if !valid.contains(to) {
4855 continue;
4856 }
4857 out.assigned_statuses
4858 .entry((*entity).to_string())
4859 .or_default()
4860 .insert((*to).to_string());
4861 if let Some(from_set) = froms.get(binding) {
4862 for from in from_set {
4863 if valid.contains(from) {
4864 out.witnessed_transitions
4865 .entry((*entity).to_string())
4866 .or_default()
4867 .insert(((*from).to_string(), (*to).to_string()));
4868 }
4869 }
4870 }
4871 }
4872 }
4873}
4874
4875fn qualified_transition_trigger<'a>(
4881 expr: &'a Expr,
4882 alias: &str,
4883 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4884) -> Option<(&'a str, &'a str)> {
4885 let (subject, new_state) = match expr {
4886 Expr::Becomes { subject, new_state, .. }
4887 | Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
4888 _ => return None,
4889 };
4890 let Expr::MemberAccess { object, field, .. } = subject else {
4891 return None;
4892 };
4893 if field.name != "status" {
4894 return None;
4895 }
4896 let Expr::QualifiedName(q) = object.as_ref() else {
4897 return None;
4898 };
4899 if q.qualifier.as_deref() != Some(alias) {
4900 return None;
4901 }
4902 let (entity, values) = status_by_entity.get_key_value(q.name.as_str())?;
4903 let source = expr_as_ident(new_state)?;
4904 if !values.contains(source) {
4905 return None;
4906 }
4907 Some((*entity, source))
4908}
4909
4910fn collect_binding_status_eq<'a>(expr: &'a Expr, cb: &mut impl FnMut(&'a str, &'a str)) {
4913 match expr {
4914 Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
4915 if let (Some((binding, "status")), Some(status)) =
4916 (expr_as_member_access(left), expr_as_ident(right))
4917 {
4918 cb(binding, status);
4919 }
4920 }
4921 Expr::LogicalOp { left, right, .. } => {
4922 collect_binding_status_eq(left, cb);
4923 collect_binding_status_eq(right, cb);
4924 }
4925 Expr::Block { items, .. } => {
4926 for item in items {
4927 collect_binding_status_eq(item, cb);
4928 }
4929 }
4930 Expr::Conditional { branches, else_body, .. } => {
4931 for b in branches {
4932 collect_binding_status_eq(&b.body, cb);
4933 }
4934 if let Some(body) = else_body {
4935 collect_binding_status_eq(body, cb);
4936 }
4937 }
4938 _ => {}
4939 }
4940}
4941
4942pub fn collect_entity_field_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
4948 let mut out: HashMap<String, HashSet<String>> = HashMap::new();
4949 for (name, fields) in collect_local_type_schemas(module) {
4950 out.insert(
4951 name.to_string(),
4952 fields.keys().map(|f| f.to_string()).collect(),
4953 );
4954 }
4955 out
4956}
4957
4958fn collect_qrefs_from_item(kind: &BlockItemKind, out: &mut Vec<(String, String)>) {
4959 match kind {
4960 BlockItemKind::Clause { value, .. }
4961 | BlockItemKind::Assignment { value, .. }
4962 | BlockItemKind::ParamAssignment { value, .. }
4963 | BlockItemKind::Let { value, .. }
4964 | BlockItemKind::PathAssignment { value, .. }
4965 | BlockItemKind::InvariantBlock { body: value, .. }
4966 | BlockItemKind::FieldWithWhen { value, .. } => {
4967 collect_qrefs_from_expr(value, out);
4968 }
4969 BlockItemKind::ForBlock {
4970 collection,
4971 filter,
4972 items,
4973 ..
4974 } => {
4975 collect_qrefs_from_expr(collection, out);
4976 if let Some(f) = filter {
4977 collect_qrefs_from_expr(f, out);
4978 }
4979 for item in items {
4980 collect_qrefs_from_item(&item.kind, out);
4981 }
4982 }
4983 BlockItemKind::IfBlock {
4984 branches,
4985 else_items,
4986 } => {
4987 for b in branches {
4988 collect_qrefs_from_expr(&b.condition, out);
4989 for item in &b.items {
4990 collect_qrefs_from_item(&item.kind, out);
4991 }
4992 }
4993 if let Some(items) = else_items {
4994 for item in items {
4995 collect_qrefs_from_item(&item.kind, out);
4996 }
4997 }
4998 }
4999 BlockItemKind::ContractsClause { entries } => {
5000 for e in entries {
5001 if let Some(ref qualifier) = e.qualifier {
5002 out.push((qualifier.clone(), e.name.name.clone()));
5003 }
5004 }
5005 }
5006 _ => {}
5009 }
5010}
5011
5012fn collect_qrefs_from_expr(expr: &Expr, out: &mut Vec<(String, String)>) {
5013 match expr {
5014 Expr::QualifiedName(q) => {
5015 if let Some(ref qualifier) = q.qualifier {
5016 out.push((qualifier.clone(), q.name.clone()));
5017 }
5018 }
5019 Expr::MemberAccess { object, field, .. }
5020 | Expr::OptionalAccess { object, field, .. } => {
5021 if let Expr::Ident(id) = object.as_ref() {
5023 if starts_uppercase(&field.name) {
5024 out.push((id.name.clone(), field.name.clone()));
5025 }
5026 }
5027 collect_qrefs_from_expr(object, out);
5028 }
5029 Expr::Call { function, args, .. } => {
5030 collect_qrefs_from_expr(function, out);
5031 for a in args {
5032 match a {
5033 CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
5034 CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
5035 }
5036 }
5037 }
5038 Expr::JoinLookup { entity, fields, .. } => {
5039 collect_qrefs_from_expr(entity, out);
5040 for f in fields {
5041 if let Some(v) = &f.value {
5042 collect_qrefs_from_expr(v, out);
5043 }
5044 }
5045 }
5046 Expr::BinaryOp { left, right, .. }
5047 | Expr::Comparison { left, right, .. }
5048 | Expr::LogicalOp { left, right, .. }
5049 | Expr::Pipe { left, right, .. }
5050 | Expr::NullCoalesce { left, right, .. } => {
5051 collect_qrefs_from_expr(left, out);
5052 collect_qrefs_from_expr(right, out);
5053 }
5054 Expr::Not { operand, .. }
5055 | Expr::Exists { operand, .. }
5056 | Expr::NotExists { operand, .. }
5057 | Expr::TypeOptional { inner: operand, .. } => {
5058 collect_qrefs_from_expr(operand, out);
5059 }
5060 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5061 collect_qrefs_from_expr(element, out);
5062 collect_qrefs_from_expr(collection, out);
5063 }
5064 Expr::Where { source, condition, .. }
5065 | Expr::With {
5066 source,
5067 predicate: condition,
5068 ..
5069 } => {
5070 collect_qrefs_from_expr(source, out);
5071 collect_qrefs_from_expr(condition, out);
5072 }
5073 Expr::WhenGuard { action, condition, .. } => {
5074 collect_qrefs_from_expr(action, out);
5075 collect_qrefs_from_expr(condition, out);
5076 }
5077 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
5078 collect_qrefs_from_expr(value, out);
5079 }
5080 Expr::Block { items, .. } => {
5081 for item in items {
5082 collect_qrefs_from_expr(item, out);
5083 }
5084 }
5085 Expr::Conditional {
5086 branches,
5087 else_body,
5088 ..
5089 } => {
5090 for b in branches {
5091 collect_qrefs_from_expr(&b.condition, out);
5092 collect_qrefs_from_expr(&b.body, out);
5093 }
5094 if let Some(body) = else_body {
5095 collect_qrefs_from_expr(body, out);
5096 }
5097 }
5098 Expr::For {
5099 collection,
5100 filter,
5101 body,
5102 ..
5103 } => {
5104 collect_qrefs_from_expr(collection, out);
5105 if let Some(f) = filter {
5106 collect_qrefs_from_expr(f, out);
5107 }
5108 collect_qrefs_from_expr(body, out);
5109 }
5110 Expr::Lambda { body, .. } => {
5111 collect_qrefs_from_expr(body, out);
5112 }
5113 Expr::TransitionsTo {
5114 subject, new_state, ..
5115 }
5116 | Expr::Becomes {
5117 subject, new_state, ..
5118 } => {
5119 collect_qrefs_from_expr(subject, out);
5120 collect_qrefs_from_expr(new_state, out);
5121 }
5122 Expr::GenericType { name, args, .. } => {
5123 collect_qrefs_from_expr(name, out);
5124 for a in args {
5125 collect_qrefs_from_expr(a, out);
5126 }
5127 }
5128 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
5129 for e in elements {
5130 collect_qrefs_from_expr(e, out);
5131 }
5132 }
5133 Expr::ObjectLiteral { fields, .. } => {
5134 for f in fields {
5135 collect_qrefs_from_expr(&f.value, out);
5136 }
5137 }
5138 Expr::ProjectionMap { source, .. } => {
5139 collect_qrefs_from_expr(source, out);
5140 }
5141 _ => {}
5142 }
5143}
5144
5145impl Ctx<'_> {
5150 fn check_deferred_location_hints(&mut self, source: &str) {
5151 for d in &self.module.declarations {
5152 let Decl::Deferred(def) = d else {
5153 continue;
5154 };
5155 const LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
5176 let bytes = source.as_bytes();
5177 let kw_start = def.span.start;
5178 let line_start = source[..kw_start]
5179 .rfind(LINE_TERMINATORS)
5180 .map_or(0, |i| {
5181 i + source[i..].chars().next().map_or(1, char::len_utf8)
5182 });
5183 let mut name_start = kw_start + "deferred".len();
5184 while bytes.get(name_start).is_some_and(u8::is_ascii_whitespace) {
5185 name_start += 1;
5186 }
5187 let starts_name =
5188 |b: &u8| b.is_ascii_alphabetic() || *b == b'_';
5189 let continues_name =
5190 |b: &u8| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.';
5191 if !bytes[line_start..kw_start]
5192 .iter()
5193 .all(|b| b.is_ascii_whitespace())
5194 || name_start == kw_start + "deferred".len()
5195 || !bytes.get(name_start).is_some_and(starts_name)
5196 {
5197 continue;
5201 }
5202 let mut name_end = name_start + 1;
5203 while bytes.get(name_end).is_some_and(continues_name) {
5204 name_end += 1;
5205 }
5206 let line_end = source[name_end..]
5207 .find(LINE_TERMINATORS)
5208 .map_or(source.len(), |i| name_end + i);
5209 let suffix = &source[name_end..line_end];
5210 if suffix.contains('"')
5211 || suffix.contains("http://")
5212 || suffix.contains("https://")
5213 || suffix.contains("-- see:")
5214 {
5215 continue;
5216 }
5217 self.push(
5218 Diagnostic::warning(
5219 def.span,
5220 format!(
5221 "Deferred specification '{}' should include a location hint.",
5222 &source[name_start..name_end],
5223 ),
5224 )
5225 .with_code("allium.deferred.missingLocationHint"),
5226 );
5227 }
5228 }
5229}
5230
5231impl Ctx<'_> {
5236 fn check_rule_invalid_triggers(&mut self) {
5237 for rule in self.blocks(BlockKind::Rule) {
5238 let rule_name = match &rule.name {
5239 Some(n) => &n.name,
5240 None => continue,
5241 };
5242
5243 for item in &rule.items {
5244 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5245 continue;
5246 };
5247 if keyword != "when" {
5248 continue;
5249 }
5250 if !is_valid_trigger(value) {
5251 self.push(
5252 Diagnostic::error(
5253 item.span,
5254 format!(
5255 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
5256 ),
5257 )
5258 .with_code("allium.rule.invalidTrigger"),
5259 );
5260 } else if let Some((param, span)) = first_named_trigger_param(value) {
5261 self.push(
5262 Diagnostic::error(
5263 span,
5264 format!(
5265 "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.",
5266 ),
5267 )
5268 .with_code("allium.rule.invalidTrigger"),
5269 );
5270 }
5271 }
5272 }
5273 }
5274}
5275
5276fn first_named_trigger_param(expr: &Expr) -> Option<(&str, Span)> {
5282 match expr {
5283 Expr::Call { args, .. } => args.iter().find_map(|arg| match arg {
5284 CallArg::Named(n) => Some((n.name.name.as_str(), n.span)),
5285 _ => None,
5286 }),
5287 Expr::LogicalOp {
5288 op: LogicalOp::Or,
5289 left,
5290 right,
5291 ..
5292 } => first_named_trigger_param(left).or_else(|| first_named_trigger_param(right)),
5293 _ => None,
5294 }
5295}
5296
5297fn literal_kind(expr: &Expr) -> Option<&'static str> {
5307 match expr {
5308 Expr::StringLiteral(_) => Some("string"),
5309 Expr::NumberLiteral { .. } => Some("number"),
5310 Expr::BoolLiteral { .. } => Some("boolean"),
5311 Expr::DurationLiteral { .. } => Some("duration"),
5312 Expr::BacktickLiteral { .. } => Some("backtick literal"),
5313 _ => None,
5314 }
5315}
5316
5317impl Ctx<'_> {
5318 fn check_list_literal_homogeneity(&mut self) {
5319 let mut lists: Vec<(&[Expr], Span)> = Vec::new();
5320 for d in &self.module.declarations {
5321 match d {
5322 Decl::Block(b) => {
5323 for item in &b.items {
5324 collect_list_literals_from_item(&item.kind, &mut lists);
5325 }
5326 }
5327 Decl::Variant(v) => {
5328 for item in &v.items {
5329 collect_list_literals_from_item(&item.kind, &mut lists);
5330 }
5331 }
5332 Decl::Invariant(inv) => collect_list_literals_from_expr(&inv.body, &mut lists),
5333 Decl::Default(def) => collect_list_literals_from_expr(&def.value, &mut lists),
5334 _ => {}
5335 }
5336 }
5337
5338 for (elements, span) in lists {
5339 let mut first: Option<&'static str> = None;
5343 for e in elements {
5344 let Some(kind) = literal_kind(e) else { continue };
5345 match first {
5346 None => first = Some(kind),
5347 Some(expected) if expected != kind => {
5348 self.push(
5349 Diagnostic::error(
5350 span,
5351 format!(
5352 "List literal has elements of differing types ('{expected}' and '{kind}'); all elements of a list must share a type.",
5353 ),
5354 )
5355 .with_code("allium.list.mixedElementTypes"),
5356 );
5357 break;
5358 }
5359 _ => {}
5360 }
5361 }
5362 }
5363 }
5364}
5365
5366impl Ctx<'_> {
5367 fn check_qualified_default_aliases(&mut self) {
5371 let mut aliases: HashSet<&str> = HashSet::new();
5372 for d in &self.module.declarations {
5373 if let Decl::Use(u) = d {
5374 if let Some(alias) = &u.alias {
5375 aliases.insert(alias.name.as_str());
5376 }
5377 }
5378 }
5379 for d in &self.module.declarations {
5380 let Decl::Default(def) = d else { continue };
5381 let (Some(alias), Some(type_name)) = (&def.type_alias, &def.type_name) else {
5382 continue;
5383 };
5384 if !aliases.contains(alias.name.as_str()) {
5385 self.push(
5386 Diagnostic::error(
5387 alias.span.merge(type_name.span),
5388 format!(
5389 "Type reference '{}/{}' uses unknown import alias '{}'.",
5390 alias.name, type_name.name, alias.name
5391 ),
5392 )
5393 .with_code("allium.default.undefinedImportedAlias"),
5394 );
5395 }
5396 }
5397 }
5398
5399 fn check_default_field_schemas(&mut self) {
5410 let schemas = collect_local_type_schemas(self.module);
5411 let mut diagnostics = Vec::new();
5412 for d in &self.module.declarations {
5413 let Decl::Default(def) = d else { continue };
5414 let (Some(type_name), Expr::ObjectLiteral { fields, .. }) =
5415 (&def.type_name, &def.value)
5416 else {
5417 continue;
5418 };
5419 match &def.type_alias {
5420 None => {
5421 validate_object_literal(fields, &type_name.name, &schemas, &mut diagnostics);
5422 }
5423 Some(alias) => {
5424 if let Some(imported) = self
5432 .imported_entity_fields
5433 .and_then(|m| m.get(alias.name.as_str()))
5434 .and_then(|types| types.get(type_name.name.as_str()))
5435 {
5436 for field in fields {
5437 if !imported.contains(field.name.name.as_str()) {
5438 diagnostics.push(
5439 Diagnostic::error(
5440 field.name.span,
5441 format!(
5442 "Default sets field '{}' which is not declared on '{}/{}'.",
5443 field.name.name, alias.name, type_name.name
5444 ),
5445 )
5446 .with_code("allium.default.unknownField"),
5447 );
5448 }
5449 }
5450 }
5451 }
5452 }
5453 }
5454 for diag in diagnostics {
5455 self.push(diag);
5456 }
5457 }
5458}
5459
5460fn collect_local_type_schemas(module: &Module) -> HashMap<&str, HashMap<&str, &Expr>> {
5462 let mut schemas: HashMap<&str, HashMap<&str, &Expr>> = HashMap::new();
5463 for d in &module.declarations {
5464 let Decl::Block(b) = d else { continue };
5465 if !matches!(
5466 b.kind,
5467 BlockKind::Entity | BlockKind::ExternalEntity | BlockKind::Value
5468 ) {
5469 continue;
5470 }
5471 let Some(name) = &b.name else { continue };
5472 let mut fields: HashMap<&str, &Expr> = HashMap::new();
5473 for item in &b.items {
5474 match &item.kind {
5475 BlockItemKind::Assignment { name: f, value }
5476 | BlockItemKind::FieldWithWhen { name: f, value, .. } => {
5477 fields.insert(f.name.as_str(), value);
5478 }
5479 _ => {}
5480 }
5481 }
5482 schemas.insert(name.name.as_str(), fields);
5483 }
5484 schemas
5485}
5486
5487fn is_list_type(expr: &Expr) -> bool {
5490 match expr {
5491 Expr::GenericType { name, .. } => matches!(name.as_ref(), Expr::Ident(id) if id.name == "List"),
5492 Expr::TypeOptional { inner, .. } => is_list_type(inner),
5493 _ => false,
5494 }
5495}
5496
5497fn base_type_name(expr: &Expr) -> Option<&str> {
5501 match expr {
5502 Expr::Ident(id) => Some(id.name.as_str()),
5503 Expr::TypeOptional { inner, .. } => base_type_name(inner),
5504 _ => None,
5505 }
5506}
5507
5508fn validate_object_literal<'a>(
5509 fields: &'a [NamedArg],
5510 type_name: &str,
5511 schemas: &HashMap<&'a str, HashMap<&'a str, &'a Expr>>,
5512 out: &mut Vec<Diagnostic>,
5513) {
5514 let Some(schema) = schemas.get(type_name) else { return };
5517 for field in fields {
5518 let Some(field_type) = schema.get(field.name.name.as_str()) else {
5519 out.push(
5520 Diagnostic::error(
5521 field.name.span,
5522 format!(
5523 "Default sets field '{}' which is not declared on '{}'.",
5524 field.name.name, type_name
5525 ),
5526 )
5527 .with_code("allium.default.unknownField"),
5528 );
5529 continue;
5530 };
5531 if let Expr::ListLiteral { elements, span } = &field.value {
5534 if elements.is_empty() && !is_list_type(field_type) {
5535 out.push(
5536 Diagnostic::error(
5537 *span,
5538 format!(
5539 "Empty list literal has no inferable element type: target field '{}' is not a List<T>.",
5540 field.name.name
5541 ),
5542 )
5543 .with_code("allium.list.emptyListNoElementType"),
5544 );
5545 }
5546 }
5547 if let Expr::ObjectLiteral { fields: nested, .. } = &field.value {
5549 if let Some(nested_type) = base_type_name(field_type) {
5550 validate_object_literal(nested, nested_type, schemas, out);
5551 }
5552 }
5553 }
5554}
5555
5556fn collect_list_literals_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<(&'a [Expr], Span)>) {
5557 match kind {
5558 BlockItemKind::Clause { value, .. }
5559 | BlockItemKind::Assignment { value, .. }
5560 | BlockItemKind::ParamAssignment { value, .. }
5561 | BlockItemKind::Let { value, .. }
5562 | BlockItemKind::PathAssignment { value, .. }
5563 | BlockItemKind::InvariantBlock { body: value, .. }
5564 | BlockItemKind::FieldWithWhen { value, .. } => {
5565 collect_list_literals_from_expr(value, out);
5566 }
5567 BlockItemKind::ForBlock { collection, filter, items, .. } => {
5568 collect_list_literals_from_expr(collection, out);
5569 if let Some(f) = filter {
5570 collect_list_literals_from_expr(f, out);
5571 }
5572 for item in items {
5573 collect_list_literals_from_item(&item.kind, out);
5574 }
5575 }
5576 BlockItemKind::IfBlock { branches, else_items } => {
5577 for b in branches {
5578 collect_list_literals_from_expr(&b.condition, out);
5579 for item in &b.items {
5580 collect_list_literals_from_item(&item.kind, out);
5581 }
5582 }
5583 if let Some(items) = else_items {
5584 for item in items {
5585 collect_list_literals_from_item(&item.kind, out);
5586 }
5587 }
5588 }
5589 _ => {}
5590 }
5591}
5592
5593fn collect_list_literals_from_expr<'a>(expr: &'a Expr, out: &mut Vec<(&'a [Expr], Span)>) {
5594 if let Expr::ListLiteral { elements, span } = expr {
5595 out.push((elements, *span));
5596 }
5597 walk_expr_children(expr, &mut |child| collect_list_literals_from_expr(child, out));
5598}
5599
5600fn walk_expr_children<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) {
5604 match expr {
5605 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => f(object),
5606 Expr::Call { function, args, .. } => {
5607 f(function);
5608 for a in args {
5609 match a {
5610 CallArg::Positional(e) => f(e),
5611 CallArg::Named(n) => f(&n.value),
5612 }
5613 }
5614 }
5615 Expr::BinaryOp { left, right, .. }
5616 | Expr::Comparison { left, right, .. }
5617 | Expr::LogicalOp { left, right, .. }
5618 | Expr::Pipe { left, right, .. }
5619 | Expr::NullCoalesce { left, right, .. } => {
5620 f(left);
5621 f(right);
5622 }
5623 Expr::Not { operand, .. }
5624 | Expr::Exists { operand, .. }
5625 | Expr::NotExists { operand, .. }
5626 | Expr::TypeOptional { inner: operand, .. } => f(operand),
5627 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5628 f(element);
5629 f(collection);
5630 }
5631 Expr::Where { source, condition, .. }
5632 | Expr::With { source, predicate: condition, .. } => {
5633 f(source);
5634 f(condition);
5635 }
5636 Expr::WhenGuard { action, condition, .. } => {
5637 f(action);
5638 f(condition);
5639 }
5640 Expr::Block { items, .. } => {
5641 for item in items {
5642 f(item);
5643 }
5644 }
5645 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => f(value),
5646 Expr::Conditional { branches, else_body, .. } => {
5647 for b in branches {
5648 f(&b.condition);
5649 f(&b.body);
5650 }
5651 if let Some(body) = else_body {
5652 f(body);
5653 }
5654 }
5655 Expr::For { collection, filter, body, .. } => {
5656 f(collection);
5657 if let Some(filt) = filter {
5658 f(filt);
5659 }
5660 f(body);
5661 }
5662 Expr::Lambda { body, .. } => f(body),
5663 Expr::JoinLookup { entity, fields, .. } => {
5664 f(entity);
5665 for jf in fields {
5666 if let Some(v) = &jf.value {
5667 f(v);
5668 }
5669 }
5670 }
5671 Expr::TransitionsTo { subject, new_state, .. }
5672 | Expr::Becomes { subject, new_state, .. } => {
5673 f(subject);
5674 f(new_state);
5675 }
5676 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
5677 for e in elements {
5678 f(e);
5679 }
5680 }
5681 Expr::ObjectLiteral { fields, .. } => {
5682 for fld in fields {
5683 f(&fld.value);
5684 }
5685 }
5686 Expr::GenericType { name, args, .. } => {
5687 f(name);
5688 for a in args {
5689 f(a);
5690 }
5691 }
5692 Expr::ProjectionMap { source, .. } => f(source),
5693 _ => {}
5694 }
5695}
5696
5697fn is_valid_trigger(expr: &Expr) -> bool {
5698 match expr {
5699 Expr::Call { function, .. } => {
5705 matches!(
5706 function.as_ref(),
5707 Expr::Ident(_) | Expr::MemberAccess { .. } | Expr::QualifiedName(_)
5708 )
5709 }
5710 Expr::Binding { value, .. } => {
5712 matches!(
5713 value.as_ref(),
5714 Expr::Becomes { .. }
5715 | Expr::TransitionsTo { .. }
5716 | Expr::MemberAccess { .. }
5717 | Expr::Comparison { .. }
5718 )
5719 }
5720 Expr::LogicalOp {
5722 op: LogicalOp::Or,
5723 left,
5724 right,
5725 ..
5726 } => is_valid_trigger(left) && is_valid_trigger(right),
5727 Expr::Comparison { left, .. } => {
5729 matches!(left.as_ref(), Expr::MemberAccess { .. })
5730 }
5731 _ => false,
5732 }
5733}
5734
5735impl Ctx<'_> {
5740 fn check_rule_undefined_bindings(&mut self) {
5741 let mut given_bindings: HashSet<&str> = HashSet::new();
5743 for given in self.blocks(BlockKind::Given) {
5744 for item in &given.items {
5745 if let BlockItemKind::Assignment { name, .. } = &item.kind {
5746 given_bindings.insert(&name.name);
5747 }
5748 }
5749 }
5750
5751 let mut default_names: HashSet<&str> = HashSet::new();
5753 for d in &self.module.declarations {
5754 if let Decl::Default(def) = d {
5755 default_names.insert(&def.name.name);
5756 }
5757 }
5758
5759 for rule in self.blocks(BlockKind::Rule) {
5760 let rule_name = match &rule.name {
5761 Some(n) => &n.name,
5762 None => continue,
5763 };
5764
5765 let mut bound: HashSet<&str> = HashSet::new();
5766 bound.extend(&given_bindings);
5767 bound.extend(&default_names);
5768
5769 for item in &rule.items {
5771 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5772 continue;
5773 };
5774 if keyword != "when" {
5775 continue;
5776 }
5777 collect_bound_names(value, &mut bound);
5778 }
5779
5780 for item in &rule.items {
5782 if let BlockItemKind::Let { name, .. } = &item.kind {
5783 bound.insert(&name.name);
5784 }
5785 }
5786
5787 for item in &rule.items {
5789 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5790 continue;
5791 };
5792 if keyword != "requires" && keyword != "ensures" {
5793 continue;
5794 }
5795 check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
5796 }
5797
5798 for item in &rule.items {
5800 match &item.kind {
5801 BlockItemKind::ForBlock {
5802 binding,
5803 items,
5804 ..
5805 } => {
5806 let mut inner_bound = bound.clone();
5807 match binding {
5808 ForBinding::Single(id) => { inner_bound.insert(&id.name); }
5809 ForBinding::Destructured(ids, _) => {
5810 for id in ids {
5811 inner_bound.insert(&id.name);
5812 }
5813 }
5814 }
5815 for sub_item in items {
5816 if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
5817 if keyword == "ensures" || keyword == "requires" {
5818 check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
5819 }
5820 }
5821 }
5822 }
5823 _ => {}
5824 }
5825 }
5826
5827 for item in &rule.items {
5831 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
5832 if keyword != "when" { continue }
5833 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
5834 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
5835 continue;
5836 }
5837 let mut found = false;
5839 for check_item in &rule.items {
5840 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
5841 if kw != "requires" && kw != "ensures" { continue }
5842 if expr_contains_ident(v, &binding_name.name) {
5843 self.push(
5844 Diagnostic::error(
5845 check_item.span,
5846 format!(
5847 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
5848 binding_name.name
5849 ),
5850 )
5851 .with_code("allium.rule.undefinedBinding"),
5852 );
5853 found = true;
5854 break;
5855 }
5856 }
5857 if found { break; }
5858 }
5859 }
5860 }
5861}
5862
5863fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
5864 match expr {
5865 Expr::Binding { name, .. } => {
5866 out.insert(&name.name);
5867 }
5868 Expr::Call { args, .. } => {
5869 for arg in args {
5870 match arg {
5871 CallArg::Positional(Expr::Ident(id)) => {
5872 out.insert(&id.name);
5873 }
5874 CallArg::Named(n) => {
5880 out.insert(&n.name.name);
5881 }
5882 _ => {}
5883 }
5884 }
5885 }
5886 Expr::LogicalOp { left, right, .. } => {
5887 collect_bound_names(left, out);
5888 collect_bound_names(right, out);
5889 }
5890 _ => {}
5891 }
5892}
5893
5894fn check_unbound_roots(
5895 expr: &Expr,
5896 bound: &HashSet<&str>,
5897 rule_name: &str,
5898 diagnostics: &mut Vec<Diagnostic>,
5899) {
5900 match expr {
5901 Expr::MemberAccess { object, .. } => {
5902 if let Expr::Ident(id) = object.as_ref() {
5903 if !starts_uppercase(&id.name)
5904 && !bound.contains(id.name.as_str())
5905 && !is_builtin_name(&id.name)
5906 {
5907 diagnostics.push(
5908 Diagnostic::error(
5909 id.span,
5910 format!(
5911 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
5912 id.name
5913 ),
5914 )
5915 .with_code("allium.rule.undefinedBinding"),
5916 );
5917 }
5918 }
5919 }
5920 Expr::Comparison { left, right, .. } => {
5921 check_unbound_roots(left, bound, rule_name, diagnostics);
5922 check_unbound_roots(right, bound, rule_name, diagnostics);
5923 }
5924 Expr::LogicalOp { left, right, .. } => {
5925 check_unbound_roots(left, bound, rule_name, diagnostics);
5926 check_unbound_roots(right, bound, rule_name, diagnostics);
5927 }
5928 Expr::Block { items, .. } => {
5929 let mut block_bound = bound.clone();
5930 for item in items {
5931 if let Expr::LetExpr { name, value, .. } = item {
5932 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
5933 block_bound.insert(name.name.as_str());
5934 } else {
5935 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
5936 }
5937 }
5938 }
5939 Expr::For { binding, collection, body, .. } => {
5940 check_unbound_roots(collection, bound, rule_name, diagnostics);
5941 let mut inner = bound.clone();
5943 match binding {
5944 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
5945 ForBinding::Destructured(ids, _) => {
5946 for id in ids {
5947 inner.insert(id.name.as_str());
5948 }
5949 }
5950 }
5951 check_unbound_roots(body, &inner, rule_name, diagnostics);
5952 }
5953 Expr::BinaryOp { left, right, .. } => {
5954 check_unbound_roots(left, bound, rule_name, diagnostics);
5955 check_unbound_roots(right, bound, rule_name, diagnostics);
5956 }
5957 Expr::Call { function, args, .. } => {
5958 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
5960 check_unbound_roots(function, bound, rule_name, diagnostics);
5961 }
5962 let mut call_bound = bound.clone();
5964 for a in args {
5965 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
5966 if let Expr::Ident(id) = param.as_ref() {
5967 call_bound.insert(id.name.as_str());
5968 }
5969 }
5970 }
5971 for a in args {
5972 match a {
5973 CallArg::Positional(Expr::Lambda { body, .. }) => {
5974 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
5975 }
5976 CallArg::Positional(e) => {
5977 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
5978 }
5979 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
5980 }
5981 }
5982 }
5983 Expr::Not { operand, .. }
5984 | Expr::Exists { operand, .. }
5985 | Expr::NotExists { operand, .. } => {
5986 check_unbound_roots(operand, bound, rule_name, diagnostics);
5987 }
5988 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5989 check_unbound_roots(element, bound, rule_name, diagnostics);
5990 check_unbound_roots(collection, bound, rule_name, diagnostics);
5991 }
5992 Expr::Conditional { branches, else_body, .. } => {
5993 for b in branches {
5994 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
5995 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
5996 }
5997 if let Some(body) = else_body {
5998 check_unbound_roots(body, bound, rule_name, diagnostics);
5999 }
6000 }
6001 _ => {}
6002 }
6003}
6004
6005fn is_builtin_name(name: &str) -> bool {
6006 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
6007}
6008
6009impl Ctx<'_> {
6014 fn check_duplicate_let_bindings(&mut self) {
6015 for rule in self.blocks(BlockKind::Rule) {
6016 let mut seen: HashMap<&str, Span> = HashMap::new();
6017 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
6018 }
6019 }
6020
6021 fn check_duplicate_lets_in_items<'b>(
6022 &mut self,
6023 items: &'b [BlockItem],
6024 seen: &mut HashMap<&'b str, Span>,
6025 ) {
6026 for item in items {
6027 match &item.kind {
6028 BlockItemKind::Let { name, .. } => {
6029 if seen.contains_key(name.name.as_str()) {
6030 self.push(
6031 Diagnostic::error(
6032 name.span,
6033 format!("Duplicate let binding '{}' in this rule.", name.name),
6034 )
6035 .with_code("allium.let.duplicateBinding"),
6036 );
6037 } else {
6038 seen.insert(&name.name, name.span);
6039 }
6040 }
6041 BlockItemKind::ForBlock { items, .. } => {
6042 self.check_duplicate_lets_in_items(items, seen);
6043 }
6044 BlockItemKind::IfBlock {
6045 branches,
6046 else_items,
6047 } => {
6048 for b in branches {
6049 self.check_duplicate_lets_in_items(&b.items, seen);
6050 }
6051 if let Some(items) = else_items {
6052 self.check_duplicate_lets_in_items(items, seen);
6053 }
6054 }
6055 BlockItemKind::Clause { value, .. } => {
6056 self.check_duplicate_lets_in_expr(value, seen);
6057 }
6058 _ => {}
6059 }
6060 }
6061 }
6062
6063 fn check_duplicate_lets_in_expr<'b>(
6064 &mut self,
6065 expr: &'b Expr,
6066 seen: &mut HashMap<&'b str, Span>,
6067 ) {
6068 match expr {
6069 Expr::LetExpr { name, value, .. } => {
6070 if seen.contains_key(name.name.as_str()) {
6071 self.push(
6072 Diagnostic::error(
6073 name.span,
6074 format!("Duplicate let binding '{}' in this rule.", name.name),
6075 )
6076 .with_code("allium.let.duplicateBinding"),
6077 );
6078 } else {
6079 seen.insert(&name.name, name.span);
6080 }
6081 self.check_duplicate_lets_in_expr(value, seen);
6082 }
6083 Expr::Block { items, .. } => {
6084 for item in items {
6085 self.check_duplicate_lets_in_expr(item, seen);
6086 }
6087 }
6088 Expr::For { body, .. } => {
6089 self.check_duplicate_lets_in_expr(body, seen);
6090 }
6091 Expr::Conditional { branches, else_body, .. } => {
6092 for b in branches {
6093 self.check_duplicate_lets_in_expr(&b.body, seen);
6094 }
6095 if let Some(body) = else_body {
6096 self.check_duplicate_lets_in_expr(body, seen);
6097 }
6098 }
6099 _ => {}
6100 }
6101 }
6102}
6103
6104impl Ctx<'_> {
6109 fn check_config_undefined_references(&mut self) {
6110 let mut config_params: HashSet<&str> = HashSet::new();
6111 for config in self.blocks(BlockKind::Config) {
6112 for item in &config.items {
6113 if let BlockItemKind::Assignment { name, .. } = &item.kind {
6114 config_params.insert(&name.name);
6115 }
6116 }
6117 }
6118
6119 for d in &self.module.declarations {
6121 match d {
6122 Decl::Block(b) => {
6123 if b.kind == BlockKind::Config {
6124 continue;
6125 }
6126 for item in &b.items {
6127 self.check_config_refs_in_item(&item.kind, &config_params);
6128 }
6129 }
6130 Decl::Invariant(inv) => {
6131 self.check_config_refs_in_expr(&inv.body, &config_params);
6132 }
6133 _ => {}
6134 }
6135 }
6136 }
6137
6138 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
6139 match kind {
6140 BlockItemKind::Clause { value, .. }
6141 | BlockItemKind::Assignment { value, .. }
6142 | BlockItemKind::ParamAssignment { value, .. }
6143 | BlockItemKind::Let { value, .. }
6144 | BlockItemKind::FieldWithWhen { value, .. } => {
6145 self.check_config_refs_in_expr(value, params);
6146 }
6147 BlockItemKind::ForBlock { collection, filter, items, .. } => {
6148 self.check_config_refs_in_expr(collection, params);
6149 if let Some(f) = filter {
6150 self.check_config_refs_in_expr(f, params);
6151 }
6152 for item in items {
6153 self.check_config_refs_in_item(&item.kind, params);
6154 }
6155 }
6156 BlockItemKind::IfBlock { branches, else_items } => {
6157 for b in branches {
6158 self.check_config_refs_in_expr(&b.condition, params);
6159 for item in &b.items {
6160 self.check_config_refs_in_item(&item.kind, params);
6161 }
6162 }
6163 if let Some(items) = else_items {
6164 for item in items {
6165 self.check_config_refs_in_item(&item.kind, params);
6166 }
6167 }
6168 }
6169 _ => {}
6170 }
6171 }
6172
6173 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
6174 match expr {
6175 Expr::MemberAccess { object, field, .. } => {
6176 if let Expr::Ident(id) = object.as_ref() {
6177 if id.name == "config" && !params.contains(field.name.as_str()) {
6178 self.push(
6179 Diagnostic::warning(
6180 field.span,
6181 format!(
6182 "Config reference 'config.{}' is not declared in any config block.",
6183 field.name
6184 ),
6185 )
6186 .with_code("allium.config.undefinedReference"),
6187 );
6188 return;
6189 }
6190 }
6191 self.check_config_refs_in_expr(object, params);
6192 }
6193 Expr::Call { function, args, .. } => {
6194 self.check_config_refs_in_expr(function, params);
6195 for a in args {
6196 match a {
6197 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
6198 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
6199 }
6200 }
6201 }
6202 Expr::BinaryOp { left, right, .. }
6203 | Expr::Comparison { left, right, .. }
6204 | Expr::LogicalOp { left, right, .. }
6205 | Expr::Pipe { left, right, .. }
6206 | Expr::NullCoalesce { left, right, .. } => {
6207 self.check_config_refs_in_expr(left, params);
6208 self.check_config_refs_in_expr(right, params);
6209 }
6210 Expr::Not { operand, .. }
6211 | Expr::Exists { operand, .. }
6212 | Expr::NotExists { operand, .. } => {
6213 self.check_config_refs_in_expr(operand, params);
6214 }
6215 Expr::Block { items, .. } => {
6216 for item in items {
6217 self.check_config_refs_in_expr(item, params);
6218 }
6219 }
6220 Expr::Conditional { branches, else_body, .. } => {
6221 for b in branches {
6222 self.check_config_refs_in_expr(&b.condition, params);
6223 self.check_config_refs_in_expr(&b.body, params);
6224 }
6225 if let Some(body) = else_body {
6226 self.check_config_refs_in_expr(body, params);
6227 }
6228 }
6229 Expr::For { collection, filter, body, .. } => {
6230 self.check_config_refs_in_expr(collection, params);
6231 if let Some(f) = filter {
6232 self.check_config_refs_in_expr(f, params);
6233 }
6234 self.check_config_refs_in_expr(body, params);
6235 }
6236 Expr::LetExpr { value, .. } => {
6237 self.check_config_refs_in_expr(value, params);
6238 }
6239 Expr::Lambda { body, .. } => {
6240 self.check_config_refs_in_expr(body, params);
6241 }
6242 _ => {}
6243 }
6244 }
6245}
6246
6247fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
6252 match kind {
6253 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
6254 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
6255 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
6256 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
6257 BlockItemKind::ForBlock {
6258 collection,
6259 filter,
6260 items,
6261 ..
6262 } => {
6263 expr_contains_ident(collection, name)
6264 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
6265 || items.iter().any(|i| item_contains_ident(&i.kind, name))
6266 }
6267 BlockItemKind::IfBlock {
6268 branches,
6269 else_items,
6270 } => {
6271 branches.iter().any(|b| {
6272 expr_contains_ident(&b.condition, name)
6273 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
6274 }) || else_items
6275 .as_ref()
6276 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
6277 }
6278 BlockItemKind::PathAssignment { path, value } => {
6279 expr_contains_ident(path, name) || expr_contains_ident(value, name)
6280 }
6281 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
6282 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
6283 BlockItemKind::ContractsClause { .. }
6284 | BlockItemKind::EnumVariant { .. }
6285 | BlockItemKind::OpenQuestion { .. }
6286 | BlockItemKind::Annotation(_)
6287 | BlockItemKind::TransitionsBlock(_) => false,
6288 }
6289}
6290
6291fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
6292 match expr {
6293 Expr::Ident(id) => id.name == name,
6294 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
6295 expr_contains_ident(object, name)
6296 }
6297 Expr::Call { function, args, .. } => {
6298 expr_contains_ident(function, name)
6299 || args.iter().any(|a| match a {
6300 CallArg::Positional(e) => expr_contains_ident(e, name),
6301 CallArg::Named(n) => expr_contains_ident(&n.value, name),
6302 })
6303 }
6304 Expr::JoinLookup { entity, fields, .. } => {
6305 expr_contains_ident(entity, name)
6306 || fields
6307 .iter()
6308 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
6309 }
6310 Expr::BinaryOp { left, right, .. }
6311 | Expr::Comparison { left, right, .. }
6312 | Expr::LogicalOp { left, right, .. }
6313 | Expr::Pipe { left, right, .. }
6314 | Expr::NullCoalesce { left, right, .. } => {
6315 expr_contains_ident(left, name) || expr_contains_ident(right, name)
6316 }
6317 Expr::Not { operand, .. }
6318 | Expr::Exists { operand, .. }
6319 | Expr::NotExists { operand, .. }
6320 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
6321 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6322 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
6323 }
6324 Expr::Where {
6325 source, condition, ..
6326 }
6327 | Expr::With {
6328 source,
6329 predicate: condition,
6330 ..
6331 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
6332 Expr::WhenGuard {
6333 action, condition, ..
6334 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
6335 Expr::Lambda { param, body, .. } => {
6336 expr_contains_ident(param, name) || expr_contains_ident(body, name)
6337 }
6338 Expr::Binding { name: n, value, .. } => {
6339 n.name == name || expr_contains_ident(value, name)
6340 }
6341 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
6342 elements.iter().any(|e| expr_contains_ident(e, name))
6343 }
6344 Expr::ObjectLiteral { fields, .. } => {
6345 fields.iter().any(|f| expr_contains_ident(&f.value, name))
6346 }
6347 Expr::GenericType { name: n, args, .. } => {
6348 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
6349 }
6350 Expr::Conditional {
6351 branches,
6352 else_body,
6353 ..
6354 } => {
6355 branches.iter().any(|b| {
6356 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
6357 }) || else_body
6358 .as_ref()
6359 .is_some_and(|e| expr_contains_ident(e, name))
6360 }
6361 Expr::For {
6362 collection,
6363 filter,
6364 body,
6365 ..
6366 } => {
6367 expr_contains_ident(collection, name)
6368 || filter
6369 .as_ref()
6370 .is_some_and(|f| expr_contains_ident(f, name))
6371 || expr_contains_ident(body, name)
6372 }
6373 Expr::TransitionsTo {
6374 subject, new_state, ..
6375 }
6376 | Expr::Becomes {
6377 subject, new_state, ..
6378 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
6379 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
6380 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
6381 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
6382 Expr::QualifiedName(_)
6383 | Expr::StringLiteral(_)
6384 | Expr::BacktickLiteral { .. }
6385 | Expr::NumberLiteral { .. }
6386 | Expr::BoolLiteral { .. }
6387 | Expr::Null { .. }
6388 | Expr::Now { .. }
6389 | Expr::This { .. }
6390 | Expr::Within { .. }
6391 | Expr::DurationLiteral { .. } => false,
6392 }
6393}
6394
6395#[cfg(test)]
6400mod tests {
6401 use super::*;
6402 use crate::diagnostic::Severity;
6403 use crate::parser::parse;
6404
6405 fn analyze_src(src: &str) -> Vec<Diagnostic> {
6406 let input = if src.starts_with("-- allium:") {
6407 src.to_string()
6408 } else {
6409 format!("-- allium: 3\n{src}")
6410 };
6411 let result = parse(&input);
6412 analyze(&result.module, &input)
6413 }
6414
6415 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
6416 diagnostics.iter().any(|d| d.code == Some(code))
6417 }
6418
6419 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
6420 diagnostics.iter().filter(|d| d.code == Some(code)).count()
6421 }
6422
6423 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
6424 let input = if src.starts_with("-- allium:") {
6425 src.to_string()
6426 } else {
6427 format!("-- allium: 3\n{src}")
6428 };
6429 let result = parse(&input);
6430 analyse(&result.module, &input)
6431 }
6432
6433 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
6434 result.findings.iter().any(|f| f["type"] == finding_type)
6435 }
6436
6437 #[test]
6440 fn suppression_on_previous_line() {
6441 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
6442 assert!(!has_code(&ds, "allium.field.unused"));
6443 }
6444
6445 #[test]
6446 fn suppression_all() {
6447 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
6448 assert!(!has_code(&ds, "allium.field.unused"));
6449 }
6450
6451 #[test]
6454 fn related_clause_with_binding_and_guard() {
6455 let ds = analyze_src(
6456 "surface QuoteVersions {\n facing user: User\n}\n\n\
6457 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
6458 );
6459 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
6460 }
6461
6462 #[test]
6463 fn related_clause_reports_unknown_surface() {
6464 let ds = analyze_src(
6465 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
6466 );
6467 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
6468 }
6469
6470 #[test]
6473 fn v1_capitalised_inline_enum() {
6474 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
6475 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
6476 }
6477
6478 #[test]
6481 fn discard_binding_no_warning() {
6482 let ds = analyze_src(
6483 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
6484 );
6485 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
6486 }
6487
6488 #[test]
6491 fn variable_status_assignment_suppresses_unreachable() {
6492 let ds = analyze_src(
6493 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
6494 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
6495 ensures: update.status = new_status\n}\n",
6496 );
6497 assert!(!has_code(&ds, "allium.status.unreachableValue"));
6498 assert!(!has_code(&ds, "allium.status.noExit"));
6499 }
6500
6501 #[test]
6502 fn surface_param_types_disambiguate_shared_status_values() {
6503 let ds = analyze_src(
6506 "entity Account {\n status: active | suspended\n}\n\n\
6507 entity Subscription {\n status: active | expired | cancelled\n}\n\n\
6508 surface AccountAdmin {\n facing admin: Admin\n provides:\n \
6509 SuspendAccount(admin, account: Account)\n when account.status = active\n \
6510 ReinstateAccount(admin, account: Account)\n when account.status = suspended\n}\n\n\
6511 surface SubscriptionAdmin {\n facing admin: Admin\n provides:\n \
6512 CancelSubscription(admin, sub: Subscription)\n when sub.status = active\n \
6513 RenewSubscription(admin, sub: Subscription)\n when sub.status != active\n \
6514 ExpireSubscription(admin, sub: Subscription)\n when sub.status = active\n}\n\n\
6515 rule AccountSuspended {\n when: SuspendAccount(admin, account)\n \
6516 requires: account.status = active\n ensures: account.status = suspended\n}\n\n\
6517 rule AccountReinstated {\n when: ReinstateAccount(admin, account)\n \
6518 requires: account.status = suspended\n ensures: account.status = active\n}\n\n\
6519 rule SubscriptionCancelled {\n when: CancelSubscription(admin, sub)\n \
6520 requires: sub.status = active\n ensures: sub.status = cancelled\n}\n\n\
6521 rule SubscriptionExpired {\n when: ExpireSubscription(admin, sub)\n \
6522 requires: sub.status = active\n ensures: sub.status = expired\n}\n\n\
6523 rule SubscriptionRenewed {\n when: RenewSubscription(admin, sub)\n \
6524 requires: sub.status != active\n ensures: sub.status = active\n}\n",
6525 );
6526 assert!(!has_code(&ds, "allium.status.unreachableValue"));
6527 assert!(!has_code(&ds, "allium.status.noExit"));
6528 }
6529
6530 #[test]
6531 fn negated_requires_counts_as_exit_for_complement_values() {
6532 let ds = analyze_src(
6535 "entity Order {\n status: draft | submitted | approved | rejected\n}\n\n\
6536 rule OrderSubmitted {\n when: SubmitOrder(clerk, order)\n \
6537 requires: order.status = draft\n ensures: order.status = submitted\n}\n\n\
6538 rule OrderApproved {\n when: ApproveOrder(clerk, order)\n \
6539 requires: order.status = submitted\n ensures: order.status = approved\n}\n\n\
6540 rule OrderRejected {\n when: RejectOrder(clerk, order)\n \
6541 requires: order.status = submitted\n ensures: order.status = rejected\n}\n\n\
6542 rule OrderReactivated {\n when: ReactivateOrder(clerk, order)\n \
6543 requires: order.status != draft\n ensures: order.status = draft\n}\n",
6544 );
6545 assert!(!has_code(&ds, "allium.status.unreachableValue"));
6546 assert!(!has_code(&ds, "allium.status.noExit"));
6547 }
6548
6549 #[test]
6552 fn created_with_status_suppresses_unreachable() {
6553 let ds = analyze_src(
6554 "entity Order {\n status: pending | confirmed\n customer: String\n \
6555 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
6556 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
6557 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
6558 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
6559 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
6560 );
6561 assert!(!has_code(&ds, "allium.status.unreachableValue"));
6562 }
6563
6564 #[test]
6565 fn created_omitting_status_warns() {
6566 let ds = analyze_src(
6567 "entity Order {\n status: pending | confirmed\n customer: String\n \
6568 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
6569 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
6570 Order.created(\n customer: customer\n )\n}\n",
6571 );
6572 assert!(has_code(&ds, "allium.created.missingStatus"));
6573 }
6574
6575 #[test]
6576 fn created_multiple_initial_statuses() {
6577 let ds = analyze_src(
6578 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
6579 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
6580 terminal: reviewed\n }\n}\n\n\
6581 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
6582 Proposal.created(status: draft, author: author)\n}\n\n\
6583 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
6584 Proposal.created(status: submitted, author: author)\n}\n\n\
6585 rule Review {\n when: ReviewerReviews(proposal)\n \
6586 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
6587 );
6588 assert!(!has_code(&ds, "allium.status.unreachableValue"));
6590 }
6591
6592 #[test]
6593 fn created_invalid_status_errors() {
6594 let ds = analyze_src(
6595 "entity Task {\n status: open | in_progress | done\n title: String\n \
6596 transitions status {\n open -> in_progress\n in_progress -> done\n \
6597 terminal: done\n }\n}\n\n\
6598 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
6599 Task.created(status: archived, title: title)\n}\n",
6600 );
6601 assert!(has_code(&ds, "allium.created.invalidStatus"));
6602 }
6603
6604 #[test]
6605 fn created_without_transitions_no_missing_status_warning() {
6606 let ds = analyze_src(
6608 "entity Note {\n status: draft | published\n content: String\n}\n\n\
6609 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
6610 Note.created(content: content)\n}\n",
6611 );
6612 assert!(!has_code(&ds, "allium.created.missingStatus"));
6613 }
6614
6615 #[test]
6618 fn terminal_declared_suppresses_no_exit() {
6619 let ds = analyze_src(
6620 "entity Subscription {\n status: active | paused | completed | cancelled\n \
6621 transitions status {\n active -> paused\n paused -> active\n \
6622 active -> completed\n active -> cancelled\n paused -> cancelled\n \
6623 terminal: completed, cancelled\n }\n}\n\n\
6624 rule Activate {\n when: UserActivates(user, subscription)\n \
6625 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
6626 rule Pause {\n when: UserPauses(user, subscription)\n \
6627 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
6628 rule Complete {\n when: PeriodEnds(subscription)\n \
6629 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
6630 rule Cancel {\n when: UserCancels(user, subscription)\n \
6631 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
6632 );
6633 assert!(!has_code(&ds, "allium.status.noExit"));
6634 }
6635
6636 #[test]
6637 fn non_terminal_no_exit_still_warns() {
6638 let ds = analyze_src(
6639 "entity Ticket {\n status: open | stuck | resolved\n \
6640 transitions status {\n open -> stuck\n open -> resolved\n \
6641 terminal: resolved\n }\n}\n\n\
6642 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
6643 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
6644 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
6645 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
6646 );
6647 assert!(has_code(&ds, "allium.status.noExit"));
6649 }
6650
6651 #[test]
6654 fn cross_entity_trigger_param_recognised() {
6655 let ds = analyze_src(
6656 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
6657 transitions status {\n scheduled -> confirmed\n \
6658 confirmed -> completed\n terminal: completed\n }\n}\n\n\
6659 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
6660 InterviewSlot.created(status: scheduled)\n}\n\n\
6661 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
6662 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
6663 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
6664 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
6665 );
6666 assert!(!ds.iter().any(|d| {
6668 d.code == Some("allium.status.unreachableValue")
6669 && d.message.contains("InterviewSlot")
6670 }));
6671 assert!(!ds.iter().any(|d| {
6672 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
6673 }));
6674 }
6675
6676 #[test]
6677 fn cross_entity_undeclared_transition() {
6678 let ds = analyze_src(
6679 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
6680 transitions status {\n scheduled -> confirmed\n \
6681 confirmed -> completed\n terminal: completed\n }\n}\n\n\
6682 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
6683 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
6684 );
6685 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
6686 }
6687
6688 #[test]
6689 fn nested_entity_status_recognised() {
6690 let ds = analyze_src(
6691 "entity Order {\n status: placed | paid\n payment: Payment\n \
6692 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
6693 entity Payment {\n status: pending | captured | failed\n \
6694 transitions status {\n pending -> captured\n pending -> failed\n \
6695 terminal: captured, failed\n }\n}\n\n\
6696 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
6697 requires: order.payment.status = pending\n \
6698 ensures: order.payment.status = captured\n}\n",
6699 );
6700 assert!(!ds.iter().any(|d| {
6702 (d.code == Some("allium.status.unreachableValue")
6703 || d.code == Some("allium.status.noExit"))
6704 && d.message.contains("'captured'")
6705 }));
6706 }
6707
6708 #[test]
6711 fn dead_transition_missing_producer() {
6712 let r = analyse_src(
6713 "entity App {\n status: submitted | screening | approved | rejected\n \
6714 verified: Boolean\n \
6715 transitions status {\n submitted -> screening\n screening -> approved\n \
6716 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
6717 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
6718 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
6719 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
6720 requires:\n app.status = screening\n app.verified = true\n \
6721 ensures: app.status = approved\n}\n\n\
6722 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
6723 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
6724 );
6725 assert!(has_finding(&r, "dead_transition"));
6726 assert!(has_finding(&r, "missing_producer"));
6727 }
6728
6729 #[test]
6730 fn satisfied_requires_no_dead_transition() {
6731 let r = analyse_src(
6732 "entity App {\n status: submitted | screening | approved | rejected\n \
6733 verified: Boolean\n \
6734 transitions status {\n submitted -> screening\n screening -> approved\n \
6735 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
6736 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
6737 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
6738 rule Verify {\n when: SystemVerifies(app, result)\n \
6739 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
6740 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
6741 requires:\n app.status = screening\n app.verified = true\n \
6742 ensures: app.status = approved\n}\n\n\
6743 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
6744 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
6745 );
6746 assert!(!has_finding(&r, "dead_transition"));
6747 assert!(!has_finding(&r, "missing_producer"));
6748 }
6749
6750 #[test]
6751 fn deadlock_detected() {
6752 let r = analyse_src(
6753 "entity Doc {\n status: submitted | review | approved | rejected\n \
6754 reviewer_assigned: Boolean\n \
6755 transitions status {\n submitted -> review\n review -> approved\n \
6756 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
6757 rule Submit {\n when: AuthorSubmits(author, doc)\n \
6758 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
6759 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
6760 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
6761 ensures: doc.status = approved\n}\n\n\
6762 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
6763 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
6764 ensures: doc.status = rejected\n}\n",
6765 );
6766 assert!(has_finding(&r, "deadlock"));
6767 }
6768
6769 #[test]
6770 fn no_deadlock_when_paths_open() {
6771 let r = analyse_src(
6772 "entity Invoice {\n status: draft | sent | paid | void\n \
6773 transitions status {\n draft -> sent\n draft -> void\n \
6774 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
6775 rule Send {\n when: AccountantSends(accountant, invoice)\n \
6776 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
6777 rule Pay {\n when: PaymentReceived(invoice)\n \
6778 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
6779 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
6780 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
6781 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
6782 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
6783 );
6784 assert!(!has_finding(&r, "deadlock"));
6785 }
6786
6787 #[test]
6790 fn conflict_temporal_vs_external() {
6791 let r = analyse_src(
6792 "entity Membership {\n status: active | expired | extended\n \
6793 expires_at: Timestamp\n \
6794 transitions status {\n active -> expired\n active -> extended\n \
6795 terminal: expired, extended\n }\n}\n\n\
6796 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
6797 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
6798 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
6799 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
6800 );
6801 assert!(has_finding(&r, "conflict"));
6802 }
6803
6804 #[test]
6805 fn no_conflict_actor_choice() {
6806 let r = analyse_src(
6807 "entity LeaveRequest {\n status: pending | approved | denied\n \
6808 transitions status {\n pending -> approved\n pending -> denied\n \
6809 terminal: approved, denied\n }\n}\n\n\
6810 rule Approve {\n when: ManagerApproves(manager, request)\n \
6811 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
6812 rule Deny {\n when: ManagerDenies(manager, request)\n \
6813 requires: request.status = pending\n ensures: request.status = denied\n}\n",
6814 );
6815 assert!(!has_finding(&r, "conflict"));
6816 }
6817
6818 #[test]
6821 fn invariant_violation_detected() {
6822 let r = analyse_src(
6823 "entity JobRole {\n status: open | filled\n \
6824 candidacies: Candidacy with role = this\n \
6825 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
6826 entity Candidacy {\n status: active | hired | rejected\n \
6827 role: JobRole\n \
6828 transitions status {\n active -> hired\n active -> rejected\n \
6829 terminal: hired, rejected\n }\n}\n\n\
6830 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
6831 requires: candidacy.status = active\n \
6832 ensures: candidacy.status = hired\n}\n\n\
6833 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
6834 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
6835 );
6836 assert!(has_finding(&r, "invariant_risk"));
6837 }
6838
6839 #[test]
6840 fn invariant_guarded_no_violation() {
6841 let r = analyse_src(
6842 "entity JobRole {\n status: open | filled\n \
6843 candidacies: Candidacy with role = this\n \
6844 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
6845 entity Candidacy {\n status: active | hired | rejected\n \
6846 role: JobRole\n \
6847 transitions status {\n active -> hired\n active -> rejected\n \
6848 terminal: hired, rejected\n }\n}\n\n\
6849 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
6850 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
6851 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
6852 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
6853 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
6854 );
6855 assert!(!has_finding(&r, "invariant_risk"));
6856 }
6857
6858 #[test]
6861 fn external_entity_referenced_in_rules_info() {
6862 let ds = analyze_src(
6863 "external entity Client {\n id: String\n}\n\n\
6864 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
6865 );
6866 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
6867 assert!(hint.is_some());
6868 assert_eq!(hint.unwrap().severity, Severity::Info);
6869 }
6870
6871 #[test]
6874 fn undefined_type_reference() {
6875 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
6876 assert!(has_code(&ds, "allium.type.undefinedReference"));
6877 }
6878
6879 #[test]
6880 fn known_type_reference_ok() {
6881 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
6882 assert!(!has_code(&ds, "allium.type.undefinedReference"));
6883 }
6884
6885 #[test]
6888 fn unreachable_trigger_reported() {
6889 let ds = analyze_src(
6890 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
6891 );
6892 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
6893 }
6894
6895 fn analyze_with_imports(
6898 src: &str,
6899 imports: &[(&str, &[&str])],
6900 ) -> Vec<Diagnostic> {
6901 let input = format!("-- allium: 3\n{src}");
6902 let result = parse(&input);
6903 let imported: HashMap<String, HashSet<String>> = imports
6904 .iter()
6905 .map(|(alias, triggers)| {
6906 (
6907 alias.to_string(),
6908 triggers.iter().map(|t| t.to_string()).collect(),
6909 )
6910 })
6911 .collect();
6912 analyze_with_cross_module(
6913 &result.module,
6914 &input,
6915 &HashSet::new(),
6916 &HashSet::new(),
6917 &imported,
6918 &HashMap::new(),
6919 &AmbiguousImports::default(),
6920 &ReverseContributions::default(),
6921 )
6922 }
6923
6924 fn analyze_with_ambiguous(
6930 src: &str,
6931 names: &[(&str, &[&str])],
6932 triggers: &[(&str, &[&str])],
6933 ) -> Vec<Diagnostic> {
6934 let input = format!("-- allium: 3\n{src}");
6935 let result = parse(&input);
6936 let to_map = |entries: &[(&str, &[&str])]| -> HashMap<String, Vec<String>> {
6937 entries
6938 .iter()
6939 .map(|(name, aliases)| {
6940 (
6941 name.to_string(),
6942 aliases.iter().map(|a| a.to_string()).collect(),
6943 )
6944 })
6945 .collect()
6946 };
6947 let ambiguous = AmbiguousImports {
6948 names: to_map(names),
6949 triggers: to_map(triggers),
6950 };
6951 let mut imported: HashMap<String, HashSet<String>> = HashMap::new();
6952 for (trigger, aliases) in triggers {
6953 for alias in *aliases {
6954 imported
6955 .entry(alias.to_string())
6956 .or_default()
6957 .insert(trigger.to_string());
6958 }
6959 }
6960 analyze_with_cross_module(
6961 &result.module,
6962 &input,
6963 &HashSet::new(),
6964 &HashSet::new(),
6965 &imported,
6966 &HashMap::new(),
6967 &ambiguous,
6968 &ReverseContributions::default(),
6969 )
6970 }
6971
6972 #[test]
6973 fn qualified_trigger_suppressed_in_single_file_mode() {
6974 let ds = analyze_src(
6977 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
6978 );
6979 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
6980 }
6981
6982 #[test]
6983 fn qualified_trigger_reachable_via_imported_module() {
6984 let ds = analyze_with_imports(
6985 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
6986 &[("emitter", &["Pinged"])],
6987 );
6988 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
6989 }
6990
6991 #[test]
6992 fn qualified_trigger_unreachable_when_imported_module_lacks_it() {
6993 let ds = analyze_with_imports(
6994 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
6995 &[("emitter", &["SomethingElse"])],
6996 );
6997 let flagged: Vec<&Diagnostic> = ds
6998 .iter()
6999 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7000 .collect();
7001 assert_eq!(flagged.len(), 1);
7002 assert!(flagged[0].message.contains("'emitter/Pinged'"));
7003 assert!(flagged[0].message.contains("imported module 'emitter'"));
7004 }
7005
7006 #[test]
7007 fn qualified_trigger_suppressed_for_alias_outside_check_set() {
7008 let ds = analyze_with_imports(
7011 "use \"github.com/allium-specs/oauth/abc\" as oauth\n\nrule Audit {\n when: oauth/SessionCreated(session)\n ensures: Logged(session: session)\n}\n",
7012 &[],
7013 );
7014 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7015 }
7016
7017 #[test]
7018 fn unqualified_trigger_reachable_via_imported_module() {
7019 let ds = analyze_with_imports(
7020 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7021 &[("emitter", &["Pinged"])],
7022 );
7023 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7024 }
7025
7026 #[test]
7027 fn unqualified_trigger_still_flagged_when_no_import_emits_it() {
7028 let ds = analyze_with_imports(
7029 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7030 &[("emitter", &["SomethingElse"])],
7031 );
7032 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
7033 }
7034
7035 #[test]
7038 fn ambiguous_trigger_subscription_warns() {
7039 let ds = analyze_with_ambiguous(
7040 "use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7041 &[],
7042 &[("Pinged", &["a", "b"])],
7043 );
7044 let diag = ds
7045 .iter()
7046 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7047 .expect("ambiguous trigger subscription should warn");
7048 assert!(diag.message.contains("'a' and 'b'"), "message: {}", diag.message);
7049 assert!(diag.message.contains("a/Pinged"), "message: {}", diag.message);
7050 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7052 }
7053
7054 #[test]
7055 fn ambiguous_trigger_not_flagged_when_emitted_locally() {
7056 let ds = analyze_with_ambiguous(
7058 "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",
7059 &[],
7060 &[("Pinged", &["a", "b"])],
7061 );
7062 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7063 }
7064
7065 #[test]
7066 fn qualified_trigger_subscription_not_flagged_as_ambiguous() {
7067 let ds = analyze_with_ambiguous(
7068 "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",
7069 &[],
7070 &[("Pinged", &["a", "b"])],
7071 );
7072 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7073 }
7074
7075 #[test]
7076 fn ambiguous_name_reference_warns() {
7077 let ds = analyze_with_ambiguous(
7078 "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",
7079 &[("Invoice", &["billing", "orders"])],
7080 &[],
7081 );
7082 let diag = ds
7083 .iter()
7084 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7085 .expect("ambiguous unqualified name should warn");
7086 assert!(
7087 diag.message.contains("'billing' and 'orders'"),
7088 "message: {}",
7089 diag.message
7090 );
7091 assert!(diag.message.contains("billing/Invoice"), "message: {}", diag.message);
7092 }
7093
7094 #[test]
7095 fn ambiguous_name_shadowed_by_local_declaration() {
7096 let ds = analyze_with_ambiguous(
7097 "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",
7098 &[("Invoice", &["billing", "orders"])],
7099 &[],
7100 );
7101 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7102 }
7103
7104 #[test]
7105 fn ambiguous_name_flagged_once_per_name() {
7106 let ds = analyze_with_ambiguous(
7107 "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",
7108 &[("Invoice", &["billing", "orders"])],
7109 &[],
7110 );
7111 let count = ds
7112 .iter()
7113 .filter(|d| d.code == Some("allium.use.ambiguousReference"))
7114 .count();
7115 assert_eq!(count, 1, "expected a single warning per ambiguous name");
7116 }
7117
7118 #[test]
7119 fn no_ambiguity_warnings_in_single_file_mode() {
7120 let ds = analyze_src(
7121 "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",
7122 );
7123 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7124 }
7125
7126 #[test]
7127 fn conditional_ensures_emission_registers() {
7128 let ds = analyze_src(
7131 "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",
7132 );
7133 let unreachable: Vec<&Diagnostic> = ds
7134 .iter()
7135 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7136 .collect();
7137 assert_eq!(unreachable.len(), 1);
7140 assert!(unreachable[0].message.contains("'AdvertReceived'"));
7141 }
7142
7143 #[test]
7144 fn for_body_ensures_emission_registers() {
7145 let ds = analyze_src(
7146 "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",
7147 );
7148 let unreachable: Vec<&Diagnostic> = ds
7149 .iter()
7150 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7151 .collect();
7152 assert_eq!(unreachable.len(), 1);
7153 assert!(unreachable[0].message.contains("'Broadcast'"));
7154 }
7155
7156 #[test]
7157 fn collect_trigger_outputs_includes_provides_ensures_and_branches() {
7158 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";
7159 let result = parse(input);
7160 let outputs = collect_trigger_outputs(&result.module);
7161 assert!(outputs.contains("Submit"));
7162 assert!(outputs.contains("Accepted"));
7163 assert!(outputs.contains("Rejected"));
7164 }
7165
7166 #[test]
7169 fn unused_field_reported() {
7170 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");
7171 assert!(has_code(&ds, "allium.field.unused"));
7172 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7174 assert!(unused.iter().any(|d| d.message.contains("A.y")));
7175 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
7176 }
7177
7178 #[test]
7179 fn field_used_by_sibling_derived_field_not_unused() {
7180 let ds = analyze_src("entity Widget {\n count: Integer\n is_positive: count > 0\n}\n");
7183 let unused: Vec<_> =
7184 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7185 assert!(
7186 !unused.iter().any(|d| d.message.contains("Widget.count")),
7187 "count is referenced by is_positive and must not be flagged unused. Got: {:?}",
7188 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7189 );
7190 assert!(
7192 unused.iter().any(|d| d.message.contains("Widget.is_positive")),
7193 "is_positive is unreferenced and should still warn. Got: {:?}",
7194 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7195 );
7196 }
7197
7198 #[test]
7199 fn field_set_via_created_named_arg_not_unused() {
7200 let ds = analyze_src(
7203 "entity Widget {\n name: String\n}\n\nrule MakeWidget {\n when: MakeWidget(label)\n ensures: Widget.created(name: label)\n}\n",
7204 );
7205 let unused: Vec<_> =
7206 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7207 assert!(
7208 !unused.iter().any(|d| d.message.contains("Widget.name")),
7209 "name is set by Widget.created(name: ...) and must not be flagged unused. Got: {:?}",
7210 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7211 );
7212 }
7213
7214 #[test]
7215 fn field_named_like_a_rule_binding_still_unused() {
7216 let ds = analyze_src(
7220 "entity Widget {\n order: String\n}\n\nrule R {\n when: Ping(order)\n ensures: Done()\n}\n",
7221 );
7222 let unused: Vec<_> =
7223 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7224 assert!(
7225 unused.iter().any(|d| d.message.contains("Widget.order")),
7226 "a rule binding named 'order' must not suppress the unused field 'Widget.order'. Got: {:?}",
7227 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7228 );
7229 }
7230
7231 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";
7234
7235 #[test]
7236 fn conditional_ensures_makes_branch_statuses_reachable() {
7237 let ds = analyze_src(DECIDE_BRANCH_SPEC);
7240 let unreachable: Vec<_> = ds
7241 .iter()
7242 .filter(|d| d.code == Some("allium.status.unreachableValue"))
7243 .collect();
7244 assert!(
7245 !unreachable.iter().any(|d| d.message.contains("approved")),
7246 "approved is assigned in the if-branch and must be reachable. Got: {:?}",
7247 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
7248 );
7249 assert!(
7250 !unreachable.iter().any(|d| d.message.contains("rejected")),
7251 "rejected is assigned in the else-branch and must be reachable. Got: {:?}",
7252 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
7253 );
7254 }
7255
7256 #[test]
7257 fn conditional_ensures_transitions_are_witnessed_no_deadlock() {
7258 let r = analyse_src(DECIDE_BRANCH_SPEC);
7262 assert!(
7263 !has_finding(&r, "deadlock"),
7264 "branch-witnessed exits from pending must clear the deadlock. Findings: {:?}",
7265 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
7266 );
7267 }
7268
7269 const FOR_IN_PROVIDES_SPEC: &str = r#"external entity Person { name: String }
7270
7271entity User {
7272 person: Person
7273 sessions: Session with user = this
7274}
7275
7276entity Session {
7277 user: User
7278 status: active | ended
7279 transitions status { active -> ended terminal: ended }
7280}
7281
7282rule LogOut {
7283 when: UserLogsOut(session)
7284 requires: session.status = active
7285 ensures: session.status = ended
7286}
7287
7288surface AccountManagement {
7289 facing person: Person
7290 context user: User where person = person
7291 exposes:
7292 for session in user.sessions:
7293 session.status
7294 provides:
7295 for session in user.sessions:
7296 UserLogsOut(session)
7297}
7298"#;
7299
7300 #[test]
7301 fn trigger_provided_in_for_block_is_reachable() {
7302 let ds = analyze_src(FOR_IN_PROVIDES_SPEC);
7305 assert!(
7306 !ds.iter().any(|d| d.code == Some("allium.rule.unreachableTrigger")
7307 && d.message.contains("UserLogsOut")),
7308 "UserLogsOut is provided inside a for-block and must be reachable. Got: {:?}",
7309 ds.iter()
7310 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7311 .map(|d| &d.message)
7312 .collect::<Vec<_>>()
7313 );
7314 }
7315
7316 #[test]
7317 fn trigger_provided_in_for_block_no_unreachable_finding() {
7318 let r = analyse_src(FOR_IN_PROVIDES_SPEC);
7320 assert!(
7321 !has_finding(&r, "unreachable_trigger"),
7322 "for-block-provided trigger must not yield an unreachable_trigger finding. Findings: {:?}",
7323 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
7324 );
7325 }
7326
7327 #[test]
7330 fn unused_entity_reported() {
7331 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
7332 assert!(has_code(&ds, "allium.entity.unused"));
7333 }
7334
7335 #[test]
7336 fn external_ref_suppresses_unused_entity() {
7337 let src = "entity InputEvent {\n payload: String\n}\n";
7338 let input = format!("-- allium: 3\n{src}");
7339 let result = parse(&input);
7340 let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
7341 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7342 assert!(!has_code(&ds, "allium.entity.unused"));
7343 }
7344
7345 #[test]
7346 fn external_ref_suppresses_unused_definition() {
7347 let src = "value Snapshot {\n version: Integer\n}\n";
7348 let input = format!("-- allium: 3\n{src}");
7349 let result = parse(&input);
7350 let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
7351 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7352 assert!(!has_code(&ds, "allium.definition.unused"));
7353 }
7354
7355 #[test]
7356 fn unreferenced_entity_still_warns_without_external_ref() {
7357 let src = "entity InputEvent {\n payload: String\n}\n";
7358 let input = format!("-- allium: 3\n{src}");
7359 let result = parse(&input);
7360 let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
7361 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7362 assert!(has_code(&ds, "allium.entity.unused"));
7363 }
7364
7365 #[test]
7366 fn collect_qualified_refs_from_rule_clause() {
7367 let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
7368 let input = format!("-- allium: 3\n{src}");
7369 let result = parse(&input);
7370 let refs = collect_qualified_references(&result.module);
7371 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
7372 }
7373
7374 #[test]
7375 fn collect_qualified_refs_from_field_type() {
7376 let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
7377 let input = format!("-- allium: 3\n{src}");
7378 let result = parse(&input);
7379 let refs = collect_qualified_references(&result.module);
7380 assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
7381 }
7382
7383 #[test]
7384 fn collect_qualified_refs_from_requires() {
7385 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";
7386 let input = format!("-- allium: 3\n{src}");
7387 let result = parse(&input);
7388 let refs = collect_qualified_references(&result.module);
7389 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
7390 }
7391
7392 #[test]
7393 fn collect_qualified_refs_from_for_block() {
7394 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";
7395 let input = format!("-- allium: 3\n{src}");
7396 let result = parse(&input);
7397 let refs = collect_qualified_references(&result.module);
7398 assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
7399 }
7400
7401 #[test]
7402 fn collect_qualified_refs_from_member_access() {
7403 let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
7404 let input = format!("-- allium: 3\n{src}");
7405 let result = parse(&input);
7406 let refs = collect_qualified_references(&result.module);
7407 assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
7408 }
7409
7410 #[test]
7411 fn collect_qualified_refs_multiple_from_same_module() {
7412 let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
7413 let input = format!("-- allium: 3\n{src}");
7414 let result = parse(&input);
7415 let refs = collect_qualified_references(&result.module);
7416 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
7417 assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
7418 }
7419
7420 #[test]
7421 fn collect_qualified_refs_multiple_modules() {
7422 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";
7423 let input = format!("-- allium: 3\n{src}");
7424 let result = parse(&input);
7425 let refs = collect_qualified_references(&result.module);
7426 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
7427 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
7428 }
7429
7430 #[test]
7431 fn collect_qualified_refs_empty_when_none() {
7432 let src = "entity Order {\n total: Decimal\n}\n";
7433 let input = format!("-- allium: 3\n{src}");
7434 let result = parse(&input);
7435 let refs = collect_qualified_references(&result.module);
7436 assert!(refs.is_empty());
7437 }
7438
7439 #[test]
7440 fn collect_qualified_refs_from_ensures() {
7441 let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
7442 let input = format!("-- allium: 3\n{src}");
7443 let result = parse(&input);
7444 let refs = collect_qualified_references(&result.module);
7445 assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
7446 }
7447
7448 #[test]
7449 fn collect_qualified_refs_from_invariant() {
7450 let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
7451 let input = format!("-- allium: 3\n{src}");
7452 let result = parse(&input);
7453 let refs = collect_qualified_references(&result.module);
7454 assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
7455 }
7456
7457 #[test]
7458 fn collect_qualified_refs_from_deferred() {
7459 let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
7460 let input = format!("-- allium: 3\n{src}");
7461 let result = parse(&input);
7462 let refs = collect_qualified_references(&result.module);
7463 assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
7464 }
7465
7466 #[test]
7467 fn collect_qualified_refs_from_alias_dot_member() {
7468 let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
7469 let input = format!("-- allium: 3\n{src}");
7470 let result = parse(&input);
7471 let refs = collect_qualified_references(&result.module);
7472 assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
7473 }
7474
7475 #[test]
7476 fn collect_all_idents_includes_unqualified_entity_ref() {
7477 let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
7478 let input = format!("-- allium: 3\n{src}");
7479 let result = parse(&input);
7480 let idents = collect_all_referenced_idents(&result.module);
7481 assert!(idents.contains("InputPartition"));
7482 }
7483
7484 #[test]
7485 fn collect_declared_names_returns_entity_and_value_names() {
7486 let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
7487 let input = format!("-- allium: 3\n{src}");
7488 let result = parse(&input);
7489 let names = collect_declared_names(&result.module);
7490 assert!(names.contains("Order"));
7491 assert!(names.contains("Money"));
7492 assert!(names.contains("Status"));
7493 }
7494
7495 #[test]
7496 fn external_ref_only_suppresses_matching_name() {
7497 let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
7499 let input = format!("-- allium: 3\n{src}");
7500 let result = parse(&input);
7501 let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
7502 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7503 assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
7504 && d.message.contains("Used")));
7505 assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
7506 && d.message.contains("Orphan")));
7507 }
7508
7509 #[test]
7510 fn external_ref_suppresses_unused_external_entity() {
7511 let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
7512 let input = format!("-- allium: 3\n{src}");
7513 let result = parse(&input);
7514 let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
7515 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7516 assert!(!has_code(&ds, "allium.entity.unused"));
7517 }
7518
7519 #[test]
7520 fn external_ref_suppresses_unused_enum() {
7521 let src = "enum Priority {\n low\n medium\n high\n}\n";
7522 let input = format!("-- allium: 3\n{src}");
7523 let result = parse(&input);
7524 let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
7525 let ds = analyze_with_external_refs(&result.module, &input, &refs);
7526 assert!(!has_code(&ds, "allium.definition.unused"));
7527 }
7528
7529 #[test]
7530 fn empty_external_refs_same_as_plain_analyze() {
7531 let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
7532 let input = format!("-- allium: 3\n{src}");
7533 let result = parse(&input);
7534 let plain = analyze(&result.module, &input);
7535 let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
7536 assert_eq!(plain.len(), with_empty.len());
7537 for (a, b) in plain.iter().zip(with_empty.iter()) {
7538 assert_eq!(a.code, b.code);
7539 assert_eq!(a.message, b.message);
7540 }
7541 }
7542
7543 #[test]
7546 fn resolved_use_path_no_warning() {
7547 let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
7548 let input = format!("-- allium: 3\n{src}");
7549 let result = parse(&input);
7550 let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
7551 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7552 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
7553 }
7554
7555 #[test]
7556 fn unresolved_use_path_warns() {
7557 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
7558 let input = format!("-- allium: 3\n{src}");
7559 let result = parse(&input);
7560 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
7562 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7563 assert!(has_code(&ds, "allium.use.unresolvedPath"));
7564 }
7565
7566 #[test]
7567 fn unresolved_use_path_skipped_in_single_file_mode() {
7568 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
7570 let input = format!("-- allium: 3\n{src}");
7571 let result = parse(&input);
7572 let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
7573 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
7574 }
7575
7576 #[test]
7577 fn unresolved_use_path_fires_with_empty_resolved_set() {
7578 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
7581 let input = format!("-- allium: 3\n{src}");
7582 let result = parse(&input);
7583 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new(), &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7584 assert!(has_code(&ds, "allium.use.unresolvedPath"));
7585 }
7586
7587 #[test]
7588 fn unresolved_use_path_message_includes_path() {
7589 let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
7590 let input = format!("-- allium: 3\n{src}");
7591 let result = parse(&input);
7592 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
7593 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7594 let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
7595 assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
7596 }
7597
7598 #[test]
7599 fn unresolved_use_path_suppressible() {
7600 let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
7601 let input = format!("-- allium: 3\n{src}");
7602 let result = parse(&input);
7603 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
7604 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7605 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
7606 }
7607
7608 #[test]
7609 fn multiple_use_paths_mixed_resolution() {
7610 let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
7611 let input = format!("-- allium: 3\n{src}");
7612 let result = parse(&input);
7613 let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
7614 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default());
7615 let unresolved: Vec<_> = ds.iter()
7616 .filter(|d| d.code == Some("allium.use.unresolvedPath"))
7617 .collect();
7618 assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
7619 assert!(unresolved[0].message.contains("lost.allium"));
7620 }
7621
7622 #[test]
7625 fn deferred_missing_location_hint() {
7626 let ds = analyze_src("deferred Foo.bar\n");
7627 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
7628 }
7629
7630 #[test]
7631 fn deferred_with_quoted_path_hint_ok() {
7632 let ds = analyze_src("deferred Foo.bar \"detailed/foo.allium\"\n");
7633 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7634 }
7635
7636 #[test]
7637 fn deferred_with_see_comment_hint_ok() {
7638 let ds = analyze_src("deferred Foo.bar -- see: detailed/foo.allium\n");
7640 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7641 }
7642
7643 #[test]
7644 fn deferred_with_url_hint_ok() {
7645 let ds = analyze_src("deferred Foo.bar -- https://example.com/foo.allium\n");
7646 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7647 }
7648
7649 #[test]
7650 fn deferred_with_url_glued_to_path_warns() {
7651 assert!(has_code(
7655 &analyze_src("deferred Foohttps://x\n"),
7656 "allium.deferred.missingLocationHint"
7657 ));
7658 assert!(has_code(
7659 &analyze_src("deferred Foohttp://x\n"),
7660 "allium.deferred.missingLocationHint"
7661 ));
7662 }
7663
7664 #[test]
7665 fn deferred_with_non_hint_comment_warns() {
7666 let ds = analyze_src("deferred Foo.bar -- TODO write this\n");
7669 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
7670 }
7671
7672 #[test]
7673 fn deferred_expression_path_with_quote_suppresses() {
7674 let ds = analyze_src("deferred Foo(\"x\")\n");
7679 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7680 let ds = analyze_src("deferred Foo = \"x\"\n");
7681 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7682 }
7683
7684 #[test]
7685 fn deferred_trailing_dot_warns_with_captured_name() {
7686 let ds = analyze_src("deferred Dangling.\n");
7691 let hints: Vec<&Diagnostic> = ds
7692 .iter()
7693 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
7694 .collect();
7695 assert_eq!(hints.len(), 1);
7696 assert!(hints[0].message.contains("'Dangling.'"));
7697 }
7698
7699 #[test]
7700 fn deferred_lone_cr_is_a_line_boundary() {
7701 let ds = analyze_src("deferred Foo\rdeferred Bar\n");
7705 let hints: Vec<&Diagnostic> = ds
7706 .iter()
7707 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
7708 .collect();
7709 assert_eq!(hints.len(), 2, "both CR-separated declarations warn");
7710 let ds = analyze_src("deferred Foo\r-- see: x.allium\n");
7712 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
7713 }
7714
7715 #[test]
7716 fn deferred_unmatchable_path_stays_silent() {
7717 let ds = analyze_src("deferred (Foo)\n");
7722 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
7723 }
7724
7725 #[test]
7726 fn deferred_qualified_path_warns_with_flat_name() {
7727 let ds = analyze_src("deferred billing/InvoiceWorkflow\n");
7730 let hints: Vec<&Diagnostic> = ds
7731 .iter()
7732 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
7733 .collect();
7734 assert_eq!(hints.len(), 1);
7735 assert!(hints[0].message.contains("'billing'"));
7736 }
7737
7738 #[test]
7739 fn deferred_location_hint_is_per_line() {
7740 let ds = analyze_src(
7744 "deferred A.one -- see: a.allium\ndeferred B.two\ndeferred C.three \"c.allium\"\n",
7745 );
7746 let hints: Vec<&Diagnostic> = ds
7747 .iter()
7748 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
7749 .collect();
7750 assert_eq!(hints.len(), 1);
7751 assert!(hints[0].message.contains("B.two"));
7752 }
7753
7754 #[test]
7757 fn valid_trigger_ok() {
7758 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
7759 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
7760 }
7761
7762 #[test]
7763 fn qualified_trigger_call_is_valid() {
7764 let ds = analyze_src(
7768 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7769 );
7770 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
7771 }
7772
7773 #[test]
7774 fn typed_trigger_param_reported_at_trigger() {
7775 let ds = analyze_src(
7780 "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",
7781 );
7782 let invalid: Vec<&Diagnostic> = ds
7783 .iter()
7784 .filter(|d| d.code == Some("allium.rule.invalidTrigger"))
7785 .collect();
7786 assert_eq!(invalid.len(), 1, "exactly one invalidTrigger diagnostic");
7787 assert!(invalid[0].message.contains("'account'"));
7788 assert!(invalid[0].message.contains("bare names"));
7789 assert!(
7791 !has_code(&ds, "allium.rule.undefinedBinding"),
7792 "typed trigger param must not also fire undefinedBinding on the body"
7793 );
7794 }
7795
7796 #[test]
7797 fn untyped_trigger_param_ok() {
7798 let ds = analyze_src(
7800 "entity Account { name: String }\nentity Greeting { label: String }\n\nrule UntypedParam {\n when: AccountSeen(account)\n ensures: Greeting.created(label: account.name)\n}\n",
7801 );
7802 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
7803 assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
7804 }
7805
7806 #[test]
7809 fn homogeneous_list_literal_ok() {
7810 let ds = analyze_src("default E e = { items: [\"a\", \"b\", \"c\"] }");
7811 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
7812 }
7813
7814 #[test]
7815 fn empty_list_literal_ok() {
7816 let ds = analyze_src("default E e = { items: [] }");
7817 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
7818 }
7819
7820 #[test]
7821 fn heterogeneous_list_literal_flagged() {
7822 let ds = analyze_src("default E e = { items: [\"a\", 5] }");
7823 assert!(has_code(&ds, "allium.list.mixedElementTypes"));
7824 }
7825
7826 #[test]
7827 fn list_literal_of_identifiers_not_flagged() {
7828 let ds = analyze_src("default E e = { items: [foo, bar] }");
7831 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
7832 }
7833
7834 #[test]
7837 fn qualified_default_known_alias_ok() {
7838 let ds = analyze_src(
7839 "use \"./p.allium\" as gp\n\ndefault gp/Policy my_policy = { id: \"x\" }",
7840 );
7841 assert!(!has_code(&ds, "allium.default.undefinedImportedAlias"));
7842 }
7843
7844 #[test]
7845 fn qualified_default_unknown_alias_flagged() {
7846 let ds = analyze_src("default zz/Policy my_policy = { id: \"x\" }");
7848 assert!(has_code(&ds, "allium.default.undefinedImportedAlias"));
7849 }
7850
7851 #[test]
7854 fn default_unknown_field_flagged() {
7855 let ds = analyze_src(
7856 "entity Policy { id: String }\ndefault Policy p = { id: \"x\", naem: \"typo\" }",
7857 );
7858 assert!(has_code(&ds, "allium.default.unknownField"));
7859 }
7860
7861 #[test]
7862 fn default_known_fields_ok() {
7863 let ds = analyze_src(
7864 "entity Policy { id: String\n label: String }\ndefault Policy p = { id: \"x\", label: \"y\" }",
7865 );
7866 assert!(!has_code(&ds, "allium.default.unknownField"));
7867 }
7868
7869 #[test]
7870 fn default_nested_object_unknown_field_flagged() {
7871 let ds = analyze_src(
7873 "value Predicate { clause_order: List<String> }\nentity Policy { id: String\n predicate: Predicate }\ndefault Policy p = { id: \"x\", predicate: { bogus: 5 } }",
7874 );
7875 assert!(has_code(&ds, "allium.default.unknownField"));
7876 }
7877
7878 #[test]
7879 fn empty_list_in_list_field_ok() {
7880 let ds = analyze_src(
7881 "entity E { tags: List<String> }\ndefault E e = { tags: [] }",
7882 );
7883 assert!(!has_code(&ds, "allium.list.emptyListNoElementType"));
7884 }
7885
7886 #[test]
7887 fn empty_list_in_non_list_field_flagged() {
7888 let ds = analyze_src(
7889 "entity E { id: String }\ndefault E e = { id: [] }",
7890 );
7891 assert!(has_code(&ds, "allium.list.emptyListNoElementType"));
7892 }
7893
7894 #[test]
7895 fn qualified_default_fields_not_validated() {
7896 let ds = analyze_src(
7899 "use \"./p.allium\" as gp\n\ndefault gp/Policy p = { anything: 1, goes: 2 }",
7900 );
7901 assert!(!has_code(&ds, "allium.default.unknownField"));
7902 }
7903
7904 #[test]
7907 fn duplicate_let_binding() {
7908 let ds = analyze_src(
7909 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
7910 );
7911 assert!(has_code(&ds, "allium.let.duplicateBinding"));
7912 }
7913
7914 #[test]
7917 fn config_undefined_reference() {
7918 let ds = analyze_src(
7919 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
7920 );
7921 assert!(has_code(&ds, "allium.config.undefinedReference"));
7922 }
7923
7924 #[test]
7925 fn config_valid_reference_ok() {
7926 let ds = analyze_src(
7927 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
7928 );
7929 assert!(!has_code(&ds, "allium.config.undefinedReference"));
7930 }
7931
7932 fn module_of(src: &str) -> Module {
7935 parse(&format!("-- allium: 3\n{src}")).module
7936 }
7937
7938 #[test]
7939 fn reverse_contributions_credit_qualified_creation() {
7940 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
7941 let importer = module_of(
7942 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
7943 );
7944 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
7945 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("open")));
7946 assert!(rc.witnessed_transitions.is_empty());
7947 assert!(rc.provided_triggers.is_empty());
7948 }
7949
7950 #[test]
7951 fn reverse_contributions_credit_qualified_provides() {
7952 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
7953 let importer = module_of(
7954 "use \"./t.allium\" as tickets\nsurface Intake {\n provides:\n tickets/OpenTicket()\n tickets/CloseTicket(ticket)\n}\n",
7955 );
7956 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
7957 assert!(rc.provided_triggers.contains("OpenTicket"));
7958 assert!(rc.provided_triggers.contains("CloseTicket"));
7959 }
7960
7961 #[test]
7962 fn reverse_contributions_credit_witnessed_transition() {
7963 let imported = module_of(
7964 "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",
7965 );
7966 let importer = module_of(
7967 "use \"./t.allium\" as tickets\nrule Archive {\n when: tickets/ArchiveTicketRequested(ticket)\n requires: ticket.status = closed\n ensures: ticket.status = archived\n}\n",
7968 );
7969 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
7970 assert!(rc
7971 .witnessed_transitions
7972 .get("Ticket")
7973 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
7974 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
7976 }
7977
7978 #[test]
7979 fn reverse_contributions_credit_importer_owned_trigger_via_qualified_context() {
7980 let imported = module_of(
7984 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
7985 );
7986 let importer = module_of(
7987 "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",
7988 );
7989 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
7990 assert!(
7991 rc.witnessed_transitions
7992 .get("Ticket")
7993 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))),
7994 "importer-owned trigger typed via qualified context must witness the transition. Got: {:?}",
7995 rc.witnessed_transitions
7996 );
7997 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
7998 }
7999
8000 #[test]
8001 fn reverse_contributions_credit_becomes_transition_trigger_witness() {
8002 let imported = module_of(
8005 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
8006 );
8007 let importer = module_of(
8008 "use \"./t.allium\" as tickets\nrule Archive {\n when: t: tickets/Ticket.status becomes closed\n ensures: t.status = archived\n}\n",
8009 );
8010 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8011 assert!(rc
8012 .witnessed_transitions
8013 .get("Ticket")
8014 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
8015 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8016 }
8017
8018 #[test]
8019 fn reverse_contributions_require_the_matching_alias() {
8020 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8021 let importer = module_of(
8022 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
8023 );
8024 let rc = collect_reverse_contributions(&importer, "other", &imported);
8026 assert!(rc.is_empty());
8027 }
8028
8029 #[test]
8030 fn reverse_contributions_filter_undeclared_status() {
8031 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8032 let importer = module_of(
8034 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: pending)\n}\n",
8035 );
8036 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8037 assert!(rc.is_empty());
8038 }
8039}