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