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 pub referenced_fields: HashSet<String>,
75}
76
77impl ReverseContributions {
78 pub fn is_empty(&self) -> bool {
80 self.provided_triggers.is_empty()
81 && self.assigned_statuses.is_empty()
82 && self.witnessed_transitions.is_empty()
83 && self.referenced_fields.is_empty()
84 }
85
86 pub fn merge(&mut self, other: ReverseContributions) {
88 self.provided_triggers.extend(other.provided_triggers);
89 for (entity, statuses) in other.assigned_statuses {
90 self.assigned_statuses.entry(entity).or_default().extend(statuses);
91 }
92 for (entity, edges) in other.witnessed_transitions {
93 self.witnessed_transitions.entry(entity).or_default().extend(edges);
94 }
95 self.referenced_fields.extend(other.referenced_fields);
96 }
97}
98
99#[allow(clippy::too_many_arguments)] pub fn analyze_with_cross_module(
113 module: &Module,
114 source: &str,
115 external_refs: &HashSet<String>,
116 resolved_use_paths: &HashSet<String>,
117 imported_triggers: &HashMap<String, HashSet<String>>,
118 imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
119 ambiguous_imports: &AmbiguousImports,
120 reverse: &ReverseContributions,
121 imported_referenced_triggers: &HashMap<String, HashSet<String>>,
122 missing_use_paths: &HashSet<String>,
123) -> Vec<Diagnostic> {
124 let mut ctx = Ctx::new(
125 module,
126 external_refs,
127 Some(resolved_use_paths),
128 Some(imported_triggers),
129 Some(ambiguous_imports),
130 );
131 ctx.imported_entity_fields = Some(imported_entity_fields);
132 ctx.reverse_contributions = Some(reverse);
133 ctx.imported_referenced_triggers = Some(imported_referenced_triggers);
134 ctx.missing_use_paths = Some(missing_use_paths);
135 run_checks(ctx, source)
136}
137
138fn run_checks(mut ctx: Ctx<'_>, source: &str) -> Vec<Diagnostic> {
139 ctx.check_related_surface_references();
140 ctx.check_discriminator_variants();
141 ctx.check_surface_binding_usage();
142 ctx.check_status_state_machine();
143 ctx.check_external_entity_source_hints();
144 ctx.check_type_references();
145 ctx.check_unreachable_triggers();
146 ctx.check_unused_fields();
147 ctx.check_unused_entities();
148 ctx.check_unused_definitions();
149 ctx.check_unresolved_use_paths();
150 ctx.check_ambiguous_imported_names();
151 ctx.check_deferred_location_hints(source);
152 ctx.check_rule_invalid_triggers();
153 ctx.check_rule_undefined_bindings();
154 ctx.check_duplicate_let_bindings();
155 ctx.check_config_undefined_references();
156 ctx.check_list_literal_homogeneity();
157 ctx.check_undefined_import_aliases();
158 ctx.check_default_field_schemas();
159
160 let mut diagnostics = apply_suppressions(ctx.diagnostics, source);
161 diagnostics.sort_by(|a, b| {
166 (a.span.start, a.span.end, a.code.unwrap_or(""), a.message.as_str()).cmp(&(
167 b.span.start,
168 b.span.end,
169 b.code.unwrap_or(""),
170 b.message.as_str(),
171 ))
172 });
173 diagnostics
174}
175
176pub fn analyse(module: &Module, source: &str) -> crate::diagnostic::AnalyseResult {
179 let empty = HashSet::new();
180 analyse_with_external_refs(module, source, &empty)
181}
182
183pub fn analyse_with_external_refs(
186 module: &Module,
187 source: &str,
188 external_refs: &HashSet<String>,
189) -> crate::diagnostic::AnalyseResult {
190 let diagnostics = analyze_with_external_refs(module, source, external_refs);
191 let findings = find_process_issues(module, None, None, None);
192 crate::diagnostic::AnalyseResult {
193 diagnostics,
194 findings,
195 }
196}
197
198#[allow(clippy::too_many_arguments)] pub fn analyse_with_cross_module(
202 module: &Module,
203 source: &str,
204 external_refs: &HashSet<String>,
205 resolved_use_paths: &HashSet<String>,
206 imported_triggers: &HashMap<String, HashSet<String>>,
207 imported_entity_fields: &HashMap<String, HashMap<String, HashSet<String>>>,
208 ambiguous_imports: &AmbiguousImports,
209 reverse: &ReverseContributions,
210 imported_referenced_triggers: &HashMap<String, HashSet<String>>,
211 imported_entity_statuses: &HashMap<String, HashSet<String>>,
212 missing_use_paths: &HashSet<String>,
213) -> crate::diagnostic::AnalyseResult {
214 let diagnostics = analyze_with_cross_module(
215 module,
216 source,
217 external_refs,
218 resolved_use_paths,
219 imported_triggers,
220 imported_entity_fields,
221 ambiguous_imports,
222 reverse,
223 imported_referenced_triggers,
224 missing_use_paths,
225 );
226 let findings = find_process_issues(
227 module,
228 Some(imported_triggers),
229 Some(reverse),
230 Some(imported_entity_statuses),
231 );
232 crate::diagnostic::AnalyseResult {
233 diagnostics,
234 findings,
235 }
236}
237
238struct EntityInfo<'a> {
240 status_values: HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
242 field_types: HashMap<&'a str, HashMap<&'a str, &'a str>>,
244 graph_edges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
246 terminals: HashMap<&'a str, HashSet<&'a str>>,
248}
249
250impl<'a> EntityInfo<'a> {
251 fn from_module(module: &'a Module) -> Self {
252 let mut status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = HashMap::new();
253 let mut field_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
254 let mut graph_edges: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
255 let mut terminals: HashMap<&str, HashSet<&str>> = HashMap::new();
256
257 let entities = module.declarations.iter().filter_map(|d| match d {
258 Decl::Block(b) if b.kind == BlockKind::Entity => Some(b),
259 _ => None,
260 });
261 for entity in entities {
262 let name = match &entity.name {
263 Some(n) => n.name.as_str(),
264 None => continue,
265 };
266 for item in &entity.items {
267 match &item.kind {
268 BlockItemKind::Assignment { name: f, value } if f.name == "status" => {
269 let mut idents = Vec::new();
270 collect_pipe_idents(value, &mut idents);
271 if idents.len() >= 2
272 && !idents.iter().any(|id| starts_uppercase(&id.name))
273 {
274 let set: HashSet<&str> =
275 idents.iter().map(|id| id.name.as_str()).collect();
276 status_values.insert(name, (set, idents));
277 }
278 }
279 BlockItemKind::Assignment { name: f, value } => {
280 if let Some(t) = extract_field_entity_type(value) {
281 field_types.entry(name).or_default().insert(f.name.as_str(), t);
282 }
283 }
284 BlockItemKind::TransitionsBlock(graph) => {
285 let edges: Vec<(&str, &str)> = graph
286 .edges
287 .iter()
288 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
289 .collect();
290 graph_edges.insert(name, edges);
291 let terms: HashSet<&str> =
292 graph.terminal.iter().map(|t| t.name.as_str()).collect();
293 if !terms.is_empty() {
294 terminals.insert(name, terms);
295 }
296 }
297 _ => {}
298 }
299 }
300 }
301
302 Self { status_values, field_types, graph_edges, terminals }
303 }
304
305 fn status_by_entity(&self) -> HashMap<&'a str, HashSet<&'a str>> {
307 self.status_values
308 .iter()
309 .map(|(k, (set, _))| (*k, set.clone()))
310 .collect()
311 }
312}
313
314fn find_process_issues(
316 module: &Module,
317 imported_triggers: Option<&HashMap<String, HashSet<String>>>,
318 reverse: Option<&ReverseContributions>,
319 imported_statuses: Option<&HashMap<String, HashSet<String>>>,
320) -> Vec<crate::diagnostic::Finding> {
321 let empty = HashSet::new();
322 let no_statuses = HashMap::new();
323 let mut ctx = Ctx::new(module, &empty, None, imported_triggers, None);
324 ctx.reverse_contributions = reverse;
325 let info = EntityInfo::from_module(module);
326 ctx.collect_process_findings(&info);
327 ctx.collect_conflict_findings(&info, imported_statuses.unwrap_or(&no_statuses));
328 ctx.collect_invariant_findings(&info);
329 let mut findings = std::mem::take(&mut ctx.findings);
330 findings.sort_by_cached_key(|f| {
333 (
334 f["type"].as_str().unwrap_or("").to_string(),
335 f["summary"].as_str().unwrap_or("").to_string(),
336 f.to_string(),
337 )
338 });
339 findings
340}
341
342fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
347 if diagnostics.is_empty() {
348 return diagnostics;
349 }
350 let sm = SourceMap::new(source);
351 let directives = collect_suppression_directives(source, &sm);
352 if directives.is_empty() {
353 return diagnostics;
354 }
355 diagnostics
356 .into_iter()
357 .filter(|d| {
358 let (line, _) = sm.line_col(d.span.start);
359 let line = line as i64;
360 let active = directives
361 .get(&(line as u32))
362 .or_else(|| directives.get(&((line - 1).max(0) as u32)));
363 match (active, d.code) {
364 (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
365 (Some(codes), None) => !codes.contains("all"),
366 _ => true,
367 }
368 })
369 .collect()
370}
371
372fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
373 let mut directives = HashMap::new();
374 let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
375 for m in pattern.find_iter(source) {
376 let text = m.as_str();
377 let (line, _) = sm.line_col(m.start());
378 if let Some(idx) = text.find("allium-ignore") {
380 let offset = m.start() + idx + "allium-ignore".len();
381 let source_after = &source[offset..m.end()];
382 let codes: HashSet<&'a str> = source_after
383 .split(',')
384 .map(|c| c.trim())
385 .filter(|c| !c.is_empty())
386 .collect();
387 directives.insert(line, codes);
388 }
389 }
390 directives
391}
392
393struct Ctx<'a> {
398 module: &'a Module,
399 external_refs: &'a HashSet<String>,
400 resolved_use_paths: Option<&'a HashSet<String>>,
404 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
409 ambiguous_imports: Option<&'a AmbiguousImports>,
413 imported_entity_fields: Option<&'a HashMap<String, HashMap<String, HashSet<String>>>>,
418 imported_referenced_triggers: Option<&'a HashMap<String, HashSet<String>>>,
424 reverse_contributions: Option<&'a ReverseContributions>,
428 missing_use_paths: Option<&'a HashSet<String>>,
434 diagnostics: Vec<Diagnostic>,
435 findings: Vec<crate::diagnostic::Finding>,
436}
437
438impl<'a> Ctx<'a> {
439 fn new(
440 module: &'a Module,
441 external_refs: &'a HashSet<String>,
442 resolved_use_paths: Option<&'a HashSet<String>>,
443 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
444 ambiguous_imports: Option<&'a AmbiguousImports>,
445 ) -> Self {
446 Self {
447 module,
448 external_refs,
449 resolved_use_paths,
450 imported_triggers,
451 ambiguous_imports,
452 imported_entity_fields: None,
453 imported_referenced_triggers: None,
454 reverse_contributions: None,
455 missing_use_paths: None,
456 diagnostics: Vec::new(),
457 findings: Vec::new(),
458 }
459 }
460
461 fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
462 self.module.declarations.iter().filter_map(move |d| match d {
463 Decl::Block(b) if b.kind == kind => Some(b),
464 _ => None,
465 })
466 }
467
468 fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
469 self.module
470 .declarations
471 .iter()
472 .filter_map(|d| match d {
473 Decl::Variant(v) => Some(v),
474 _ => None,
475 })
476 }
477
478 fn has_use_imports(&self) -> bool {
479 self.module
480 .declarations
481 .iter()
482 .any(|d| matches!(d, Decl::Use(_)))
483 }
484
485 fn push(&mut self, d: Diagnostic) {
486 self.diagnostics.push(d);
487 }
488
489 fn push_finding(&mut self, finding: Finding) {
490 self.findings.push(finding);
491 }
492
493 fn declared_type_names(&self) -> HashSet<&'a str> {
496 let mut names = HashSet::new();
497 for d in &self.module.declarations {
498 match d {
499 Decl::Block(b) => {
500 if matches!(
501 b.kind,
502 BlockKind::Entity
503 | BlockKind::ExternalEntity
504 | BlockKind::Value
505 | BlockKind::Enum
506 | BlockKind::Actor
507 ) {
508 if let Some(n) = &b.name {
509 names.insert(n.name.as_str());
510 }
511 }
512 }
513 Decl::Variant(v) => {
514 names.insert(v.name.name.as_str());
515 }
516 _ => {}
517 }
518 }
519 for t in &[
521 "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
522 "List", "Set", "Map", "Any", "Void",
523 ] {
524 names.insert(t);
525 }
526 for d in &self.module.declarations {
528 if let Decl::Use(u) = d {
529 if let Some(alias) = &u.alias {
530 names.insert(alias.name.as_str());
531 }
532 }
533 }
534 names
535 }
536
537 fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
539 let mut names = HashSet::new();
540 for d in &self.module.declarations {
541 match d {
542 Decl::Block(b) => {
543 for item in &b.items {
544 collect_accessed_fields_from_item(&item.kind, &mut names);
545 }
546 if matches!(b.kind, BlockKind::Entity | BlockKind::ExternalEntity) {
552 let field_names: HashSet<&str> = b
553 .items
554 .iter()
555 .filter_map(|it| match &it.kind {
556 BlockItemKind::Assignment { name, .. }
557 | BlockItemKind::FieldWithWhen { name, .. } => {
558 Some(name.name.as_str())
559 }
560 _ => None,
561 })
562 .collect();
563 let mut idents = HashSet::new();
564 for item in &b.items {
565 collect_idents_from_item(&item.kind, &mut idents);
566 }
567 names.extend(idents.intersection(&field_names).copied());
568 }
569 }
570 Decl::Invariant(inv) => {
571 collect_accessed_fields_from_expr(&inv.body, &mut names);
572 }
573 _ => {}
574 }
575 }
576 names
577 }
578}
579
580impl Ctx<'_> {
585 fn check_related_surface_references(&mut self) {
586 let surface_names: HashSet<&str> = self
587 .blocks(BlockKind::Surface)
588 .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
589 .collect();
590
591 for surface in self.blocks(BlockKind::Surface) {
592 let surface_name = match &surface.name {
593 Some(n) => &n.name,
594 None => continue,
595 };
596
597 for item in &surface.items {
598 let BlockItemKind::Clause { keyword, value } = &item.kind else {
599 continue;
600 };
601 if keyword != "related" {
602 continue;
603 }
604
605 let refs = extract_related_surface_names(value);
606 for ident in refs {
607 if !surface_names.contains(ident.name.as_str()) {
608 self.push(
609 Diagnostic::error(
610 ident.span,
611 format!(
612 "Surface '{surface_name}' references unknown related surface '{}'.",
613 ident.name
614 ),
615 )
616 .with_code("allium.surface.relatedUndefined"),
617 );
618 }
619 }
620 }
621 }
622 }
623}
624
625fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
626 match expr {
627 Expr::Ident(id) => vec![id],
628 Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
629 Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
630 Expr::Block { items, .. } => items
631 .iter()
632 .flat_map(extract_related_surface_names)
633 .collect(),
634 _ => vec![],
635 }
636}
637
638fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
639 match expr {
640 Expr::Ident(id) => Some(id),
641 Expr::MemberAccess { object, .. } => extract_leading_ident(object),
642 _ => None,
643 }
644}
645
646impl Ctx<'_> {
651 fn check_discriminator_variants(&mut self) {
652 let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
653 for v in self.variants() {
654 let base_name = expr_as_ident(&v.base).or_else(|| {
655 if let Expr::JoinLookup { entity, .. } = &v.base {
657 expr_as_ident(entity)
658 } else {
659 None
660 }
661 });
662 if let Some(base_name) = base_name {
663 variants_by_base
664 .entry(base_name)
665 .or_default()
666 .insert(&v.name.name);
667 }
668 }
669
670 for entity in self.blocks(BlockKind::Entity) {
671 let entity_name = match &entity.name {
672 Some(n) => &n.name,
673 None => continue,
674 };
675
676 for item in &entity.items {
677 let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
678 continue;
679 };
680
681 let mut pipe_idents = Vec::new();
682 collect_pipe_idents(value, &mut pipe_idents);
683 if pipe_idents.len() < 2 {
684 continue;
685 }
686
687 let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
688 if !has_capitalised {
689 continue;
690 }
691
692 let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
693 if !all_capitalised {
694 self.push(
695 Diagnostic::error(
696 value.span(),
697 format!(
698 "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
699 field_name.name
700 ),
701 )
702 .with_code("allium.sum.invalidDiscriminator"),
703 );
704 continue;
705 }
706
707 let declared = variants_by_base
708 .get(entity_name.as_str())
709 .cloned()
710 .unwrap_or_default();
711
712 let missing: Vec<&&Ident> = pipe_idents
713 .iter()
714 .filter(|id| !declared.contains(id.name.as_str()))
715 .collect();
716
717 if missing.len() == pipe_idents.len() && declared.is_empty() {
718 self.push(
719 Diagnostic::error(
720 value.span(),
721 format!(
722 "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
723 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
724 declarations. Use lowercase values for a plain enum.",
725 field_name.name
726 ),
727 )
728 .with_code("allium.sum.v1InlineEnum"),
729 );
730 } else {
731 for id in missing {
732 self.push(
733 Diagnostic::error(
734 id.span,
735 format!(
736 "Entity '{entity_name}' discriminator references '{}' without matching \
737 'variant {} : {entity_name}'.",
738 id.name, id.name
739 ),
740 )
741 .with_code("allium.sum.discriminatorUnknownVariant"),
742 );
743 }
744 }
745 }
746 }
747 }
748}
749
750fn starts_uppercase(s: &str) -> bool {
751 s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
752}
753
754fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
755 match expr {
756 Expr::Ident(id) => out.push(id),
757 Expr::Pipe { left, right, .. } => {
758 collect_pipe_idents(left, out);
759 collect_pipe_idents(right, out);
760 }
761 _ => {}
762 }
763}
764
765fn expr_as_ident(expr: &Expr) -> Option<&str> {
766 match expr {
767 Expr::Ident(id) => Some(&id.name),
768 _ => None,
769 }
770}
771
772impl Ctx<'_> {
777 fn check_surface_binding_usage(&mut self) {
778 for surface in self.blocks(BlockKind::Surface) {
779 let surface_name = match &surface.name {
780 Some(n) => &n.name,
781 None => continue,
782 };
783
784 let has_provides = surface
786 .items
787 .iter()
788 .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
789
790 let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
792 let BlockItemKind::Clause { keyword, value } = &item.kind else {
793 continue;
794 };
795 if keyword != "facing" && keyword != "context" {
796 continue;
797 }
798 if let Expr::Binding { name, .. } = value {
799 bindings.push((&name.name, name.span, keyword == "facing"));
800 }
801 }
802
803 for (name, span, is_facing) in &bindings {
804 if *name == "_" {
805 continue;
806 }
807 if *is_facing && !has_provides {
809 continue;
810 }
811 let used = surface.items.iter().any(|item| {
812 let BlockItemKind::Clause { keyword, value } = &item.kind else {
813 return item_contains_ident(&item.kind, name);
814 };
815 if keyword == "facing" || keyword == "context" {
816 if let Expr::Binding {
817 name: binding_name, ..
818 } = value
819 {
820 if binding_name.name == *name {
821 return false;
822 }
823 }
824 }
825 expr_contains_ident(value, name)
826 });
827
828 if !used {
829 self.push(
830 Diagnostic::warning(
831 *span,
832 format!(
833 "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
834 ),
835 )
836 .with_code("allium.surface.unusedBinding"),
837 );
838 }
839 }
840 }
841 }
842}
843
844fn for_each_rule_clause<'a>(items: &'a [BlockItem], f: &mut impl FnMut(&'a str, &'a Expr)) {
852 for item in items {
853 match &item.kind {
854 BlockItemKind::Clause { keyword, value } => f(keyword, value),
855 BlockItemKind::IfBlock { branches, else_items } => {
856 for b in branches {
857 for_each_rule_clause(&b.items, f);
858 }
859 if let Some(else_items) = else_items {
860 for_each_rule_clause(else_items, f);
861 }
862 }
863 BlockItemKind::ForBlock { items, .. } => {
864 for_each_rule_clause(items, f);
865 }
866 _ => {}
867 }
868 }
869}
870
871impl Ctx<'_> {
872 fn check_status_state_machine(&mut self) {
873 let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
874 let mut terminal_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
875 let mut has_transitions: HashSet<&str> = HashSet::new();
876 let mut declared_edges: HashMap<&str, HashSet<(&str, &str)>> = HashMap::new();
877 let mut field_entity_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
878 for entity in self.blocks(BlockKind::Entity) {
879 let entity_name = match &entity.name {
880 Some(n) => n.name.as_str(),
881 None => continue,
882 };
883 for item in &entity.items {
884 match &item.kind {
885 BlockItemKind::Assignment { name, value } if name.name == "status" => {
886 let mut idents = Vec::new();
887 collect_pipe_idents(value, &mut idents);
888 if idents.len() < 2 {
889 continue;
890 }
891 if idents.iter().any(|id| starts_uppercase(&id.name)) {
892 continue;
893 }
894 let set: HashSet<&str> =
895 idents.iter().map(|id| id.name.as_str()).collect();
896 status_by_entity.insert(entity_name, (idents, set));
897 }
898 BlockItemKind::Assignment { name, value } => {
899 if let Some(type_name) = extract_field_entity_type(value) {
901 field_entity_types
902 .entry(entity_name)
903 .or_default()
904 .insert(name.name.as_str(), type_name);
905 }
906 }
907 BlockItemKind::TransitionsBlock(graph) => {
908 has_transitions.insert(entity_name);
909 let terminals: HashSet<&str> =
910 graph.terminal.iter().map(|t| t.name.as_str()).collect();
911 if !terminals.is_empty() {
912 terminal_by_entity.insert(entity_name, terminals);
913 }
914 let edges: HashSet<(&str, &str)> = graph
915 .edges
916 .iter()
917 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
918 .collect();
919 declared_edges.insert(entity_name, edges);
920 }
921 _ => {}
922 }
923 }
924 }
925 for fields in field_entity_types.values_mut() {
927 fields.retain(|_, type_name| status_by_entity.contains_key(type_name));
928 }
929
930 if status_by_entity.is_empty() {
931 return;
932 }
933
934 let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
939 for surface in self.blocks(BlockKind::Surface) {
940 for item in &surface.items {
941 let BlockItemKind::Clause { keyword, value } = &item.kind else {
942 continue;
943 };
944 if keyword == "provides" {
945 collect_command_param_types(value, &status_by_entity, &mut command_param_types);
946 }
947 }
948 }
949
950 let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
951 let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
952 HashMap::new();
953 let mut created_issues: Vec<Diagnostic> = Vec::new();
954
955 for rule in self.blocks(BlockKind::Rule) {
956 let mut binding_types = collect_rule_binding_types(rule, &status_by_entity);
957 augment_binding_types_from_commands(rule, &command_param_types, &mut binding_types);
958 let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
959
960 for_each_rule_clause(&rule.items, &mut |keyword, value| {
961 if keyword != "requires" {
962 return;
963 }
964 visit_status_comparisons(
965 value,
966 &binding_types,
967 &status_by_entity,
968 &field_entity_types,
969 &mut |binding, status| {
970 requires_by_binding
971 .entry(binding)
972 .or_default()
973 .insert(status);
974 },
975 );
976 });
977
978 for_each_rule_clause(&rule.items, &mut |keyword, value| {
983 if keyword != "when" {
984 return;
985 }
986 if let Some((binding, entity, source)) = local_transition_trigger_source(value) {
987 if status_by_entity
988 .get(entity)
989 .is_some_and(|(_, values)| values.contains(source))
990 {
991 requires_by_binding.entry(binding).or_default().insert(source);
992 }
993 }
994 });
995
996 for_each_rule_clause(&rule.items, &mut |keyword, value| {
997 if keyword != "ensures" {
998 return;
999 }
1000 visit_status_assignments(
1001 value,
1002 &binding_types,
1003 &status_by_entity,
1004 &field_entity_types,
1005 &mut |binding, target, entity| {
1006 assigned_by_entity
1007 .entry(entity)
1008 .or_default()
1009 .insert(target);
1010
1011 if let Some(sources) = requires_by_binding.get(binding) {
1012 let entity_transitions =
1013 transitions_by_entity.entry(entity).or_default();
1014 for source in sources {
1015 entity_transitions
1016 .entry(source)
1017 .or_default()
1018 .insert(target);
1019 }
1020 }
1021 },
1022 );
1023 visit_created_calls(
1024 value,
1025 &status_by_entity,
1026 &has_transitions,
1027 &mut |entity, status| {
1028 assigned_by_entity
1029 .entry(entity)
1030 .or_default()
1031 .insert(status);
1032 },
1033 &mut created_issues,
1034 );
1035 });
1036 }
1037
1038 if let Some(rev) = self.reverse_contributions {
1043 for (entity, statuses) in &rev.assigned_statuses {
1044 if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
1045 let set = assigned_by_entity.entry(*key).or_default();
1046 for s in statuses {
1047 if values.contains(s.as_str()) {
1048 set.insert(s.as_str());
1049 }
1050 }
1051 }
1052 }
1053 for (entity, edges) in &rev.witnessed_transitions {
1054 if let Some((key, (_, values))) = status_by_entity.get_key_value(entity.as_str()) {
1055 for (from, to) in edges {
1056 if values.contains(from.as_str()) && values.contains(to.as_str()) {
1057 transitions_by_entity
1058 .entry(*key)
1059 .or_default()
1060 .entry(from.as_str())
1061 .or_default()
1062 .insert(to.as_str());
1063 assigned_by_entity.entry(*key).or_default().insert(to.as_str());
1064 }
1065 }
1066 }
1067 }
1068 }
1069
1070 for (entity_name, (idents, values)) in &status_by_entity {
1071 let assigned = assigned_by_entity.get(entity_name);
1072 let transitions = transitions_by_entity.get(entity_name);
1073
1074 if let Some(assigned) = assigned {
1075 if assigned.iter().any(|v| !values.contains(v)) {
1076 continue;
1077 }
1078 }
1079
1080 let assigned_set = assigned.cloned().unwrap_or_default();
1081 let transition_map = transitions.cloned().unwrap_or_default();
1082
1083 for id in idents {
1084 if !assigned_set.contains(id.name.as_str()) {
1085 self.push(
1086 Diagnostic::warning(
1087 id.span,
1088 format!(
1089 "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
1090 id.name
1091 ),
1092 )
1093 .with_code("allium.status.unreachableValue"),
1094 );
1095 }
1096
1097 let is_terminal = terminal_by_entity
1098 .get(entity_name)
1099 .map_or_else(
1100 || is_likely_terminal(&id.name),
1101 |terminals| terminals.contains(id.name.as_str()),
1102 );
1103 if is_terminal {
1104 continue;
1105 }
1106 let exits = transition_map.get(id.name.as_str());
1107 if exits.is_some_and(|e| !e.is_empty()) {
1108 continue;
1109 }
1110 self.push(
1111 Diagnostic::warning(
1112 id.span,
1113 format!(
1114 "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
1115 id.name
1116 ),
1117 )
1118 .with_code("allium.status.noExit"),
1119 );
1120 }
1121 }
1122
1123 for (entity_name, transition_map) in &transitions_by_entity {
1125 if let Some(edges) = declared_edges.get(entity_name) {
1126 if let Some((idents, _)) = status_by_entity.get(entity_name) {
1127 for (from, targets) in transition_map {
1128 for to in targets {
1129 if from != to && !edges.contains(&(*from, *to)) {
1130 let span = idents
1132 .iter()
1133 .find(|id| id.name == *from)
1134 .map(|id| id.span)
1135 .unwrap_or(idents[0].span);
1136 self.push(
1137 Diagnostic::warning(
1138 span,
1139 format!(
1140 "Rule produces transition '{from}' → '{to}' on entity '{entity_name}', but this edge is not in the declared transition graph.",
1141 ),
1142 )
1143 .with_code("allium.status.undeclaredTransition"),
1144 );
1145 }
1146 }
1147 }
1148 }
1149 }
1150 }
1151
1152 for issue in created_issues {
1153 self.push(issue);
1154 }
1155 }
1156}
1157
1158impl Ctx<'_> {
1163 fn collect_process_findings(&mut self, info: &EntityInfo<'_>) {
1164 let status_values = &info.status_values;
1165 let field_types = &info.field_types;
1166 let graph_edges = &info.graph_edges;
1167 let terminals = &info.terminals;
1168
1169 if status_values.is_empty() {
1170 return;
1171 }
1172
1173 let mut surface_triggers: HashSet<&str> = HashSet::new();
1175 let mut surface_names: Vec<String> = Vec::new();
1176 for surface in self.blocks(BlockKind::Surface) {
1177 if let Some(n) = &surface.name {
1178 surface_names.push(n.name.clone());
1179 }
1180 for item in &surface.items {
1181 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1182 continue;
1183 };
1184 if keyword == "provides" {
1185 collect_call_names(value, &mut surface_triggers);
1186 }
1187 }
1188 }
1189 if let Some(rev) = self.reverse_contributions {
1192 for t in &rev.provided_triggers {
1193 surface_triggers.insert(t.as_str());
1194 }
1195 }
1196
1197 let mut emitted_triggers: HashSet<&str> = HashSet::new();
1199 for rule in self.blocks(BlockKind::Rule) {
1200 for item in &rule.items {
1201 collect_emitted_trigger_from_item(&item.kind, &mut emitted_triggers);
1202 }
1203 }
1204
1205 let mut assigned_fields: HashSet<String> = HashSet::new();
1207 if let Some(rev) = self.reverse_contributions {
1211 for (entity, statuses) in &rev.assigned_statuses {
1212 for s in statuses {
1213 assigned_fields.insert(format!("{entity}.status.{s}"));
1214 }
1215 }
1216 for (entity, edges) in &rev.witnessed_transitions {
1217 for (_from, to) in edges {
1218 assigned_fields.insert(format!("{entity}.status.{to}"));
1219 }
1220 }
1221 }
1222
1223 struct RuleData<'b> {
1224 name: &'b str,
1225 trigger_reachable: bool,
1226 requires_fields: Vec<(String, String, String)>,
1227 transitions: Vec<(String, String, String)>,
1228 field_assignments: HashSet<String>,
1229 entity_bindings: Vec<String>,
1230 }
1231 let mut rules: Vec<RuleData> = Vec::new();
1232
1233 for rule in self.blocks(BlockKind::Rule) {
1234 let rule_name = match &rule.name {
1235 Some(n) => n.name.as_str(),
1236 None => continue,
1237 };
1238 let mut trigger_ref: Option<TriggerRef<'_>> = None;
1239 let mut requires_statuses: HashMap<&str, HashSet<&str>> = HashMap::new();
1240 let mut requires_fields: Vec<(String, String, String)> = Vec::new();
1241 let mut ensures_statuses: Vec<(&str, &str)> = Vec::new();
1242 let mut rule_assigned: HashSet<String> = HashSet::new();
1243
1244 for item in &rule.items {
1245 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1246 continue;
1247 };
1248 if keyword == "when" {
1249 let mut refs = extract_trigger_refs(value);
1250 if !refs.is_empty() {
1251 trigger_ref = Some(refs.remove(0));
1252 }
1253 }
1254 }
1255
1256 let trigger_reachable = trigger_ref.as_ref().map_or(true, |t| {
1260 self.trigger_reachability(t, &surface_triggers, &emitted_triggers)
1261 .unwrap_or(true)
1262 });
1263
1264 let binding_types = collect_rule_binding_types(rule, &status_values_for_binding(&status_values));
1265
1266 let entity_bindings: Vec<String> = binding_types
1268 .values()
1269 .map(|v| v.to_string())
1270 .collect::<HashSet<_>>()
1271 .into_iter()
1272 .collect();
1273
1274 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1275 if keyword != "requires" {
1276 return;
1277 }
1278 collect_requires_conditions(
1279 value,
1280 &binding_types,
1281 status_values,
1282 &mut |binding, field, val| {
1283 if field == "status" {
1284 requires_statuses
1285 .entry(binding)
1286 .or_default()
1287 .insert(val);
1288 } else {
1289 let entity = resolve_binding_entity_from_status(
1290 binding, None, &binding_types, &status_values,
1291 );
1292 if let Some(e) = entity {
1293 requires_fields.push((
1294 e.to_string(),
1295 field.to_string(),
1296 val.to_string(),
1297 ));
1298 }
1299 }
1300 },
1301 );
1302 });
1303
1304 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1305 if keyword != "ensures" {
1306 return;
1307 }
1308 collect_field_assignments(
1309 value,
1310 &binding_types,
1311 &status_values,
1312 &field_types,
1313 &mut |entity, field, value| {
1314 let key = format!("{entity}.{field}");
1315 assigned_fields.insert(key.clone());
1316 rule_assigned.insert(key);
1317 if field == "status" && value != "_variable_" {
1318 assigned_fields.insert(format!("{entity}.status.{value}"));
1319 }
1320 },
1321 );
1322 collect_ensures_status(
1323 value,
1324 &binding_types,
1325 &status_values,
1326 &field_types,
1327 &mut |binding, target| {
1328 ensures_statuses.push((binding, target));
1329 },
1330 );
1331 });
1332
1333 let mut transitions = Vec::new();
1334 for (binding, target) in &ensures_statuses {
1335 let entity = resolve_binding_entity_from_status(
1336 binding,
1337 Some(target),
1338 &binding_types,
1339 &status_values,
1340 );
1341 if let Some(e) = entity {
1342 if let Some(sources) = requires_statuses.get(binding) {
1343 for source in sources {
1344 transitions.push((
1345 e.to_string(),
1346 source.to_string(),
1347 target.to_string(),
1348 ));
1349 }
1350 }
1351 }
1352 }
1353
1354 rules.push(RuleData {
1355 name: rule_name,
1356 trigger_reachable,
1357 requires_fields,
1358 transitions,
1359 field_assignments: rule_assigned,
1360 entity_bindings,
1361 });
1362 }
1363
1364 let mut created_fields: HashSet<String> = HashSet::new();
1366 for rule in self.blocks(BlockKind::Rule) {
1367 for_each_rule_clause(&rule.items, &mut |keyword, value| {
1368 if keyword != "ensures" {
1369 return;
1370 }
1371 collect_created_field_assignments(value, &status_values, &mut assigned_fields);
1372 collect_created_field_assignments(value, &status_values, &mut created_fields);
1373 });
1374 }
1375
1376 let mut surface_provided_fields: HashSet<String> = HashSet::new();
1378 for surface in self.blocks(BlockKind::Surface) {
1379 for item in &surface.items {
1380 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1381 continue;
1382 };
1383 if keyword == "provides" {
1384 collect_surface_provided_fields(value, &status_values, &mut surface_provided_fields);
1385 }
1386 }
1387 }
1388
1389 let build_searched = |entity: &str, field: &str| -> Vec<serde_json::Value> {
1391 let key = format!("{entity}.{field}");
1392 let mut searched = Vec::new();
1393
1394 let matching_rule: Option<&RuleData> = rules.iter().find(|r| {
1396 r.field_assignments.contains(&key)
1397 });
1398 if let Some(r) = matching_rule {
1399 if !r.trigger_reachable {
1400 searched.push(serde_json::json!({
1401 "kind": "rule_ensures",
1402 "found": r.name,
1403 "but": "trigger has no providing surface"
1404 }));
1405 } else {
1406 searched.push(serde_json::json!({
1407 "kind": "rule_ensures",
1408 "found": r.name
1409 }));
1410 }
1411 } else {
1412 searched.push(serde_json::json!({
1413 "kind": "rule_ensures",
1414 "found": false
1415 }));
1416 }
1417
1418 searched.push(serde_json::json!({
1420 "kind": "surface_provides",
1421 "found": surface_provided_fields.contains(&key)
1422 }));
1423
1424 searched.push(serde_json::json!({
1426 "kind": "created_calls",
1427 "found": created_fields.contains(&key)
1428 }));
1429
1430 searched
1431 };
1432
1433 for (entity, edges) in graph_edges {
1435 let _statuses = match status_values.get(entity) {
1436 Some(v) => v,
1437 None => continue,
1438 };
1439
1440 for (from, to) in edges {
1441 let witnesses: Vec<&RuleData> = rules
1442 .iter()
1443 .filter(|r| {
1444 r.transitions
1445 .iter()
1446 .any(|(e, f, t)| e == *entity && f == *from && t == *to)
1447 })
1448 .collect();
1449
1450 if witnesses.is_empty() {
1451 continue;
1452 }
1453
1454 let any_achievable = witnesses.iter().any(|r| {
1455 r.requires_fields.iter().all(|(e, f, _v)| {
1456 assigned_fields.contains(&format!("{e}.{f}"))
1457 })
1458 });
1459
1460 if !any_achievable {
1461 let witness_names: Vec<String> =
1462 witnesses.iter().map(|r| r.name.to_string()).collect();
1463 let unsatisfiable: Vec<serde_json::Value> = witnesses
1464 .iter()
1465 .flat_map(|r| {
1466 r.requires_fields.iter().filter(|(e, f, _)| {
1467 !assigned_fields.contains(&format!("{e}.{f}"))
1468 })
1469 })
1470 .map(|(e, f, v)| {
1471 serde_json::json!({
1472 "entity": e,
1473 "field": f,
1474 "value": v,
1475 "searched": build_searched(e, f),
1476 })
1477 })
1478 .collect();
1479
1480 self.push_finding(serde_json::json!({
1481 "type": "dead_transition",
1482 "summary": format!(
1483 "Transition '{from}' → '{to}' on entity '{entity}' is declared but unachievable"
1484 ),
1485 "edge": {"entity": entity, "from": from, "to": to},
1486 "witnessing_rules": witness_names,
1487 "unsatisfiable_requires": unsatisfiable,
1488 "affected_entities": [entity],
1489 }));
1490 }
1491 }
1492 }
1493
1494 for r in &rules {
1496 for (entity, field, value) in &r.requires_fields {
1497 let key = format!("{entity}.{field}");
1498 if !assigned_fields.contains(&key) {
1499 self.push_finding(serde_json::json!({
1500 "type": "missing_producer",
1501 "summary": format!("Nothing establishes {entity}.{field} = {value}"),
1502 "requires": {"rule": r.name, "field": field, "value": value},
1503 "searched": build_searched(entity, field),
1504 "affected_entities": [entity],
1505 }));
1506 }
1507 }
1508 }
1509
1510 for (entity, edges) in graph_edges {
1512 let entity_terminals = match terminals.get(entity) {
1513 Some(t) => t,
1514 None => continue,
1515 };
1516 let (statuses, _idents) = match status_values.get(entity) {
1517 Some(v) => v,
1518 None => continue,
1519 };
1520
1521 let achievable_edges: HashSet<(&str, &str)> = edges
1522 .iter()
1523 .filter(|(_from, to)| {
1524 let producers: Vec<&RuleData> = rules
1525 .iter()
1526 .filter(|r| {
1527 r.transitions
1528 .iter()
1529 .any(|(e, _f, t)| e == *entity && t == *to)
1530 })
1531 .collect();
1532 if producers.is_empty() {
1533 return assigned_fields.contains(&format!("{entity}.status.{to}"));
1534 }
1535 producers.iter().any(|r| {
1536 r.requires_fields.iter().all(|(e, f, _v)| {
1537 assigned_fields.contains(&format!("{e}.{f}"))
1538 })
1539 })
1540 })
1541 .copied()
1542 .collect();
1543
1544 for status in statuses {
1545 if entity_terminals.contains(status) {
1546 continue;
1547 }
1548 let mut visited = HashSet::new();
1549 let mut queue = vec![*status];
1550 let mut found_terminal = false;
1551 while let Some(current) = queue.pop() {
1552 if !visited.insert(current) {
1553 continue;
1554 }
1555 if entity_terminals.contains(current) {
1556 found_terminal = true;
1557 break;
1558 }
1559 for (from, to) in &achievable_edges {
1560 if *from == current {
1561 queue.push(to);
1562 }
1563 }
1564 }
1565 if !found_terminal {
1566 let has_inbound = achievable_edges
1567 .iter()
1568 .any(|(_, to)| *to == *status);
1569
1570 if has_inbound || statuses.len() <= 6 {
1571 let outbound: Vec<serde_json::Value> = edges
1573 .iter()
1574 .filter(|(f, _)| *f == *status)
1575 .map(|(f, t)| {
1576 let witness_rules: Vec<(&str, &[(String, String, String)])> =
1577 rules
1578 .iter()
1579 .filter(|r| {
1580 r.transitions.iter().any(|(e, _ef, et)| {
1581 e == *entity && et == *t
1582 })
1583 })
1584 .map(|r| {
1585 (r.name, r.requires_fields.as_slice())
1586 })
1587 .collect();
1588 let reason = edge_blocked_reason(
1589 &witness_rules, &assigned_fields,
1590 );
1591 serde_json::json!({
1592 "from": f,
1593 "to": t,
1594 "reason": reason,
1595 })
1596 })
1597 .collect();
1598
1599 let cycle = detect_cycle(*status, &achievable_edges);
1601
1602 self.push_finding(serde_json::json!({
1603 "type": "deadlock",
1604 "summary": format!(
1605 "Entity '{entity}' can reach state '{status}' but has no achievable path to any terminal state"
1606 ),
1607 "state": status,
1608 "outbound_edges": outbound,
1609 "cycle": cycle,
1610 "affected_entities": [entity],
1611 }));
1612 }
1613 }
1614 }
1615 }
1616
1617 let mut unreachable_by_trigger: HashMap<String, Vec<(&str, Vec<String>)>> = HashMap::new();
1619 for rule in self.blocks(BlockKind::Rule) {
1620 let rule_name = match &rule.name {
1621 Some(n) => n.name.as_str(),
1622 None => continue,
1623 };
1624 for item in &rule.items {
1625 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1626 continue;
1627 };
1628 if keyword != "when" {
1629 continue;
1630 }
1631 for tref in extract_trigger_refs(value) {
1632 if self.trigger_reachability(&tref, &surface_triggers, &emitted_triggers)
1633 == Some(false)
1634 {
1635 let rule_data = rules.iter().find(|r| r.name == rule_name);
1637 let bindings = rule_data
1638 .map(|r| r.entity_bindings.clone())
1639 .unwrap_or_default();
1640 unreachable_by_trigger
1641 .entry(tref.display())
1642 .or_default()
1643 .push((rule_name, bindings));
1644 }
1645 }
1646 }
1647 }
1648 for (trigger, rule_entries) in &unreachable_by_trigger {
1649 let listening_rules: Vec<&str> = rule_entries.iter().map(|(n, _)| *n).collect();
1650 let affected_entities: Vec<String> = rule_entries
1651 .iter()
1652 .flat_map(|(_, bindings)| bindings.iter().cloned())
1653 .collect::<HashSet<_>>()
1654 .into_iter()
1655 .collect();
1656 self.push_finding(serde_json::json!({
1657 "type": "unreachable_trigger",
1658 "summary": format!(
1659 "Trigger '{trigger}' is not provided by any surface"
1660 ),
1661 "trigger": trigger,
1662 "listening_rules": listening_rules,
1663 "surfaces_checked": surface_names,
1664 "affected_entities": affected_entities,
1665 }));
1666 }
1667 }
1668
1669 fn collect_conflict_findings(
1670 &mut self,
1671 info: &EntityInfo<'_>,
1672 imported_statuses: &HashMap<String, HashSet<String>>,
1673 ) {
1674 let local = info.status_by_entity();
1681 let mut status_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
1682 for (k, v) in &local {
1683 status_by_entity.insert(*k, v.iter().copied().collect());
1684 }
1685 for (ent, statuses) in imported_statuses {
1686 status_by_entity
1687 .entry(ent.as_str())
1688 .or_insert_with(|| statuses.iter().map(String::as_str).collect());
1689 }
1690
1691 if status_by_entity.is_empty() {
1692 return;
1693 }
1694
1695 struct ConflictRule<'b> {
1696 name: &'b str,
1697 trigger_kind: ConflictTriggerKind<'b>,
1698 requires_statuses: HashMap<String, HashSet<String>>,
1699 ensures_statuses: HashMap<String, String>,
1700 }
1701
1702 let mut conflict_rules: Vec<ConflictRule> = Vec::new();
1703
1704 for rule in self.blocks(BlockKind::Rule) {
1705 let rule_name = match &rule.name {
1706 Some(n) => n.name.as_str(),
1707 None => continue,
1708 };
1709 let binding_types = collect_rule_binding_types(rule, &HashMap::<&str, ()>::new());
1712
1713 let mut trigger_kind = ConflictTriggerKind::Unknown;
1714 let mut requires_statuses: HashMap<String, HashSet<String>> = HashMap::new();
1715 let mut ensures_statuses: HashMap<String, String> = HashMap::new();
1716
1717 for_each_rule_clause(&rule.items, &mut |keyword, value| match keyword {
1721 "when" => {
1722 trigger_kind = classify_trigger(value);
1723 }
1724 "requires" => {
1725 collect_requires_statuses_for_conflict(
1726 value,
1727 &binding_types,
1728 &status_by_entity,
1729 &mut requires_statuses,
1730 );
1731 }
1732 "ensures" => {
1733 collect_ensures_statuses_for_conflict(
1734 value,
1735 &binding_types,
1736 &status_by_entity,
1737 &mut ensures_statuses,
1738 );
1739 }
1740 _ => {}
1741 });
1742
1743 conflict_rules.push(ConflictRule {
1744 name: rule_name,
1745 trigger_kind,
1746 requires_statuses,
1747 ensures_statuses,
1748 });
1749 }
1750
1751 let mut reported: HashSet<(usize, usize)> = HashSet::new();
1753 for i in 0..conflict_rules.len() {
1754 for j in (i + 1)..conflict_rules.len() {
1755 let a = &conflict_rules[i];
1756 let b = &conflict_rules[j];
1757
1758 if matches!(
1759 (&a.trigger_kind, &b.trigger_kind),
1760 (ConflictTriggerKind::Call(_), ConflictTriggerKind::Call(_))
1761 ) {
1762 continue;
1763 }
1764
1765 let mut overlap_state: Option<(&str, &str)> = None;
1767 let mut compatible = false;
1768 for (entity, a_statuses) in &a.requires_statuses {
1769 if let Some(b_statuses) = b.requires_statuses.get(entity) {
1770 let intersection: Vec<&String> =
1771 a_statuses.intersection(b_statuses).collect();
1772 if !intersection.is_empty() {
1773 compatible = true;
1774 overlap_state = Some((entity.as_str(), intersection[0].as_str()));
1775 break;
1776 }
1777 }
1778 }
1779 if !compatible {
1780 continue;
1781 }
1782
1783 for (entity, a_target) in &a.ensures_statuses {
1784 if let Some(b_target) = b.ensures_statuses.get(entity) {
1785 if a_target != b_target && !reported.contains(&(i, j)) {
1786 reported.insert((i, j));
1787 let state = overlap_state
1788 .map(|(_, s)| s.to_string())
1789 .unwrap_or_default();
1790 let mut values = serde_json::Map::new();
1791 values.insert(a.name.to_string(), serde_json::json!(a_target));
1792 values.insert(b.name.to_string(), serde_json::json!(b_target));
1793
1794 self.push_finding(serde_json::json!({
1795 "type": "conflict",
1796 "summary": format!(
1797 "Rules '{}' and '{}' can both fire when entity '{entity}' is in state '{state}', setting status to conflicting values",
1798 a.name, b.name,
1799 ),
1800 "rule_a": a.name,
1801 "rule_b": b.name,
1802 "field": "status",
1803 "state": state,
1804 "values": values,
1805 "affected_entities": [entity],
1806 }));
1807 }
1808 }
1809 }
1810 }
1811 }
1812 }
1813
1814 fn collect_invariant_findings(&mut self, info: &EntityInfo<'_>) {
1815 let status_by_entity = info.status_by_entity();
1816 let field_types = &info.field_types;
1817
1818 struct RuleEffect<'b> {
1819 name: &'b str,
1820 status_sets: Vec<(String, String)>,
1821 field_sets: HashSet<String>,
1822 requires: Vec<(String, String, String)>,
1823 }
1824
1825 let binding_map: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
1826 .iter()
1827 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
1828 .collect();
1829 let binding_map_for_types: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = status_by_entity
1830 .iter()
1831 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1832 .collect();
1833 let mut rule_effects: Vec<RuleEffect> = Vec::new();
1834
1835 for rule in self.blocks(BlockKind::Rule) {
1836 let rule_name = match &rule.name {
1837 Some(n) => n.name.as_str(),
1838 None => continue,
1839 };
1840 let binding_types = collect_rule_binding_types(rule, &binding_map_for_types);
1841 let mut status_sets = Vec::new();
1842 let mut field_sets = HashSet::new();
1843 let mut requires = Vec::new();
1844
1845 for_each_rule_clause(&rule.items, &mut |keyword, value| match keyword {
1848 "ensures" => {
1849 collect_rule_effects(
1850 value,
1851 &binding_types,
1852 &status_by_entity,
1853 &field_types,
1854 &mut status_sets,
1855 &mut field_sets,
1856 );
1857 }
1858 "requires" => {
1859 collect_requires_conditions(
1860 value,
1861 &binding_types,
1862 &binding_map,
1863 &mut |binding, field, val| {
1864 let entity = resolve_binding_entity(
1865 binding,
1866 None,
1867 &binding_types,
1868 &binding_map_for_types,
1869 );
1870 if let Some(e) = entity {
1871 requires.push((
1872 e.to_string(),
1873 field.to_string(),
1874 val.to_string(),
1875 ));
1876 }
1877 },
1878 );
1879 }
1880 _ => {}
1881 });
1882
1883 rule_effects.push(RuleEffect {
1884 name: rule_name,
1885 status_sets,
1886 field_sets,
1887 requires,
1888 });
1889 }
1890
1891 for decl in &self.module.declarations {
1893 let Decl::Invariant(inv) = decl else {
1894 continue;
1895 };
1896
1897 if let Some(pattern) = extract_uniqueness_invariant(&inv.body) {
1898 let key_entity_type: Option<&str> = status_by_entity
1899 .keys()
1900 .find_map(|entity_name| {
1901 field_types
1902 .get(entity_name)
1903 .and_then(|fields| fields.get(pattern.key_field).copied())
1904 });
1905
1906 for effect in &rule_effects {
1907 for (entity, target) in &effect.status_sets {
1908 if target == pattern.prohibited_status {
1909 let has_guard = key_entity_type.map_or(false, |ket| {
1910 effect.field_sets.iter().any(|f| {
1911 f.starts_with(&format!("{ket}."))
1912 }) || effect.requires.iter().any(|(e, _f, _v)| {
1913 e == ket
1914 })
1915 });
1916
1917 if !has_guard {
1918 let needed = format!(
1919 "Rule should set {}.status to prevent concurrent {} states",
1920 key_entity_type.unwrap_or("related entity"),
1921 pattern.prohibited_status,
1922 );
1923 self.push_finding(serde_json::json!({
1924 "type": "invariant_risk",
1925 "summary": format!(
1926 "Rule '{}' could violate invariant '{}'",
1927 effect.name, inv.name.name,
1928 ),
1929 "rule": effect.name,
1930 "invariant": inv.name.name,
1931 "mechanism": format!(
1932 "Sets {entity}.status to '{target}' without preventing concurrent instances"
1933 ),
1934 "guard_analysis": {
1935 "has_guard": false,
1936 "needed": needed,
1937 },
1938 "affected_entities": [entity],
1939 }));
1940 }
1941 }
1942 }
1943 }
1944 }
1945 }
1946 }
1947}
1948
1949fn edge_blocked_reason(
1954 witness_rules: &[(&str, &[(String, String, String)])],
1955 assigned_fields: &HashSet<String>,
1956) -> String {
1957 if witness_rules.is_empty() {
1958 return "no witnessing rule".to_string();
1959 }
1960
1961 for (name, requires_fields) in witness_rules {
1962 for (e, f, v) in *requires_fields {
1963 if !assigned_fields.contains(&format!("{e}.{f}")) {
1964 return format!(
1965 "rule {name} requires {e}.{f} = {v}, never established",
1966 );
1967 }
1968 }
1969 }
1970
1971 "no achievable witnessing rule".to_string()
1972}
1973
1974fn detect_cycle<'a>(
1977 start: &'a str,
1978 edges: &HashSet<(&'a str, &'a str)>,
1979) -> Option<Vec<&'a str>> {
1980 let mut stack: Vec<(&str, Vec<&str>)> = vec![(start, vec![start])];
1982 let mut visited: HashSet<&str> = HashSet::new();
1983
1984 while let Some((current, path)) = stack.pop() {
1985 if !visited.insert(current) {
1986 continue;
1987 }
1988 for (from, to) in edges {
1989 if *from != current {
1990 continue;
1991 }
1992 if let Some(pos) = path.iter().position(|s| *s == *to) {
1993 let mut cycle: Vec<&str> = path[pos..].to_vec();
1995 cycle.push(to);
1996 return Some(cycle);
1997 }
1998 let mut next_path = path.clone();
1999 next_path.push(to);
2000 visited.remove(to);
2002 stack.push((to, next_path));
2003 }
2004 }
2005 None
2006}
2007
2008fn collect_surface_provided_fields(
2010 expr: &Expr,
2011 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2012 out: &mut HashSet<String>,
2013) {
2014 match expr {
2015 Expr::Call { function, args, .. } => {
2016 if let Expr::Ident(fn_name) = function.as_ref() {
2017 for arg in args {
2020 if let CallArg::Positional(Expr::Ident(binding)) = arg {
2021 if status_values.contains_key(binding.name.as_str()) {
2023 out.insert(format!("{}.status", binding.name));
2025 }
2026 }
2027 if let CallArg::Named(named) = arg {
2028 if let Expr::Ident(val) = &named.value {
2029 if status_values.contains_key(val.name.as_str()) {
2030 out.insert(format!("{}.{}", val.name, named.name.name));
2031 }
2032 }
2033 }
2034 }
2035 let _ = fn_name;
2037 }
2038 }
2039 Expr::Block { items, .. } => {
2040 for item in items {
2041 collect_surface_provided_fields(item, status_values, out);
2042 }
2043 }
2044 Expr::WhenGuard { action, .. } => {
2045 collect_surface_provided_fields(action, status_values, out);
2046 }
2047 Expr::Conditional { branches, else_body, .. } => {
2048 for b in branches {
2049 collect_surface_provided_fields(&b.body, status_values, out);
2050 }
2051 if let Some(body) = else_body {
2052 collect_surface_provided_fields(body, status_values, out);
2053 }
2054 }
2055 _ => {}
2056 }
2057}
2058
2059fn collect_rule_effects(
2061 expr: &Expr,
2062 binding_types: &HashMap<&str, &str>,
2063 status_by_entity: &HashMap<&str, HashSet<&str>>,
2064 field_types: &HashMap<&str, HashMap<&str, &str>>,
2065 status_sets: &mut Vec<(String, String)>,
2066 field_sets: &mut HashSet<String>,
2067) {
2068 match expr {
2069 Expr::Comparison {
2070 left,
2071 op: ComparisonOp::Eq,
2072 right,
2073 ..
2074 } => {
2075 if let Some(target) = expr_as_ident(right) {
2076 if let Some((binding, field)) = expr_as_member_access(left) {
2077 let entity = resolve_binding_entity(
2078 binding,
2079 if field == "status" { Some(target) } else { None },
2080 binding_types,
2081 &status_by_entity
2082 .iter()
2083 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2084 .collect(),
2085 );
2086 if let Some(e) = entity {
2087 if field == "status" {
2088 status_sets.push((e.to_string(), target.to_string()));
2089 }
2090 field_sets.insert(format!("{e}.{field}"));
2091 }
2092 }
2093 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2095 let root_entity = resolve_binding_entity(
2096 root,
2097 None,
2098 binding_types,
2099 &status_by_entity
2100 .iter()
2101 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2102 .collect(),
2103 );
2104 if let Some(re) = root_entity {
2105 if let Some(nested) =
2106 field_types.get(re).and_then(|f| f.get(mid).copied())
2107 {
2108 if field == "status" {
2109 status_sets.push((nested.to_string(), target.to_string()));
2110 }
2111 field_sets.insert(format!("{nested}.{field}"));
2112 }
2113 }
2114 }
2115 }
2116 }
2117 Expr::Block { items, .. } => {
2118 for item in items {
2119 collect_rule_effects(
2120 item, binding_types, status_by_entity, field_types, status_sets, field_sets,
2121 );
2122 }
2123 }
2124 Expr::Conditional {
2125 branches,
2126 else_body,
2127 ..
2128 } => {
2129 for branch in branches {
2130 collect_rule_effects(
2131 &branch.body, binding_types, status_by_entity, field_types, status_sets,
2132 field_sets,
2133 );
2134 }
2135 if let Some(body) = else_body {
2136 collect_rule_effects(
2137 body, binding_types, status_by_entity, field_types, status_sets, field_sets,
2138 );
2139 }
2140 }
2141 _ => {}
2142 }
2143}
2144
2145struct UniquenessPattern<'a> {
2148 prohibited_status: &'a str,
2149 key_field: &'a str,
2150}
2151
2152fn extract_uniqueness_invariant<'a>(expr: &'a Expr) -> Option<UniquenessPattern<'a>> {
2154 let Expr::For { body, .. } = expr else {
2156 return None;
2157 };
2158 let Expr::For { body: inner_body, .. } = body.as_ref() else {
2159 return None;
2160 };
2161
2162 let Expr::LogicalOp {
2164 op: LogicalOp::Implies,
2165 left: premise,
2166 right: conclusion,
2167 ..
2168 } = inner_body.as_ref()
2169 else {
2170 return None;
2171 };
2172
2173 let Expr::Not { operand, .. } = conclusion.as_ref() else {
2175 return None;
2176 };
2177
2178 let prohibited = extract_prohibited_status(operand)?;
2180
2181 let key_field = extract_key_field(premise)?;
2183
2184 Some(UniquenessPattern {
2185 prohibited_status: prohibited,
2186 key_field,
2187 })
2188}
2189
2190fn extract_prohibited_status(expr: &Expr) -> Option<&str> {
2192 let Expr::LogicalOp {
2193 op: LogicalOp::And,
2194 left,
2195 right,
2196 ..
2197 } = expr
2198 else {
2199 return None;
2200 };
2201
2202 let l_status = extract_status_value(left)?;
2204 let r_status = extract_status_value(right)?;
2205
2206 if l_status == r_status {
2207 Some(l_status)
2208 } else {
2209 None
2210 }
2211}
2212
2213fn extract_status_value(expr: &Expr) -> Option<&str> {
2214 if let Expr::Comparison {
2215 left,
2216 op: ComparisonOp::Eq,
2217 right,
2218 ..
2219 } = expr
2220 {
2221 if let Some((_, "status")) = expr_as_member_access(left) {
2222 return expr_as_ident(right);
2223 }
2224 }
2225 None
2226}
2227
2228fn extract_key_field(expr: &Expr) -> Option<&str> {
2230 let Expr::LogicalOp {
2231 op: LogicalOp::And,
2232 left: _,
2233 right,
2234 ..
2235 } = expr
2236 else {
2237 return None;
2238 };
2239
2240 if let Expr::Comparison {
2242 left,
2243 op: ComparisonOp::Eq,
2244 right: _,
2245 ..
2246 } = right.as_ref()
2247 {
2248 if let Some((_, field)) = expr_as_member_access(left) {
2249 return Some(field);
2253 }
2254 }
2255 None
2256}
2257
2258#[derive(PartialEq)]
2259enum ConflictTriggerKind<'a> {
2260 Call(&'a str),
2261 Temporal,
2262 Unknown,
2263}
2264
2265fn classify_trigger(expr: &Expr) -> ConflictTriggerKind<'_> {
2266 match expr {
2267 Expr::Call { function, .. } => {
2268 if let Expr::Ident(id) = function.as_ref() {
2269 return ConflictTriggerKind::Call(&id.name);
2270 }
2271 ConflictTriggerKind::Unknown
2272 }
2273 Expr::Binding { value, .. } => classify_trigger(value),
2274 Expr::Comparison { .. }
2275 | Expr::Becomes { .. }
2276 | Expr::TransitionsTo { .. } => ConflictTriggerKind::Temporal,
2277 _ => ConflictTriggerKind::Unknown,
2278 }
2279}
2280
2281fn collect_requires_statuses_for_conflict(
2282 expr: &Expr,
2283 binding_types: &HashMap<&str, &str>,
2284 status_by_entity: &HashMap<&str, HashSet<&str>>,
2285 out: &mut HashMap<String, HashSet<String>>,
2286) {
2287 match expr {
2288 Expr::Comparison {
2289 left,
2290 op: ComparisonOp::Eq,
2291 right,
2292 ..
2293 } => {
2294 if let (Some((binding, "status")), Some(target)) =
2295 (expr_as_member_access(left), expr_as_ident(right))
2296 {
2297 let entity = resolve_binding_entity(
2298 binding,
2299 Some(target),
2300 binding_types,
2301 &status_by_entity
2302 .iter()
2303 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2304 .collect(),
2305 );
2306 if let Some(e) = entity {
2307 out.entry(e.to_string()).or_default().insert(target.to_string());
2308 }
2309 }
2310 }
2311 Expr::LogicalOp { left, right, .. } => {
2312 collect_requires_statuses_for_conflict(left, binding_types, status_by_entity, out);
2313 collect_requires_statuses_for_conflict(right, binding_types, status_by_entity, out);
2314 }
2315 Expr::Block { items, .. } => {
2316 for item in items {
2317 collect_requires_statuses_for_conflict(item, binding_types, status_by_entity, out);
2318 }
2319 }
2320 _ => {}
2321 }
2322}
2323
2324fn collect_ensures_statuses_for_conflict(
2325 expr: &Expr,
2326 binding_types: &HashMap<&str, &str>,
2327 status_by_entity: &HashMap<&str, HashSet<&str>>,
2328 out: &mut HashMap<String, String>,
2329) {
2330 match expr {
2331 Expr::Comparison {
2332 left,
2333 op: ComparisonOp::Eq,
2334 right,
2335 ..
2336 } => {
2337 if let (Some((binding, "status")), Some(target)) =
2338 (expr_as_member_access(left), expr_as_ident(right))
2339 {
2340 let entity = resolve_binding_entity(
2341 binding,
2342 Some(target),
2343 binding_types,
2344 &status_by_entity
2345 .iter()
2346 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2347 .collect(),
2348 );
2349 if let Some(e) = entity {
2350 out.insert(e.to_string(), target.to_string());
2351 }
2352 }
2353 }
2354 Expr::Block { items, .. } => {
2355 for item in items {
2356 collect_ensures_statuses_for_conflict(item, binding_types, status_by_entity, out);
2357 }
2358 }
2359 Expr::Conditional {
2360 branches,
2361 else_body,
2362 ..
2363 } => {
2364 for branch in branches {
2365 collect_ensures_statuses_for_conflict(
2366 &branch.body, binding_types, status_by_entity, out,
2367 );
2368 }
2369 if let Some(body) = else_body {
2370 collect_ensures_statuses_for_conflict(body, binding_types, status_by_entity, out);
2371 }
2372 }
2373 _ => {}
2374 }
2375}
2376
2377fn status_values_for_binding<'a>(
2379 status_values: &'a HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
2380) -> HashMap<&'a str, (Vec<&'a Ident>, HashSet<&'a str>)> {
2381 status_values
2382 .iter()
2383 .map(|(k, (set, idents))| (*k, (idents.clone(), set.clone())))
2384 .collect()
2385}
2386
2387fn resolve_binding_entity_from_status<'a>(
2390 binding: &str,
2391 target: Option<&str>,
2392 binding_types: &HashMap<&'a str, &'a str>,
2393 status_values: &HashMap<&'a str, (HashSet<&'a str>, Vec<&Ident>)>,
2394) -> Option<&'a str> {
2395 binding_types
2396 .get(binding)
2397 .copied()
2398 .or_else(|| {
2399 status_values
2400 .keys()
2401 .find(|name| name.eq_ignore_ascii_case(binding))
2402 .copied()
2403 })
2404 .or_else(|| {
2405 let target = target?;
2406 let mut candidates = status_values
2407 .iter()
2408 .filter(|(_, (values, _))| values.contains(target));
2409 let first = candidates.next()?;
2410 if candidates.next().is_none() {
2411 Some(first.0)
2412 } else {
2413 None
2414 }
2415 })
2416}
2417
2418fn collect_requires_conditions<'a>(
2420 expr: &'a Expr,
2421 binding_types: &HashMap<&'a str, &'a str>,
2422 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2423 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2424) {
2425 match expr {
2426 Expr::Comparison {
2427 left,
2428 op: ComparisonOp::Eq,
2429 right,
2430 ..
2431 } => {
2432 if let Some(target) = expr_as_ident(right) {
2433 if let Some((binding, field)) = expr_as_member_access(left) {
2434 cb(binding, field, target);
2435 } else if let Some((root, _mid, field)) =
2436 expr_as_nested_member_access(left)
2437 {
2438 if field == "status" {
2439 cb(root, "status", target);
2440 }
2441 }
2442 }
2443 if let Expr::BoolLiteral { value: true, .. } = right.as_ref() {
2445 if let Some((binding, field)) = expr_as_member_access(left) {
2446 cb(binding, field, "true");
2447 }
2448 }
2449 }
2450 Expr::Comparison {
2451 op: ComparisonOp::GtEq,
2452 ..
2453 } => {
2454 }
2456 Expr::LogicalOp { left, right, .. } => {
2457 collect_requires_conditions(left, binding_types, status_values, cb);
2458 collect_requires_conditions(right, binding_types, status_values, cb);
2459 }
2460 Expr::Block { items, .. } => {
2461 for item in items {
2462 collect_requires_conditions(item, binding_types, status_values, cb);
2463 }
2464 }
2465 _ => {}
2466 }
2467}
2468
2469fn collect_field_assignments<'a>(
2471 expr: &'a Expr,
2472 binding_types: &HashMap<&'a str, &'a str>,
2473 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2474 field_types: &HashMap<&str, HashMap<&str, &str>>,
2475 cb: &mut impl FnMut(&str, &str, &str),
2476) {
2477 match expr {
2478 Expr::Comparison {
2479 left,
2480 op: ComparisonOp::Eq,
2481 right,
2482 ..
2483 } => {
2484 if let Some((binding, field)) = expr_as_member_access(left) {
2485 let entity = resolve_binding_entity_from_status(
2486 binding, None, binding_types, status_values,
2487 );
2488 if let Some(entity) = entity {
2489 let val = expr_as_ident(right).unwrap_or("_variable_");
2490 cb(entity, field, val);
2491 }
2492 }
2493 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2495 let root_entity = resolve_binding_entity_from_status(
2496 root, None, binding_types, status_values,
2497 );
2498 if let Some(root_entity) = root_entity {
2499 if let Some(nested) =
2500 field_types.get(root_entity).and_then(|f| f.get(mid).copied())
2501 {
2502 let val = expr_as_ident(right).unwrap_or("_variable_");
2503 cb(nested, field, val);
2504 }
2505 }
2506 }
2507 }
2508 Expr::Block { items, .. } => {
2509 for item in items {
2510 collect_field_assignments(item, binding_types, status_values, field_types, cb);
2511 }
2512 }
2513 Expr::Conditional {
2514 branches,
2515 else_body,
2516 ..
2517 } => {
2518 for branch in branches {
2519 collect_field_assignments(
2520 &branch.body, binding_types, status_values, field_types, cb,
2521 );
2522 }
2523 if let Some(body) = else_body {
2524 collect_field_assignments(body, binding_types, status_values, field_types, cb);
2525 }
2526 }
2527 _ => {}
2528 }
2529}
2530
2531fn collect_ensures_status<'a>(
2533 expr: &'a Expr,
2534 binding_types: &HashMap<&'a str, &'a str>,
2535 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2536 field_types: &HashMap<&str, HashMap<&str, &str>>,
2537 cb: &mut impl FnMut(&'a str, &'a str),
2538) {
2539 match expr {
2540 Expr::Comparison {
2541 left,
2542 op: ComparisonOp::Eq,
2543 right,
2544 ..
2545 } => {
2546 if let Some(target) = expr_as_ident(right) {
2547 if let Some((binding, "status")) = expr_as_member_access(left) {
2550 cb(binding, target);
2551 }
2552 }
2553 }
2554 Expr::Block { items, .. } => {
2555 for item in items {
2556 collect_ensures_status(item, binding_types, status_values, field_types, cb);
2557 }
2558 }
2559 Expr::Conditional {
2560 branches,
2561 else_body,
2562 ..
2563 } => {
2564 for branch in branches {
2565 collect_ensures_status(
2566 &branch.body, binding_types, status_values, field_types, cb,
2567 );
2568 }
2569 if let Some(body) = else_body {
2570 collect_ensures_status(body, binding_types, status_values, field_types, cb);
2571 }
2572 }
2573 _ => {}
2574 }
2575}
2576
2577fn collect_created_field_assignments<'a>(
2579 expr: &'a Expr,
2580 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2581 assigned: &mut HashSet<String>,
2582) {
2583 match expr {
2584 Expr::Call { function, args, .. } => {
2585 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2586 if field.name == "created" {
2587 if let Expr::Ident(entity_id) = object.as_ref() {
2588 let entity = entity_id.name.as_str();
2589 if status_values.contains_key(entity) {
2590 for arg in args {
2591 if let CallArg::Named(named) = arg {
2592 assigned.insert(format!(
2593 "{entity}.{}", named.name.name
2594 ));
2595 if named.name.name == "status" {
2597 if let Expr::Ident(val) = &named.value {
2598 assigned.insert(format!(
2599 "{entity}.status.{}", val.name
2600 ));
2601 }
2602 }
2603 }
2604 }
2605 }
2606 }
2607 }
2608 }
2609 }
2610 Expr::Block { items, .. } => {
2611 for item in items {
2612 collect_created_field_assignments(item, status_values, assigned);
2613 }
2614 }
2615 Expr::Conditional {
2616 branches,
2617 else_body,
2618 ..
2619 } => {
2620 for branch in branches {
2621 collect_created_field_assignments(&branch.body, status_values, assigned);
2622 }
2623 if let Some(body) = else_body {
2624 collect_created_field_assignments(body, status_values, assigned);
2625 }
2626 }
2627 _ => {}
2628 }
2629}
2630
2631fn collect_rule_binding_types<'a, V>(
2632 rule: &'a BlockDecl,
2633 status_by_entity: &HashMap<&str, V>,
2634) -> HashMap<&'a str, &'a str> {
2635 let mut types = HashMap::new();
2636 for item in &rule.items {
2637 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2638 continue;
2639 };
2640 if keyword != "when" {
2641 continue;
2642 }
2643 collect_binding_types_from_expr(value, status_by_entity, &mut types);
2644 }
2645 types
2646}
2647
2648fn collect_binding_types_from_expr<'a, V>(
2649 expr: &'a Expr,
2650 status_by_entity: &HashMap<&str, V>,
2651 out: &mut HashMap<&'a str, &'a str>,
2652) {
2653 match expr {
2654 Expr::Binding { name, value, .. } => {
2655 if let Some(entity_name) = extract_entity_from_trigger(value) {
2656 if status_by_entity.contains_key(entity_name) {
2657 out.insert(&name.name, entity_name);
2658 }
2659 }
2660 }
2661 Expr::Call { function, args, .. } => {
2662 if let Expr::Ident(fn_name) = function.as_ref() {
2663 for arg in args {
2664 if let CallArg::Positional(Expr::Ident(binding)) = arg {
2665 if status_by_entity.contains_key(fn_name.name.as_str()) {
2666 out.insert(&binding.name, &fn_name.name);
2667 }
2668 }
2669 }
2670 }
2671 }
2672 Expr::LogicalOp { left, right, .. } => {
2673 collect_binding_types_from_expr(left, status_by_entity, out);
2674 collect_binding_types_from_expr(right, status_by_entity, out);
2675 }
2676 _ => {}
2677 }
2678}
2679
2680fn collect_command_param_types<'a, V>(
2684 expr: &'a Expr,
2685 status_by_entity: &HashMap<&str, V>,
2686 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
2687) {
2688 match expr {
2689 Expr::Call { function, args, .. } => {
2690 if let Expr::Ident(fn_name) = function.as_ref() {
2691 let params: Vec<Option<&str>> = args
2692 .iter()
2693 .map(|arg| match arg {
2694 CallArg::Named(named) => match unwrap_type_refinement(&named.value) {
2695 Expr::Ident(val)
2696 if status_by_entity.contains_key(val.name.as_str()) =>
2697 {
2698 Some(val.name.as_str())
2699 }
2700 _ => None,
2701 },
2702 CallArg::Positional(_) => None,
2703 })
2704 .collect();
2705 if params.iter().any(Option::is_some) {
2706 out.insert(&fn_name.name, params);
2707 }
2708 }
2709 }
2710 Expr::WhenGuard { action, .. } => {
2711 collect_command_param_types(action, status_by_entity, out);
2712 }
2713 Expr::Block { items, .. } => {
2714 for item in items {
2715 collect_command_param_types(item, status_by_entity, out);
2716 }
2717 }
2718 Expr::Conditional {
2719 branches,
2720 else_body,
2721 ..
2722 } => {
2723 for branch in branches {
2724 collect_command_param_types(&branch.body, status_by_entity, out);
2725 }
2726 if let Some(body) = else_body {
2727 collect_command_param_types(body, status_by_entity, out);
2728 }
2729 }
2730 _ => {}
2731 }
2732}
2733
2734fn augment_binding_types_from_commands<'a>(
2738 rule: &'a BlockDecl,
2739 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2740 out: &mut HashMap<&'a str, &'a str>,
2741) {
2742 for item in &rule.items {
2743 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2744 continue;
2745 };
2746 if keyword != "when" {
2747 continue;
2748 }
2749 augment_binding_types_from_call(value, command_param_types, out);
2750 }
2751}
2752
2753fn augment_binding_types_from_call<'a>(
2754 expr: &'a Expr,
2755 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2756 out: &mut HashMap<&'a str, &'a str>,
2757) {
2758 match expr {
2759 Expr::Call { function, args, .. } => {
2760 if let Expr::Ident(fn_name) = function.as_ref() {
2761 if let Some(params) = command_param_types.get(fn_name.name.as_str()) {
2762 for (arg, param_type) in args.iter().zip(params) {
2763 if let (CallArg::Positional(Expr::Ident(binding)), Some(entity)) =
2764 (arg, param_type)
2765 {
2766 out.entry(&binding.name).or_insert(entity);
2767 }
2768 }
2769 }
2770 }
2771 }
2772 Expr::LogicalOp { left, right, .. } => {
2773 augment_binding_types_from_call(left, command_param_types, out);
2774 augment_binding_types_from_call(right, command_param_types, out);
2775 }
2776 _ => {}
2777 }
2778}
2779
2780fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
2781 match expr {
2782 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
2783 extract_entity_from_member(subject)
2784 }
2785 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2786 _ => None,
2787 }
2788}
2789
2790fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
2791 match expr {
2792 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2793 _ => None,
2794 }
2795}
2796
2797fn visit_status_assignments<'a>(
2798 expr: &'a Expr,
2799 binding_types: &HashMap<&'a str, &'a str>,
2800 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2801 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2802 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2803) {
2804 match expr {
2805 Expr::Comparison {
2806 left,
2807 op: ComparisonOp::Eq,
2808 right,
2809 ..
2810 } => {
2811 if let Some(target) = expr_as_ident(right) {
2812 if let Some((binding, "status")) = expr_as_member_access(left) {
2814 let entity = resolve_binding_entity(
2815 binding,
2816 Some(target),
2817 binding_types,
2818 status_by_entity,
2819 );
2820 if let Some(entity) = entity {
2821 cb(binding, target, entity);
2822 }
2823 }
2824 else if let Some((root, field, "status")) =
2828 expr_as_nested_member_access(left)
2829 {
2830 let root_entity = resolve_binding_entity(
2831 root, None, binding_types, status_by_entity,
2832 );
2833 if let Some(root_entity) = root_entity {
2834 if let Some(nested_entity) = field_entity_types
2835 .get(root_entity)
2836 .and_then(|fields| fields.get(field).copied())
2837 {
2838 cb("_nested_", target, nested_entity);
2841 }
2842 }
2843 }
2844 }
2845 }
2846 Expr::Block { items, .. } => {
2847 for item in items {
2848 visit_status_assignments(
2849 item,
2850 binding_types,
2851 status_by_entity,
2852 field_entity_types,
2853 cb,
2854 );
2855 }
2856 }
2857 Expr::Conditional {
2858 branches,
2859 else_body,
2860 ..
2861 } => {
2862 for branch in branches {
2863 visit_status_assignments(
2864 &branch.body,
2865 binding_types,
2866 status_by_entity,
2867 field_entity_types,
2868 cb,
2869 );
2870 }
2871 if let Some(body) = else_body {
2872 visit_status_assignments(
2873 body,
2874 binding_types,
2875 status_by_entity,
2876 field_entity_types,
2877 cb,
2878 );
2879 }
2880 }
2881 _ => {}
2882 }
2883}
2884
2885fn visit_created_calls<'a>(
2889 expr: &'a Expr,
2890 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2891 has_transitions: &HashSet<&'a str>,
2892 on_status: &mut impl FnMut(&'a str, &'a str),
2893 issues: &mut Vec<Diagnostic>,
2894) {
2895 match expr {
2896 Expr::Call {
2897 function, args, span, ..
2898 } => {
2899 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2900 if field.name == "created" {
2901 if let Expr::Ident(entity_ident) = object.as_ref() {
2902 let entity_name = entity_ident.name.as_str();
2903 if let Some((_, values)) = status_by_entity.get(entity_name) {
2904 let status_arg = args.iter().find_map(|arg| {
2905 if let CallArg::Named(named) = arg {
2906 if named.name.name == "status" {
2907 return Some(named);
2908 }
2909 }
2910 None
2911 });
2912
2913 match status_arg {
2914 Some(named) => {
2915 if let Expr::Ident(status_ident) = &named.value {
2916 let status = status_ident.name.as_str();
2917 if values.contains(status) {
2918 on_status(entity_name, status);
2919 } else {
2920 issues.push(
2921 Diagnostic::error(
2922 named.value.span(),
2923 format!(
2924 ".created() on entity '{entity_name}' sets status to '{status}', which is not a declared status value.",
2925 ),
2926 )
2927 .with_code("allium.created.invalidStatus"),
2928 );
2929 }
2930 }
2931 }
2932 None => {
2933 if has_transitions.contains(entity_name) {
2934 issues.push(
2935 Diagnostic::warning(
2936 *span,
2937 format!(
2938 ".created() on entity '{entity_name}' omits the status field, but the entity has a transition graph. The initial state is unspecified.",
2939 ),
2940 )
2941 .with_code("allium.created.missingStatus"),
2942 );
2943 }
2944 }
2945 }
2946 }
2947 }
2948 }
2949 }
2950 }
2951 Expr::Block { items, .. } => {
2952 for item in items {
2953 visit_created_calls(item, status_by_entity, has_transitions, on_status, issues);
2954 }
2955 }
2956 Expr::Conditional {
2957 branches,
2958 else_body,
2959 ..
2960 } => {
2961 for branch in branches {
2962 visit_created_calls(
2963 &branch.body,
2964 status_by_entity,
2965 has_transitions,
2966 on_status,
2967 issues,
2968 );
2969 }
2970 if let Some(body) = else_body {
2971 visit_created_calls(body, status_by_entity, has_transitions, on_status, issues);
2972 }
2973 }
2974 _ => {}
2975 }
2976}
2977
2978fn visit_status_comparisons<'a>(
2979 expr: &'a Expr,
2980 binding_types: &HashMap<&'a str, &'a str>,
2981 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2982 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2983 cb: &mut impl FnMut(&'a str, &'a str),
2984) {
2985 match expr {
2986 Expr::Comparison {
2987 left,
2988 op: ComparisonOp::Eq,
2989 right,
2990 ..
2991 } => {
2992 if let Some(target) = expr_as_ident(right) {
2993 if let Some((binding, "status")) = expr_as_member_access(left) {
2995 let known = resolve_binding_entity(
2996 binding,
2997 Some(target),
2998 binding_types,
2999 status_by_entity,
3000 )
3001 .is_some();
3002 if known {
3003 cb(binding, target);
3004 }
3005 }
3006 }
3011 }
3012 Expr::Comparison {
3013 left,
3014 op: ComparisonOp::NotEq,
3015 right,
3016 ..
3017 } => {
3018 if let Some(target) = expr_as_ident(right) {
3021 if let Some((binding, "status")) = expr_as_member_access(left) {
3022 if let Some(entity) = resolve_binding_entity(
3023 binding,
3024 Some(target),
3025 binding_types,
3026 status_by_entity,
3027 ) {
3028 if let Some((_, values)) = status_by_entity.get(entity) {
3029 if values.contains(target) {
3030 for value in values.iter().filter(|v| **v != target) {
3031 cb(binding, value);
3032 }
3033 }
3034 }
3035 }
3036 }
3037 }
3038 }
3039 Expr::LogicalOp { left, right, .. } => {
3040 visit_status_comparisons(left, binding_types, status_by_entity, field_entity_types, cb);
3041 visit_status_comparisons(right, binding_types, status_by_entity, field_entity_types, cb);
3042 }
3043 Expr::Block { items, .. } => {
3044 for item in items {
3045 visit_status_comparisons(item, binding_types, status_by_entity, field_entity_types, cb);
3046 }
3047 }
3048 _ => {}
3049 }
3050}
3051
3052fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
3053 match expr {
3054 Expr::MemberAccess { object, field, .. } => {
3055 expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
3056 }
3057 _ => None,
3058 }
3059}
3060
3061fn expr_as_nested_member_access(expr: &Expr) -> Option<(&str, &str, &str)> {
3063 if let Expr::MemberAccess {
3064 object, field: last, ..
3065 } = expr
3066 {
3067 if let Expr::MemberAccess {
3068 object: root_obj,
3069 field: mid,
3070 ..
3071 } = object.as_ref()
3072 {
3073 if let Expr::Ident(root) = root_obj.as_ref() {
3074 return Some((&root.name, &mid.name, &last.name));
3075 }
3076 }
3077 }
3078 None
3079}
3080
3081fn resolve_binding_entity<'a>(
3086 binding: &str,
3087 target: Option<&str>,
3088 binding_types: &HashMap<&'a str, &'a str>,
3089 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
3090) -> Option<&'a str> {
3091 binding_types
3092 .get(binding)
3093 .copied()
3094 .or_else(|| {
3095 status_by_entity
3096 .keys()
3097 .find(|name| name.eq_ignore_ascii_case(binding))
3098 .copied()
3099 })
3100 .or_else(|| {
3101 let target = target?;
3103 let mut candidates = status_by_entity
3104 .iter()
3105 .filter(|(_, (_, values))| values.contains(target));
3106 let first = candidates.next()?;
3107 if candidates.next().is_none() {
3108 Some(first.0)
3109 } else {
3110 None
3111 }
3112 })
3113}
3114
3115fn extract_field_entity_type(expr: &Expr) -> Option<&str> {
3118 match expr {
3119 Expr::Ident(id) if starts_uppercase(&id.name) => Some(&id.name),
3120 Expr::JoinLookup { entity, .. } => {
3121 if let Expr::Ident(id) = entity.as_ref() {
3122 if starts_uppercase(&id.name) {
3123 return Some(&id.name);
3124 }
3125 }
3126 None
3127 }
3128 _ => None,
3129 }
3130}
3131
3132fn is_likely_terminal(status: &str) -> bool {
3133 matches!(
3134 status,
3135 "completed"
3136 | "cancelled"
3137 | "canceled"
3138 | "expired"
3139 | "closed"
3140 | "deleted"
3141 | "archived"
3142 | "failed"
3143 | "rejected"
3144 | "done"
3145 )
3146}
3147
3148impl Ctx<'_> {
3153 fn check_external_entity_source_hints(&mut self) {
3154 if self.has_use_imports() {
3155 return;
3156 }
3157
3158 let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
3159
3160 for entity in self.blocks(BlockKind::ExternalEntity) {
3161 let name = match &entity.name {
3162 Some(n) => n,
3163 None => continue,
3164 };
3165
3166 let referenced_in_rules = rule_blocks
3167 .iter()
3168 .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
3169
3170 let msg = format!(
3171 "External entity '{}' has no obvious governing specification import in this module.",
3172 name.name
3173 );
3174 if referenced_in_rules {
3175 self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
3176 } else {
3177 self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
3178 }
3179 }
3180 }
3181}
3182
3183impl Ctx<'_> {
3188 fn check_unresolved_use_paths(&mut self) {
3192 let Some(resolved) = self.resolved_use_paths else {
3193 return;
3194 };
3195 for d in &self.module.declarations {
3196 let Decl::Use(u) = d else { continue };
3197 let path_text = u.path.text();
3198 if !resolved.contains(&path_text) {
3199 self.push(
3200 Diagnostic::warning(
3201 u.path.span,
3202 format!(
3203 "Use path \"{path_text}\" does not resolve to a file in the current check set.",
3204 ),
3205 )
3206 .with_code("allium.use.unresolvedPath"),
3207 );
3208 }
3209 }
3210 }
3211}
3212
3213impl Ctx<'_> {
3218 fn check_ambiguous_imported_names(&mut self) {
3225 let Some(ambiguous) = self.ambiguous_imports else {
3226 return;
3227 };
3228 if ambiguous.names.is_empty() {
3229 return;
3230 }
3231 let mut local = self.declared_type_names();
3236 for b in self.blocks(BlockKind::Contract) {
3237 if let Some(n) = &b.name {
3238 local.insert(n.name.as_str());
3239 }
3240 }
3241
3242 let mut flagged: HashSet<&str> = HashSet::new();
3243 let mut findings = Vec::new();
3244 for id in collect_referenced_ident_nodes(self.module) {
3245 if id.qualified || local.contains(id.name) || flagged.contains(id.name) {
3246 continue;
3247 }
3248 let Some(aliases) = ambiguous.names.get(id.name) else {
3249 continue;
3250 };
3251 flagged.insert(id.name);
3253 findings.push(
3254 Diagnostic::warning(
3255 id.span,
3256 format!(
3257 "Unqualified reference '{}' is ambiguous: it is declared in imported modules {}. Use a qualified name (e.g. '{}/{}').",
3258 id.name,
3259 format_alias_list(aliases),
3260 aliases[0],
3261 id.name,
3262 ),
3263 )
3264 .with_code("allium.use.ambiguousReference"),
3265 );
3266 }
3267 self.diagnostics.extend(findings);
3268 }
3269}
3270
3271fn format_alias_list(aliases: &[String]) -> String {
3273 let quoted: Vec<String> = aliases.iter().map(|a| format!("'{a}'")).collect();
3274 match quoted.split_last() {
3275 Some((last, rest)) if !rest.is_empty() => {
3276 format!("{} and {last}", rest.join(", "))
3277 }
3278 _ => quoted.join(", "),
3279 }
3280}
3281
3282impl Ctx<'_> {
3287 fn check_type_references(&mut self) {
3288 let known = self.declared_type_names();
3289
3290 for d in &self.module.declarations {
3291 let block = match d {
3292 Decl::Block(b)
3293 if matches!(
3294 b.kind,
3295 BlockKind::Entity
3296 | BlockKind::ExternalEntity
3297 | BlockKind::Value
3298 ) =>
3299 {
3300 b
3301 }
3302 Decl::Variant(v) => {
3303 for item in &v.items {
3305 self.check_type_ref_in_item(item, &known);
3306 }
3307 continue;
3308 }
3309 _ => continue,
3310 };
3311
3312 for item in &block.items {
3313 self.check_type_ref_in_item(item, &known);
3314 }
3315 }
3316
3317 let rules: Vec<_> = self.blocks(BlockKind::Rule).collect();
3321 for rule in rules {
3322 let mut refs = Vec::new();
3323 for_each_rule_clause(&rule.items, &mut |keyword, value| {
3324 if keyword == "when" || keyword == "ensures" || keyword == "requires" {
3325 refs.push(value);
3326 }
3327 });
3328 for value in refs {
3329 self.check_type_refs_in_rule_expr(value, &known);
3330 }
3331 }
3332 }
3333
3334 fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
3335 match &item.kind {
3336 BlockItemKind::Assignment { value, .. }
3337 | BlockItemKind::FieldWithWhen { value, .. } => {
3338 self.check_type_refs_in_value(value, known);
3339 }
3340 _ => {}
3341 }
3342 }
3343
3344 fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
3345 match expr {
3346 Expr::Ident(id) if starts_uppercase(&id.name) => {
3347 if !known.contains(id.name.as_str()) {
3348 self.push(
3349 Diagnostic::error(
3350 id.span,
3351 format!(
3352 "Type reference '{}' is not declared locally or imported.",
3353 id.name
3354 ),
3355 )
3356 .with_code("allium.type.undefinedReference"),
3357 );
3358 }
3359 }
3360 Expr::GenericType { name, args, .. } => {
3361 self.check_type_refs_in_value(name, known);
3362 for arg in args {
3363 self.check_type_refs_in_value(arg, known);
3364 }
3365 }
3366 Expr::Pipe { left, right, .. } => {
3367 self.check_type_refs_in_value(left, known);
3368 self.check_type_refs_in_value(right, known);
3369 }
3370 Expr::TypeOptional { inner, .. } => {
3371 self.check_type_refs_in_value(inner, known);
3372 }
3373 _ => {}
3374 }
3375 }
3376
3377 fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
3378 match expr {
3379 Expr::Binding { value, .. } => {
3381 self.check_type_refs_in_rule_expr(value, known);
3382 }
3383 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
3384 if let Expr::MemberAccess { object, .. } = subject.as_ref() {
3385 if let Expr::Ident(id) = object.as_ref() {
3386 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3387 self.push(
3388 Diagnostic::error(
3389 id.span,
3390 format!(
3391 "Type reference '{}' is not declared locally or imported.",
3392 id.name
3393 ),
3394 )
3395 .with_code("allium.rule.undefinedTypeReference"),
3396 );
3397 }
3398 }
3399 }
3400 }
3401 Expr::Call { function, .. } => {
3403 if let Expr::MemberAccess { object, .. } = function.as_ref() {
3404 if let Expr::Ident(id) = object.as_ref() {
3405 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3406 self.push(
3407 Diagnostic::error(
3408 id.span,
3409 format!(
3410 "Type reference '{}' is not declared locally or imported.",
3411 id.name
3412 ),
3413 )
3414 .with_code("allium.rule.undefinedTypeReference"),
3415 );
3416 }
3417 }
3418 }
3419 }
3420 Expr::MemberAccess { object, .. } => {
3422 if let Expr::Ident(id) = object.as_ref() {
3423 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3424 self.push(
3425 Diagnostic::error(
3426 id.span,
3427 format!(
3428 "Type reference '{}' is not declared locally or imported.",
3429 id.name
3430 ),
3431 )
3432 .with_code("allium.rule.undefinedTypeReference"),
3433 );
3434 }
3435 }
3436 }
3437 Expr::Block { items, .. } => {
3438 for item in items {
3439 self.check_type_refs_in_rule_expr(item, known);
3440 }
3441 }
3442 Expr::LogicalOp { left, right, .. } => {
3443 self.check_type_refs_in_rule_expr(left, known);
3444 self.check_type_refs_in_rule_expr(right, known);
3445 }
3446 _ => {}
3447 }
3448 }
3449}
3450
3451impl Ctx<'_> {
3456 fn check_unreachable_triggers(&mut self) {
3457 let mut provided: HashSet<&str> = HashSet::new();
3459 if let Some(rev) = self.reverse_contributions {
3462 for t in &rev.provided_triggers {
3463 provided.insert(t.as_str());
3464 }
3465 }
3466 for surface in self.blocks(BlockKind::Surface) {
3467 for item in &surface.items {
3468 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3469 continue;
3470 };
3471 if keyword != "provides" {
3472 continue;
3473 }
3474 collect_call_names(value, &mut provided);
3475 }
3476 }
3477
3478 let mut emitted: HashSet<&str> = HashSet::new();
3482 for rule in self.blocks(BlockKind::Rule) {
3483 for item in &rule.items {
3484 collect_emitted_trigger_from_item(&item.kind, &mut emitted);
3485 }
3486 }
3487
3488 for rule in self.blocks(BlockKind::Rule) {
3489 let rule_name = match &rule.name {
3490 Some(n) => &n.name,
3491 None => continue,
3492 };
3493 for item in &rule.items {
3494 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3495 continue;
3496 };
3497 if keyword != "when" {
3498 continue;
3499 }
3500 for tref in extract_trigger_refs(value) {
3501 if tref.qualifier.is_none()
3505 && !provided.contains(tref.name)
3506 && !emitted.contains(tref.name)
3507 {
3508 if let Some(aliases) = self
3509 .ambiguous_imports
3510 .and_then(|a| a.triggers.get(tref.name))
3511 {
3512 let message = format!(
3513 "Rule '{rule_name}' listens for trigger '{}', which is provided or emitted by imported modules {}. Use a qualified name (e.g. '{}/{}').",
3514 tref.name,
3515 format_alias_list(aliases),
3516 aliases[0],
3517 tref.name,
3518 );
3519 self.push(
3520 Diagnostic::warning(tref.span, message)
3521 .with_code("allium.use.ambiguousReference"),
3522 );
3523 }
3524 }
3525 if self.trigger_reachability(&tref, &provided, &emitted) != Some(false) {
3526 continue;
3527 }
3528 let message = match tref.qualifier {
3529 None => format!(
3530 "Rule '{rule_name}' listens for trigger '{}' but no local surface provides or rule emits it.",
3531 tref.name,
3532 ),
3533 Some(q) => format!(
3534 "Rule '{rule_name}' listens for trigger '{q}/{}' but imported module '{q}' does not provide or emit it.",
3535 tref.name,
3536 ),
3537 };
3538 self.push(
3539 Diagnostic::info(tref.span, message)
3540 .with_code("allium.rule.unreachableTrigger"),
3541 );
3542 }
3543 }
3544 }
3545 }
3546
3547 fn trigger_reachability(
3554 &self,
3555 tref: &TriggerRef<'_>,
3556 provided: &HashSet<&str>,
3557 emitted: &HashSet<&str>,
3558 ) -> Option<bool> {
3559 match tref.qualifier {
3560 None => {
3561 if provided.contains(tref.name) || emitted.contains(tref.name) {
3562 return Some(true);
3563 }
3564 if let Some(imports) = self.imported_triggers {
3565 if imports.values().any(|set| set.contains(tref.name)) {
3566 return Some(true);
3567 }
3568 }
3569 Some(false)
3570 }
3571 Some(q) => self
3572 .imported_triggers?
3573 .get(q)
3574 .map(|set| set.contains(tref.name)),
3575 }
3576 }
3577}
3578
3579fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3583 match kind {
3584 BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
3585 collect_ensures_emission_calls(value, out);
3586 }
3587 BlockItemKind::Assignment { name, value } if name.name == "otherwise" => {
3590 collect_ensures_emission_calls(value, out);
3591 }
3592 BlockItemKind::ForBlock { items, .. } => {
3593 for item in items {
3594 collect_emitted_trigger_from_item(&item.kind, out);
3595 }
3596 }
3597 BlockItemKind::IfBlock { branches, else_items, .. } => {
3598 for b in branches {
3599 for item in &b.items {
3600 collect_emitted_trigger_from_item(&item.kind, out);
3601 }
3602 }
3603 if let Some(items) = else_items {
3604 for item in items {
3605 collect_emitted_trigger_from_item(&item.kind, out);
3606 }
3607 }
3608 }
3609 _ => {}
3610 }
3611}
3612
3613fn collect_ensures_emission_calls<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3622 match expr {
3623 Expr::Call { function, .. } => {
3624 if let Expr::Ident(id) = function.as_ref() {
3625 if starts_uppercase(&id.name) {
3626 out.insert(&id.name);
3627 }
3628 }
3629 }
3630 Expr::Block { items, .. } => {
3631 for item in items {
3632 collect_ensures_emission_calls(item, out);
3633 }
3634 }
3635 Expr::Conditional {
3636 branches,
3637 else_body,
3638 ..
3639 } => {
3640 for b in branches {
3641 collect_ensures_emission_calls(&b.body, out);
3642 }
3643 if let Some(body) = else_body {
3644 collect_ensures_emission_calls(body, out);
3645 }
3646 }
3647 Expr::For { body, .. } => {
3648 collect_ensures_emission_calls(body, out);
3649 }
3650 _ => {}
3651 }
3652}
3653
3654fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3655 match expr {
3656 Expr::Call { function, .. } => {
3657 if let Expr::Ident(id) = function.as_ref() {
3658 if starts_uppercase(&id.name) {
3659 out.insert(&id.name);
3660 }
3661 }
3662 }
3663 Expr::Block { items, .. } => {
3664 for item in items {
3665 collect_call_names(item, out);
3666 }
3667 }
3668 Expr::WhenGuard { action, .. } => {
3669 collect_call_names(action, out);
3670 }
3671 Expr::Conditional { branches, else_body, .. } => {
3672 for b in branches {
3673 collect_call_names(&b.body, out);
3674 }
3675 if let Some(body) = else_body {
3676 collect_call_names(body, out);
3677 }
3678 }
3679 Expr::For { body, .. } => {
3680 collect_call_names(body, out);
3683 }
3684 _ => {}
3685 }
3686}
3687
3688struct TriggerRef<'a> {
3691 qualifier: Option<&'a str>,
3692 name: &'a str,
3693 span: Span,
3694}
3695
3696impl TriggerRef<'_> {
3697 fn display(&self) -> String {
3699 match self.qualifier {
3700 Some(q) => format!("{q}/{}", self.name),
3701 None => self.name.to_string(),
3702 }
3703 }
3704}
3705
3706fn extract_trigger_refs(expr: &Expr) -> Vec<TriggerRef<'_>> {
3707 match expr {
3708 Expr::Call { function, .. } => match function.as_ref() {
3709 Expr::Ident(id) if starts_uppercase(&id.name) => vec![TriggerRef {
3710 qualifier: None,
3711 name: &id.name,
3712 span: id.span,
3713 }],
3714 Expr::QualifiedName(q) if starts_uppercase(&q.name) => vec![TriggerRef {
3715 qualifier: q.qualifier.as_deref(),
3716 name: &q.name,
3717 span: q.span,
3718 }],
3719 _ => vec![],
3720 },
3721 Expr::Binding { .. } => {
3722 vec![]
3724 }
3725 Expr::LogicalOp { left, right, .. } => {
3726 let mut out = extract_trigger_refs(left);
3727 out.extend(extract_trigger_refs(right));
3728 out
3729 }
3730 _ => vec![],
3731 }
3732}
3733
3734impl Ctx<'_> {
3739 fn check_unused_fields(&mut self) {
3740 let mut accessed = self.collect_all_accessed_field_names();
3741 if let Some(rev) = self.reverse_contributions {
3744 accessed.extend(rev.referenced_fields.iter().map(String::as_str));
3745 }
3746
3747 for d in &self.module.declarations {
3748 let block = match d {
3749 Decl::Block(b)
3750 if matches!(
3751 b.kind,
3752 BlockKind::Entity | BlockKind::ExternalEntity
3753 ) =>
3754 {
3755 b
3756 }
3757 Decl::Variant(v) => {
3758 let entity_name = &v.name.name;
3759 for item in &v.items {
3760 if let BlockItemKind::Assignment { name, .. }
3761 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3762 {
3763 if !accessed.contains(name.name.as_str()) {
3764 self.push(
3765 Diagnostic::info(
3766 name.span,
3767 format!(
3768 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3769 name.name
3770 ),
3771 )
3772 .with_code("allium.field.unused"),
3773 );
3774 }
3775 }
3776 }
3777 continue;
3778 }
3779 _ => continue,
3780 };
3781
3782 let entity_name = match &block.name {
3783 Some(n) => &n.name,
3784 None => continue,
3785 };
3786
3787 for item in &block.items {
3788 if let BlockItemKind::Assignment { name, .. }
3789 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3790 {
3791 if !accessed.contains(name.name.as_str()) {
3792 self.push(
3793 Diagnostic::info(
3794 name.span,
3795 format!(
3796 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3797 name.name
3798 ),
3799 )
3800 .with_code("allium.field.unused"),
3801 );
3802 }
3803 }
3804 }
3805 }
3806 }
3807}
3808
3809fn collect_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3814 match kind {
3815 BlockItemKind::Clause { value, .. }
3816 | BlockItemKind::Assignment { value, .. }
3817 | BlockItemKind::ParamAssignment { value, .. }
3818 | BlockItemKind::Let { value, .. }
3819 | BlockItemKind::PathAssignment { value, .. }
3820 | BlockItemKind::InvariantBlock { body: value, .. }
3821 | BlockItemKind::FieldWithWhen { value, .. } => collect_idents_from_expr(value, out),
3822 BlockItemKind::ForBlock { collection, filter, items, .. } => {
3823 collect_idents_from_expr(collection, out);
3824 if let Some(f) = filter {
3825 collect_idents_from_expr(f, out);
3826 }
3827 for item in items {
3828 collect_idents_from_item(&item.kind, out);
3829 }
3830 }
3831 BlockItemKind::IfBlock { branches, else_items } => {
3832 for b in branches {
3833 collect_idents_from_expr(&b.condition, out);
3834 for item in &b.items {
3835 collect_idents_from_item(&item.kind, out);
3836 }
3837 }
3838 if let Some(items) = else_items {
3839 for item in items {
3840 collect_idents_from_item(&item.kind, out);
3841 }
3842 }
3843 }
3844 _ => {}
3845 }
3846}
3847
3848fn collect_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3852 match expr {
3853 Expr::Ident(id) => {
3854 out.insert(&id.name);
3855 }
3856 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
3857 collect_idents_from_expr(object, out);
3858 }
3859 Expr::Call { function, args, .. } => {
3860 collect_idents_from_expr(function, out);
3861 for a in args {
3862 match a {
3863 CallArg::Positional(e) => collect_idents_from_expr(e, out),
3864 CallArg::Named(n) => collect_idents_from_expr(&n.value, out),
3865 }
3866 }
3867 }
3868 Expr::BinaryOp { left, right, .. }
3869 | Expr::Comparison { left, right, .. }
3870 | Expr::LogicalOp { left, right, .. }
3871 | Expr::Pipe { left, right, .. }
3872 | Expr::NullCoalesce { left, right, .. } => {
3873 collect_idents_from_expr(left, out);
3874 collect_idents_from_expr(right, out);
3875 }
3876 Expr::Not { operand, .. }
3877 | Expr::Exists { operand, .. }
3878 | Expr::NotExists { operand, .. }
3879 | Expr::TypeOptional { inner: operand, .. } => {
3880 collect_idents_from_expr(operand, out);
3881 }
3882 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3883 collect_idents_from_expr(element, out);
3884 collect_idents_from_expr(collection, out);
3885 }
3886 Expr::Where { source, condition, .. }
3887 | Expr::With { source, predicate: condition, .. } => {
3888 collect_idents_from_expr(source, out);
3889 collect_idents_from_expr(condition, out);
3890 }
3891 Expr::WhenGuard { action, condition, .. } => {
3892 collect_idents_from_expr(action, out);
3893 collect_idents_from_expr(condition, out);
3894 }
3895 Expr::Block { items, .. } => {
3896 for item in items {
3897 collect_idents_from_expr(item, out);
3898 }
3899 }
3900 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3901 collect_idents_from_expr(value, out)
3902 }
3903 Expr::Conditional { branches, else_body, .. } => {
3904 for b in branches {
3905 collect_idents_from_expr(&b.condition, out);
3906 collect_idents_from_expr(&b.body, out);
3907 }
3908 if let Some(body) = else_body {
3909 collect_idents_from_expr(body, out);
3910 }
3911 }
3912 Expr::For { collection, filter, body, .. } => {
3913 collect_idents_from_expr(collection, out);
3914 if let Some(f) = filter {
3915 collect_idents_from_expr(f, out);
3916 }
3917 collect_idents_from_expr(body, out);
3918 }
3919 Expr::Lambda { body, .. } => collect_idents_from_expr(body, out),
3920 Expr::TransitionsTo { subject, new_state, .. }
3921 | Expr::Becomes { subject, new_state, .. } => {
3922 collect_idents_from_expr(subject, out);
3923 collect_idents_from_expr(new_state, out);
3924 }
3925 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
3926 for e in elements {
3927 collect_idents_from_expr(e, out);
3928 }
3929 }
3930 Expr::ObjectLiteral { fields, .. } => {
3931 for f in fields {
3932 collect_idents_from_expr(&f.value, out);
3933 }
3934 }
3935 Expr::GenericType { name, args, .. } => {
3936 collect_idents_from_expr(name, out);
3937 for a in args {
3938 collect_idents_from_expr(a, out);
3939 }
3940 }
3941 Expr::ProjectionMap { source, .. } => collect_idents_from_expr(source, out),
3942 Expr::JoinLookup { entity, fields, .. } => {
3943 collect_idents_from_expr(entity, out);
3944 for f in fields {
3945 if let Some(v) = &f.value {
3946 collect_idents_from_expr(v, out);
3947 }
3948 }
3949 }
3950 _ => {}
3951 }
3952}
3953
3954fn collect_qualified_field_refs<'a>(expr: &'a Expr, alias: &str, out: &mut HashSet<&'a str>) {
3960 match expr {
3961 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
3962 if let Expr::QualifiedName(q) = object.as_ref() {
3963 if q.qualifier.as_deref() == Some(alias) {
3964 out.insert(&field.name);
3965 }
3966 }
3967 collect_qualified_field_refs(object, alias, out);
3968 }
3969 Expr::Call { function, args, .. } => {
3970 collect_qualified_field_refs(function, alias, out);
3971 for a in args {
3972 match a {
3973 CallArg::Positional(e) => collect_qualified_field_refs(e, alias, out),
3974 CallArg::Named(n) => collect_qualified_field_refs(&n.value, alias, out),
3975 }
3976 }
3977 }
3978 Expr::BinaryOp { left, right, .. }
3979 | Expr::Comparison { left, right, .. }
3980 | Expr::LogicalOp { left, right, .. }
3981 | Expr::Pipe { left, right, .. }
3982 | Expr::NullCoalesce { left, right, .. }
3983 | Expr::In { element: left, collection: right, .. }
3984 | Expr::NotIn { element: left, collection: right, .. } => {
3985 collect_qualified_field_refs(left, alias, out);
3986 collect_qualified_field_refs(right, alias, out);
3987 }
3988 Expr::Not { operand, .. }
3989 | Expr::Exists { operand, .. }
3990 | Expr::NotExists { operand, .. }
3991 | Expr::TypeOptional { inner: operand, .. } => {
3992 collect_qualified_field_refs(operand, alias, out);
3993 }
3994 Expr::Where { source, condition, .. }
3995 | Expr::With { source, predicate: condition, .. } => {
3996 collect_qualified_field_refs(source, alias, out);
3997 collect_qualified_field_refs(condition, alias, out);
3998 }
3999 Expr::WhenGuard { action, condition, .. } => {
4000 collect_qualified_field_refs(action, alias, out);
4001 collect_qualified_field_refs(condition, alias, out);
4002 }
4003 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } | Expr::Lambda { body: value, .. } => {
4004 collect_qualified_field_refs(value, alias, out);
4005 }
4006 Expr::TransitionsTo { subject, new_state, .. }
4007 | Expr::Becomes { subject, new_state, .. } => {
4008 collect_qualified_field_refs(subject, alias, out);
4009 collect_qualified_field_refs(new_state, alias, out);
4010 }
4011 Expr::Conditional { branches, else_body, .. } => {
4012 for b in branches {
4013 collect_qualified_field_refs(&b.condition, alias, out);
4014 collect_qualified_field_refs(&b.body, alias, out);
4015 }
4016 if let Some(body) = else_body {
4017 collect_qualified_field_refs(body, alias, out);
4018 }
4019 }
4020 Expr::For { collection, filter, body, .. } => {
4021 collect_qualified_field_refs(collection, alias, out);
4022 if let Some(f) = filter {
4023 collect_qualified_field_refs(f, alias, out);
4024 }
4025 collect_qualified_field_refs(body, alias, out);
4026 }
4027 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
4028 for e in elements {
4029 collect_qualified_field_refs(e, alias, out);
4030 }
4031 }
4032 Expr::ObjectLiteral { fields, .. } => {
4033 for f in fields {
4034 collect_qualified_field_refs(&f.value, alias, out);
4035 }
4036 }
4037 Expr::Block { items, .. } => {
4038 for item in items {
4039 collect_qualified_field_refs(item, alias, out);
4040 }
4041 }
4042 _ => {}
4043 }
4044}
4045
4046fn collect_qualified_field_refs_from_item<'a>(
4047 kind: &'a BlockItemKind,
4048 alias: &str,
4049 out: &mut HashSet<&'a str>,
4050) {
4051 match kind {
4052 BlockItemKind::Clause { value, .. }
4053 | BlockItemKind::Assignment { value, .. }
4054 | BlockItemKind::ParamAssignment { value, .. }
4055 | BlockItemKind::Let { value, .. }
4056 | BlockItemKind::PathAssignment { value, .. }
4057 | BlockItemKind::InvariantBlock { body: value, .. }
4058 | BlockItemKind::FieldWithWhen { value, .. } => {
4059 collect_qualified_field_refs(value, alias, out);
4060 }
4061 BlockItemKind::ForBlock { collection, filter, items, .. } => {
4062 collect_qualified_field_refs(collection, alias, out);
4063 if let Some(f) = filter {
4064 collect_qualified_field_refs(f, alias, out);
4065 }
4066 for item in items {
4067 collect_qualified_field_refs_from_item(&item.kind, alias, out);
4068 }
4069 }
4070 BlockItemKind::IfBlock { branches, else_items } => {
4071 for b in branches {
4072 collect_qualified_field_refs(&b.condition, alias, out);
4073 for item in &b.items {
4074 collect_qualified_field_refs_from_item(&item.kind, alias, out);
4075 }
4076 }
4077 if let Some(items) = else_items {
4078 for item in items {
4079 collect_qualified_field_refs_from_item(&item.kind, alias, out);
4080 }
4081 }
4082 }
4083 _ => {}
4084 }
4085}
4086
4087fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
4088 match kind {
4089 BlockItemKind::Clause { value, .. }
4090 | BlockItemKind::Assignment { value, .. }
4091 | BlockItemKind::ParamAssignment { value, .. }
4092 | BlockItemKind::Let { value, .. }
4093 | BlockItemKind::PathAssignment { value, .. }
4094 | BlockItemKind::InvariantBlock { body: value, .. }
4095 | BlockItemKind::FieldWithWhen { value, .. } => {
4096 collect_accessed_fields_from_expr(value, out);
4097 }
4098 BlockItemKind::ForBlock {
4099 collection,
4100 filter,
4101 items,
4102 ..
4103 } => {
4104 collect_accessed_fields_from_expr(collection, out);
4105 if let Some(f) = filter {
4106 collect_accessed_fields_from_expr(f, out);
4107 }
4108 for item in items {
4109 collect_accessed_fields_from_item(&item.kind, out);
4110 }
4111 }
4112 BlockItemKind::IfBlock {
4113 branches,
4114 else_items,
4115 } => {
4116 for b in branches {
4117 collect_accessed_fields_from_expr(&b.condition, out);
4118 for item in &b.items {
4119 collect_accessed_fields_from_item(&item.kind, out);
4120 }
4121 }
4122 if let Some(items) = else_items {
4123 for item in items {
4124 collect_accessed_fields_from_item(&item.kind, out);
4125 }
4126 }
4127 }
4128 _ => {}
4129 }
4130}
4131
4132fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
4133 match expr {
4134 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
4135 out.insert(&field.name);
4136 collect_accessed_fields_from_expr(object, out);
4137 }
4138 Expr::Call { function, args, .. } => {
4139 collect_accessed_fields_from_expr(function, out);
4140 let is_created = matches!(
4144 function.as_ref(),
4145 Expr::MemberAccess { field, .. } if field.name == "created"
4146 );
4147 for a in args {
4148 match a {
4149 CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
4150 CallArg::Named(n) => {
4151 if is_created {
4152 out.insert(&n.name.name);
4153 }
4154 collect_accessed_fields_from_expr(&n.value, out);
4155 }
4156 }
4157 }
4158 }
4159 Expr::BinaryOp { left, right, .. }
4160 | Expr::Comparison { left, right, .. }
4161 | Expr::LogicalOp { left, right, .. }
4162 | Expr::Pipe { left, right, .. }
4163 | Expr::NullCoalesce { left, right, .. } => {
4164 collect_accessed_fields_from_expr(left, out);
4165 collect_accessed_fields_from_expr(right, out);
4166 }
4167 Expr::Not { operand, .. }
4168 | Expr::Exists { operand, .. }
4169 | Expr::NotExists { operand, .. }
4170 | Expr::TypeOptional { inner: operand, .. } => {
4171 collect_accessed_fields_from_expr(operand, out);
4172 }
4173 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4174 collect_accessed_fields_from_expr(element, out);
4175 collect_accessed_fields_from_expr(collection, out);
4176 }
4177 Expr::Where { source, condition, .. }
4178 | Expr::With {
4179 source,
4180 predicate: condition,
4181 ..
4182 } => {
4183 collect_accessed_fields_from_expr(source, out);
4184 collect_accessed_fields_from_expr(condition, out);
4185 }
4186 Expr::WhenGuard { action, condition, .. } => {
4187 collect_accessed_fields_from_expr(action, out);
4188 collect_accessed_fields_from_expr(condition, out);
4189 }
4190 Expr::Block { items, .. } => {
4191 for item in items {
4192 collect_accessed_fields_from_expr(item, out);
4193 }
4194 }
4195 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
4196 collect_accessed_fields_from_expr(value, out);
4197 }
4198 Expr::Conditional { branches, else_body, .. } => {
4199 for b in branches {
4200 collect_accessed_fields_from_expr(&b.condition, out);
4201 collect_accessed_fields_from_expr(&b.body, out);
4202 }
4203 if let Some(body) = else_body {
4204 collect_accessed_fields_from_expr(body, out);
4205 }
4206 }
4207 Expr::For { collection, filter, body, .. } => {
4208 collect_accessed_fields_from_expr(collection, out);
4209 if let Some(f) = filter {
4210 collect_accessed_fields_from_expr(f, out);
4211 }
4212 collect_accessed_fields_from_expr(body, out);
4213 }
4214 Expr::Lambda { body, .. } => {
4215 collect_accessed_fields_from_expr(body, out);
4216 }
4217 Expr::JoinLookup { entity, fields, .. } => {
4218 collect_accessed_fields_from_expr(entity, out);
4219 for f in fields {
4220 out.insert(&f.field.name);
4221 if let Some(v) = &f.value {
4222 collect_accessed_fields_from_expr(v, out);
4223 }
4224 }
4225 }
4226 Expr::TransitionsTo { subject, new_state, .. }
4227 | Expr::Becomes { subject, new_state, .. } => {
4228 collect_accessed_fields_from_expr(subject, out);
4229 collect_accessed_fields_from_expr(new_state, out);
4230 }
4231 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
4232 for e in elements {
4233 collect_accessed_fields_from_expr(e, out);
4234 }
4235 }
4236 Expr::ObjectLiteral { fields, .. } => {
4237 for f in fields {
4238 collect_accessed_fields_from_expr(&f.value, out);
4239 }
4240 }
4241 Expr::GenericType { name, args, .. } => {
4242 collect_accessed_fields_from_expr(name, out);
4243 for a in args {
4244 collect_accessed_fields_from_expr(a, out);
4245 }
4246 }
4247 Expr::ProjectionMap { source, .. } => {
4248 collect_accessed_fields_from_expr(source, out);
4249 }
4250 _ => {}
4251 }
4252}
4253
4254impl Ctx<'_> {
4259 fn check_unused_entities(&mut self) {
4260 let mut all_idents = self.collect_all_referenced_idents();
4261 for name in self.external_refs {
4263 all_idents.insert(name.as_str());
4264 }
4265 for v in self.variants() {
4267 let base = expr_as_ident(&v.base).or_else(|| {
4268 if let Expr::JoinLookup { entity, .. } = &v.base {
4269 expr_as_ident(entity)
4270 } else {
4271 None
4272 }
4273 });
4274 if let Some(name) = base {
4275 all_idents.insert(name);
4276 }
4277 }
4278 let mut findings = Vec::new();
4279
4280 for d in &self.module.declarations {
4281 let block = match d {
4282 Decl::Block(b)
4283 if matches!(
4284 b.kind,
4285 BlockKind::Entity | BlockKind::ExternalEntity
4286 ) =>
4287 {
4288 b
4289 }
4290 _ => continue,
4291 };
4292 let name = match &block.name {
4293 Some(n) => n,
4294 None => continue,
4295 };
4296 if !all_idents.contains(name.name.as_str()) {
4297 findings.push(
4298 Diagnostic::warning(
4299 name.span,
4300 format!(
4301 "Entity '{}' is declared but not referenced elsewhere in this specification.",
4302 name.name
4303 ),
4304 )
4305 .with_code("allium.entity.unused"),
4306 );
4307 }
4308 }
4309 self.diagnostics.extend(findings);
4310 }
4311
4312 fn check_unused_definitions(&mut self) {
4313 let mut all_idents = self.collect_all_referenced_idents();
4314 for name in self.external_refs {
4316 all_idents.insert(name.as_str());
4317 }
4318 let mut findings = Vec::new();
4319
4320 for d in &self.module.declarations {
4321 match d {
4322 Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
4323 let name = match &b.name {
4324 Some(n) => n,
4325 None => continue,
4326 };
4327 if !all_idents.contains(name.name.as_str()) {
4328 findings.push(
4329 Diagnostic::warning(
4330 name.span,
4331 format!(
4332 "Value '{}' is declared but not referenced elsewhere.",
4333 name.name
4334 ),
4335 )
4336 .with_code("allium.definition.unused"),
4337 );
4338 }
4339 }
4340 _ => {}
4341 }
4342 }
4343 self.diagnostics.extend(findings);
4344 }
4345
4346 fn collect_all_referenced_idents(&self) -> HashSet<&str> {
4349 collect_referenced_ident_nodes(self.module)
4350 .into_iter()
4351 .map(|id| id.name)
4352 .collect()
4353 }
4354}
4355
4356fn collect_uppercase_idents_from_item<'a>(
4357 kind: &'a BlockItemKind,
4358 out: &mut Vec<ReferencedIdent<'a>>,
4359) {
4360 match kind {
4361 BlockItemKind::Clause { value, .. }
4362 | BlockItemKind::Assignment { value, .. }
4363 | BlockItemKind::ParamAssignment { value, .. }
4364 | BlockItemKind::Let { value, .. }
4365 | BlockItemKind::PathAssignment { value, .. }
4366 | BlockItemKind::InvariantBlock { body: value, .. }
4367 | BlockItemKind::FieldWithWhen { value, .. } => {
4368 collect_uppercase_idents_from_expr(value, out);
4369 }
4370 BlockItemKind::ForBlock {
4371 collection,
4372 filter,
4373 items,
4374 ..
4375 } => {
4376 collect_uppercase_idents_from_expr(collection, out);
4377 if let Some(f) = filter {
4378 collect_uppercase_idents_from_expr(f, out);
4379 }
4380 for item in items {
4381 collect_uppercase_idents_from_item(&item.kind, out);
4382 }
4383 }
4384 BlockItemKind::IfBlock {
4385 branches,
4386 else_items,
4387 } => {
4388 for b in branches {
4389 collect_uppercase_idents_from_expr(&b.condition, out);
4390 for item in &b.items {
4391 collect_uppercase_idents_from_item(&item.kind, out);
4392 }
4393 }
4394 if let Some(items) = else_items {
4395 for item in items {
4396 collect_uppercase_idents_from_item(&item.kind, out);
4397 }
4398 }
4399 }
4400 BlockItemKind::ContractsClause { entries } => {
4401 for e in entries {
4402 if e.qualifier.is_none() {
4406 out.push(ReferencedIdent::unqualified(&e.name));
4407 }
4408 }
4409 }
4410 _ => {}
4411 }
4412}
4413
4414fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut Vec<ReferencedIdent<'a>>) {
4415 match expr {
4416 Expr::Ident(id) if starts_uppercase(&id.name) => {
4417 out.push(ReferencedIdent::unqualified(id));
4418 }
4419 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
4420 collect_uppercase_idents_from_expr(object, out);
4421 }
4422 Expr::Call { function, args, .. } => {
4423 collect_uppercase_idents_from_expr(function, out);
4424 for a in args {
4425 match a {
4426 CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
4427 CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
4428 }
4429 }
4430 }
4431 Expr::JoinLookup { entity, fields, .. } => {
4432 collect_uppercase_idents_from_expr(entity, out);
4433 for f in fields {
4434 if let Some(v) = &f.value {
4435 collect_uppercase_idents_from_expr(v, out);
4436 }
4437 }
4438 }
4439 Expr::BinaryOp { left, right, .. }
4440 | Expr::Comparison { left, right, .. }
4441 | Expr::LogicalOp { left, right, .. }
4442 | Expr::Pipe { left, right, .. }
4443 | Expr::NullCoalesce { left, right, .. } => {
4444 collect_uppercase_idents_from_expr(left, out);
4445 collect_uppercase_idents_from_expr(right, out);
4446 }
4447 Expr::Not { operand, .. }
4448 | Expr::Exists { operand, .. }
4449 | Expr::NotExists { operand, .. }
4450 | Expr::TypeOptional { inner: operand, .. } => {
4451 collect_uppercase_idents_from_expr(operand, out);
4452 }
4453 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4454 collect_uppercase_idents_from_expr(element, out);
4455 collect_uppercase_idents_from_expr(collection, out);
4456 }
4457 Expr::Where { source, condition, .. }
4458 | Expr::With {
4459 source,
4460 predicate: condition,
4461 ..
4462 } => {
4463 collect_uppercase_idents_from_expr(source, out);
4464 collect_uppercase_idents_from_expr(condition, out);
4465 }
4466 Expr::WhenGuard { action, condition, .. } => {
4467 collect_uppercase_idents_from_expr(action, out);
4468 collect_uppercase_idents_from_expr(condition, out);
4469 }
4470 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
4471 collect_uppercase_idents_from_expr(value, out);
4472 }
4473 Expr::Block { items, .. } => {
4474 for item in items {
4475 collect_uppercase_idents_from_expr(item, out);
4476 }
4477 }
4478 Expr::Conditional { branches, else_body, .. } => {
4479 for b in branches {
4480 collect_uppercase_idents_from_expr(&b.condition, out);
4481 collect_uppercase_idents_from_expr(&b.body, out);
4482 }
4483 if let Some(body) = else_body {
4484 collect_uppercase_idents_from_expr(body, out);
4485 }
4486 }
4487 Expr::For { collection, filter, body, .. } => {
4488 collect_uppercase_idents_from_expr(collection, out);
4489 if let Some(f) = filter {
4490 collect_uppercase_idents_from_expr(f, out);
4491 }
4492 collect_uppercase_idents_from_expr(body, out);
4493 }
4494 Expr::Lambda { body, .. } => {
4495 collect_uppercase_idents_from_expr(body, out);
4496 }
4497 Expr::TransitionsTo { subject, new_state, .. }
4498 | Expr::Becomes { subject, new_state, .. } => {
4499 collect_uppercase_idents_from_expr(subject, out);
4500 collect_uppercase_idents_from_expr(new_state, out);
4501 }
4502 Expr::GenericType { name, args, .. } => {
4503 collect_uppercase_idents_from_expr(name, out);
4504 for a in args {
4505 collect_uppercase_idents_from_expr(a, out);
4506 }
4507 }
4508 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
4509 for e in elements {
4510 collect_uppercase_idents_from_expr(e, out);
4511 }
4512 }
4513 Expr::ObjectLiteral { fields, .. } => {
4514 for f in fields {
4515 collect_uppercase_idents_from_expr(&f.value, out);
4516 }
4517 }
4518 Expr::ProjectionMap { source, .. } => {
4519 collect_uppercase_idents_from_expr(source, out);
4520 }
4521 Expr::QualifiedName(q) => {
4522 out.push(ReferencedIdent {
4523 name: &q.name,
4524 span: q.span,
4525 qualified: q.qualifier.is_some(),
4526 });
4527 }
4528 _ => {}
4529 }
4530}
4531
4532pub fn collect_qualified_references(module: &Module) -> Vec<(String, String)> {
4542 collect_qref_nodes(module)
4543 .into_iter()
4544 .map(|r| (r.qualifier.to_string(), r.name.to_string()))
4545 .collect()
4546}
4547
4548fn collect_qref_nodes(module: &Module) -> Vec<QRef<'_>> {
4551 let mut refs = Vec::new();
4552 for d in &module.declarations {
4553 match d {
4554 Decl::Block(b) => {
4555 for item in &b.items {
4556 collect_qrefs_from_item(&item.kind, &mut refs);
4557 }
4558 }
4559 Decl::Variant(v) => {
4560 collect_qrefs_from_expr(&v.base, &mut refs);
4561 for item in &v.items {
4562 collect_qrefs_from_item(&item.kind, &mut refs);
4563 }
4564 }
4565 Decl::Invariant(inv) => {
4566 collect_qrefs_from_expr(&inv.body, &mut refs);
4567 }
4568 Decl::Default(def) => {
4569 collect_qrefs_from_expr(&def.value, &mut refs);
4570 }
4571 Decl::Deferred(def) => {
4572 collect_qrefs_from_expr(&def.path, &mut refs);
4573 }
4574 _ => {}
4576 }
4577 }
4578 refs
4579}
4580
4581pub fn collect_all_referenced_idents(module: &Module) -> HashSet<String> {
4587 collect_referenced_ident_nodes(module)
4588 .into_iter()
4589 .map(|id| id.name.to_string())
4590 .collect()
4591}
4592
4593struct ReferencedIdent<'a> {
4596 name: &'a str,
4597 span: Span,
4598 qualified: bool,
4602}
4603
4604impl<'a> ReferencedIdent<'a> {
4605 fn unqualified(id: &'a Ident) -> Self {
4606 Self {
4607 name: &id.name,
4608 span: id.span,
4609 qualified: false,
4610 }
4611 }
4612
4613 fn qualified(id: &'a Ident) -> Self {
4614 Self {
4615 name: &id.name,
4616 span: id.span,
4617 qualified: true,
4618 }
4619 }
4620}
4621
4622fn collect_referenced_ident_nodes(module: &Module) -> Vec<ReferencedIdent<'_>> {
4627 let mut idents: Vec<ReferencedIdent<'_>> = Vec::new();
4628 for d in &module.declarations {
4629 match d {
4630 Decl::Block(b) => {
4631 for item in &b.items {
4632 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4633 }
4634 }
4635 Decl::Variant(v) => {
4636 if let Expr::Ident(id) = &v.base {
4637 idents.push(ReferencedIdent::unqualified(id));
4638 }
4639 for item in &v.items {
4640 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4641 }
4642 }
4643 Decl::Invariant(inv) => {
4644 collect_uppercase_idents_from_expr(&inv.body, &mut idents);
4645 }
4646 Decl::Default(def) => {
4647 if let Some(tn) = &def.type_name {
4648 if def.type_alias.is_some() {
4653 idents.push(ReferencedIdent::qualified(tn));
4654 } else {
4655 idents.push(ReferencedIdent::unqualified(tn));
4656 }
4657 }
4658 collect_uppercase_idents_from_expr(&def.value, &mut idents);
4659 }
4660 _ => {}
4661 }
4662 }
4663 idents
4664}
4665
4666pub fn collect_declared_names(module: &Module) -> HashSet<String> {
4673 let mut names = HashSet::new();
4674 for d in &module.declarations {
4675 match d {
4676 Decl::Block(b) => {
4677 if matches!(
4678 b.kind,
4679 BlockKind::Entity
4680 | BlockKind::ExternalEntity
4681 | BlockKind::Value
4682 | BlockKind::Enum
4683 | BlockKind::Actor
4684 | BlockKind::Contract
4685 ) {
4686 if let Some(n) = &b.name {
4687 names.insert(n.name.clone());
4688 }
4689 }
4690 }
4691 Decl::Variant(v) => {
4692 names.insert(v.name.name.clone());
4693 }
4694 _ => {}
4695 }
4696 }
4697 names
4698}
4699
4700pub fn collect_trigger_outputs(module: &Module) -> HashSet<String> {
4707 let mut names: HashSet<&str> = HashSet::new();
4708 for d in &module.declarations {
4709 let Decl::Block(b) = d else { continue };
4710 match b.kind {
4711 BlockKind::Surface => {
4712 for item in &b.items {
4713 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4714 if keyword == "provides" {
4715 collect_call_names(value, &mut names);
4716 }
4717 }
4718 }
4719 }
4720 BlockKind::Rule => {
4721 for item in &b.items {
4722 collect_emitted_trigger_from_item(&item.kind, &mut names);
4723 }
4724 }
4725 _ => {}
4726 }
4727 }
4728 names.into_iter().map(str::to_string).collect()
4729}
4730
4731pub fn collect_referenced_trigger_names(module: &Module) -> HashSet<String> {
4741 let mut names = collect_trigger_outputs(module);
4742 names.extend(collect_declared_names(module));
4743 for d in &module.declarations {
4744 match d {
4745 Decl::Deferred(def) => {
4751 if let Some(id) = extract_leading_ident(&def.path) {
4752 names.insert(id.name.clone());
4753 }
4754 }
4755 Decl::Block(b) if b.kind == BlockKind::Config => {
4756 names.insert("config".to_string());
4757 }
4758 Decl::Block(b) if b.kind == BlockKind::Rule => {
4759 for item in &b.items {
4760 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4761 if keyword == "when" {
4762 for tref in extract_trigger_refs(value) {
4763 names.insert(tref.name.to_string());
4764 }
4765 }
4766 }
4767 }
4768 }
4769 _ => {}
4770 }
4771 }
4772 names
4773}
4774
4775pub fn collect_reverse_contributions<'a>(
4780 importer: &'a Module,
4781 alias: &str,
4782 imported: &'a Module,
4783) -> ReverseContributions {
4784 let mut out = ReverseContributions::default();
4785
4786 let imported_info = EntityInfo::from_module(imported);
4788 let status_by_entity = imported_info.status_by_entity();
4789
4790 let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
4799 for b in module_blocks(imported, BlockKind::Surface) {
4800 for item in &b.items {
4801 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4802 if keyword == "provides" {
4803 collect_command_param_types(value, &status_by_entity, &mut command_param_types);
4804 }
4805 }
4806 }
4807 }
4808 collect_importer_command_param_types(
4809 importer, alias, &status_by_entity, &mut command_param_types,
4810 );
4811 collect_emitted_event_param_types(imported, &status_by_entity, &mut command_param_types);
4812
4813 for b in module_blocks(importer, BlockKind::Surface) {
4815 for item in &b.items {
4816 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4817 if keyword == "provides" {
4818 collect_qualified_provides(value, alias, &mut out.provided_triggers);
4819 }
4820 }
4821 }
4822 }
4823
4824 for rule in module_blocks(importer, BlockKind::Rule) {
4828 for_each_rule_clause(&rule.items, &mut |keyword, value| {
4829 if keyword == "ensures" {
4830 collect_qualified_created(value, alias, &status_by_entity, &mut out.assigned_statuses);
4831 }
4832 });
4833 collect_witnessed_transition(
4834 rule, alias, &command_param_types, &status_by_entity, &mut out,
4835 );
4836 }
4837
4838 let mut imported_field_names: HashSet<&str> = HashSet::new();
4843 for entity in module_blocks(imported, BlockKind::Entity)
4844 .chain(module_blocks(imported, BlockKind::ExternalEntity))
4845 {
4846 for item in &entity.items {
4847 if let BlockItemKind::Assignment { name, .. }
4848 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
4849 {
4850 imported_field_names.insert(&name.name);
4851 }
4852 }
4853 }
4854 if !imported_field_names.is_empty() {
4855 let mut qualified_refs: HashSet<&str> = HashSet::new();
4856 for block in &importer.declarations {
4857 if let Decl::Block(b) = block {
4858 for item in &b.items {
4859 collect_qualified_field_refs_from_item(&item.kind, alias, &mut qualified_refs);
4860 }
4861 }
4862 }
4863 for f in qualified_refs {
4864 if imported_field_names.contains(f) {
4865 out.referenced_fields.insert(f.to_string());
4866 }
4867 }
4868 }
4869
4870 out
4871}
4872
4873fn module_blocks(module: &Module, kind: BlockKind) -> impl Iterator<Item = &BlockDecl> {
4876 module.declarations.iter().filter_map(move |d| match d {
4877 Decl::Block(b) if b.kind == kind => Some(b),
4878 _ => None,
4879 })
4880}
4881
4882fn collect_qualified_provides(expr: &Expr, alias: &str, out: &mut HashSet<String>) {
4889 match expr {
4890 Expr::Call { function, .. } => {
4891 if let Expr::QualifiedName(q) = function.as_ref() {
4892 if q.qualifier.as_deref() == Some(alias) && starts_uppercase(&q.name) {
4893 out.insert(q.name.clone());
4894 }
4895 }
4896 }
4897 Expr::Block { items, .. } => {
4898 for item in items {
4899 collect_qualified_provides(item, alias, out);
4900 }
4901 }
4902 Expr::WhenGuard { action, .. } => collect_qualified_provides(action, alias, out),
4903 Expr::Conditional { branches, else_body, .. } => {
4904 for b in branches {
4905 collect_qualified_provides(&b.body, alias, out);
4906 }
4907 if let Some(body) = else_body {
4908 collect_qualified_provides(body, alias, out);
4909 }
4910 }
4911 Expr::For { body, .. } => collect_qualified_provides(body, alias, out),
4912 _ => {}
4913 }
4914}
4915
4916
4917fn collect_importer_command_param_types<'a>(
4923 importer: &'a Module,
4924 alias: &str,
4925 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4926 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
4927) {
4928 for surface in module_blocks(importer, BlockKind::Surface) {
4929 let mut context_types: HashMap<&str, &str> = HashMap::new();
4931 for item in &surface.items {
4932 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4933 if keyword == "context" || keyword == "facing" {
4934 qualified_context_binding(value, alias, status_by_entity, &mut context_types);
4935 }
4936 }
4937 }
4938 for item in &surface.items {
4939 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4940 if keyword == "provides" {
4941 collect_provides_param_types(
4942 value, alias, &context_types, status_by_entity, out,
4943 );
4944 }
4945 }
4946 }
4947 }
4948}
4949
4950fn augment_binding_types_from_status<'a>(
4963 rule: &'a BlockDecl,
4964 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4965 binding_types: &mut HashMap<&'a str, &'a str>,
4966) {
4967 let status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
4968 .iter()
4969 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
4970 .collect();
4971 let mut pairs: Vec<(&str, &str)> = Vec::new();
4972 for_each_rule_clause(&rule.items, &mut |keyword, value| {
4973 if keyword == "requires" || keyword == "ensures" {
4974 collect_status_equations(value, &mut pairs);
4975 }
4976 });
4977 for (binding, target) in pairs {
4978 if binding_types.contains_key(binding) {
4979 continue;
4980 }
4981 if let Some(entity) =
4982 resolve_binding_entity_from_status(binding, Some(target), binding_types, &status_values)
4983 {
4984 binding_types.insert(binding, entity);
4985 }
4986 }
4987}
4988
4989fn collect_status_equations<'a>(expr: &'a Expr, out: &mut Vec<(&'a str, &'a str)>) {
4992 match expr {
4993 Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
4994 if let (Some((binding, "status")), Some(target)) =
4995 (expr_as_member_access(left), expr_as_ident(right))
4996 {
4997 out.push((binding, target));
4998 }
4999 }
5000 Expr::LogicalOp { left, right, .. } => {
5001 collect_status_equations(left, out);
5002 collect_status_equations(right, out);
5003 }
5004 Expr::Block { items, .. } => {
5005 for item in items {
5006 collect_status_equations(item, out);
5007 }
5008 }
5009 Expr::Conditional { branches, else_body, .. } => {
5010 for b in branches {
5011 collect_status_equations(&b.body, out);
5012 }
5013 if let Some(body) = else_body {
5014 collect_status_equations(body, out);
5015 }
5016 }
5017 _ => {}
5018 }
5019}
5020
5021fn collect_emitted_event_param_types<'a>(
5022 imported: &'a Module,
5023 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5024 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
5025) {
5026 let mut surface_params: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
5031 for surface in module_blocks(imported, BlockKind::Surface) {
5032 for item in &surface.items {
5033 if let BlockItemKind::Clause { keyword, value } = &item.kind {
5034 if keyword == "provides" {
5035 collect_command_param_types(value, status_by_entity, &mut surface_params);
5036 }
5037 }
5038 }
5039 }
5040 for rule in module_blocks(imported, BlockKind::Rule) {
5041 let mut binding_types = collect_rule_binding_types(rule, status_by_entity);
5042 augment_binding_types_from_commands(rule, &surface_params, &mut binding_types);
5043 augment_binding_types_from_status(rule, status_by_entity, &mut binding_types);
5044 if binding_types.is_empty() {
5045 continue;
5046 }
5047 for_each_rule_clause(&rule.items, &mut |keyword, value| {
5048 if keyword == "ensures" {
5049 collect_emission_param_types(value, &binding_types, out);
5050 }
5051 });
5052 }
5053}
5054
5055fn collect_emission_param_types<'a>(
5058 expr: &'a Expr,
5059 binding_types: &HashMap<&'a str, &'a str>,
5060 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
5061) {
5062 match expr {
5063 Expr::Call { function, args, .. } => {
5064 if let Expr::Ident(event) = function.as_ref() {
5065 let params: Vec<Option<&str>> = args
5066 .iter()
5067 .map(|arg| {
5068 let val = match arg {
5069 CallArg::Named(n) => &n.value,
5070 CallArg::Positional(e) => e,
5071 };
5072 match val {
5073 Expr::Ident(v) => binding_types.get(v.name.as_str()).copied(),
5074 _ => None,
5075 }
5076 })
5077 .collect();
5078 if params.iter().any(Option::is_some) {
5079 out.entry(&event.name).or_insert(params);
5080 }
5081 }
5082 }
5083 Expr::Block { items, .. } => {
5084 for item in items {
5085 collect_emission_param_types(item, binding_types, out);
5086 }
5087 }
5088 Expr::Conditional { branches, else_body, .. } => {
5089 for b in branches {
5090 collect_emission_param_types(&b.body, binding_types, out);
5091 }
5092 if let Some(body) = else_body {
5093 collect_emission_param_types(body, binding_types, out);
5094 }
5095 }
5096 _ => {}
5097 }
5098}
5099
5100fn unwrap_type_refinement(expr: &Expr) -> &Expr {
5110 let mut cur = expr;
5111 loop {
5112 cur = match cur {
5113 Expr::Where { source, .. } | Expr::With { source, .. } => source,
5114 Expr::TypeOptional { inner, .. } => inner,
5115 other => return other,
5116 };
5117 }
5118}
5119
5120fn qualified_context_binding<'a>(
5121 expr: &'a Expr,
5122 alias: &str,
5123 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5124 out: &mut HashMap<&'a str, &'a str>,
5125) {
5126 match expr {
5127 Expr::Binding { name, value, .. } => {
5128 let type_expr = unwrap_type_refinement(value.as_ref());
5131 if let Expr::QualifiedName(q) = type_expr {
5132 if q.qualifier.as_deref() == Some(alias) {
5133 if let Some((entity, _)) = status_by_entity.get_key_value(q.name.as_str()) {
5134 out.insert(&name.name, entity);
5135 }
5136 }
5137 }
5138 }
5139 Expr::Block { items, .. } => {
5140 for item in items {
5141 qualified_context_binding(item, alias, status_by_entity, out);
5142 }
5143 }
5144 _ => {}
5145 }
5146}
5147
5148fn collect_provides_param_types<'a>(
5151 expr: &'a Expr,
5152 alias: &str,
5153 context_types: &HashMap<&'a str, &'a str>,
5154 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5155 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
5156) {
5157 match expr {
5158 Expr::Call { function, args, .. } => {
5159 if let Expr::Ident(fn_name) = function.as_ref() {
5160 let params: Vec<Option<&str>> = args
5161 .iter()
5162 .map(|arg| match arg {
5163 CallArg::Positional(Expr::Ident(id)) => {
5164 context_types.get(id.name.as_str()).copied()
5165 }
5166 CallArg::Named(n) => match unwrap_type_refinement(&n.value) {
5167 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => {
5168 status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
5169 }
5170 Expr::Ident(v) => context_types.get(v.name.as_str()).copied(),
5171 _ => None,
5172 },
5173 _ => None,
5174 })
5175 .collect();
5176 if params.iter().any(Option::is_some) {
5177 out.insert(&fn_name.name, params);
5178 }
5179 }
5180 }
5181 Expr::WhenGuard { action, .. } => {
5182 collect_provides_param_types(action, alias, context_types, status_by_entity, out)
5183 }
5184 Expr::Block { items, .. } => {
5185 for item in items {
5186 collect_provides_param_types(item, alias, context_types, status_by_entity, out);
5187 }
5188 }
5189 Expr::Conditional { branches, else_body, .. } => {
5190 for b in branches {
5191 collect_provides_param_types(&b.body, alias, context_types, status_by_entity, out);
5192 }
5193 if let Some(body) = else_body {
5194 collect_provides_param_types(body, alias, context_types, status_by_entity, out);
5195 }
5196 }
5197 Expr::For { body, .. } => {
5198 collect_provides_param_types(body, alias, context_types, status_by_entity, out)
5199 }
5200 _ => {}
5201 }
5202}
5203
5204fn collect_qualified_created(
5207 expr: &Expr,
5208 alias: &str,
5209 status_by_entity: &HashMap<&str, HashSet<&str>>,
5210 out: &mut HashMap<String, HashSet<String>>,
5211) {
5212 match expr {
5213 Expr::Call { function, args, .. } => {
5214 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
5215 if field.name == "created" {
5216 if let Expr::QualifiedName(q) = object.as_ref() {
5217 if q.qualifier.as_deref() == Some(alias) {
5218 if let Some(statuses) = status_by_entity.get(q.name.as_str()) {
5219 for arg in args {
5220 if let CallArg::Named(named) = arg {
5221 if named.name.name == "status" {
5222 if let Expr::Ident(val) = &named.value {
5223 if statuses.contains(val.name.as_str()) {
5224 out.entry(q.name.clone())
5225 .or_default()
5226 .insert(val.name.clone());
5227 }
5228 }
5229 }
5230 }
5231 }
5232 }
5233 }
5234 }
5235 }
5236 }
5237 }
5238 Expr::Block { items, .. } => {
5239 for item in items {
5240 collect_qualified_created(item, alias, status_by_entity, out);
5241 }
5242 }
5243 Expr::Conditional { branches, else_body, .. } => {
5244 for b in branches {
5245 collect_qualified_created(&b.body, alias, status_by_entity, out);
5246 }
5247 if let Some(body) = else_body {
5248 collect_qualified_created(body, alias, status_by_entity, out);
5249 }
5250 }
5251 _ => {}
5252 }
5253}
5254
5255fn collect_witnessed_transition(
5266 rule: &BlockDecl,
5267 alias: &str,
5268 command_param_types: &HashMap<&str, Vec<Option<&str>>>,
5269 status_by_entity: &HashMap<&str, HashSet<&str>>,
5270 out: &mut ReverseContributions,
5271) {
5272 let mut binding_entity: HashMap<&str, &str> = HashMap::new();
5279 let mut trigger_source: HashMap<&str, &str> = HashMap::new();
5280 for item in &rule.items {
5281 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5282 continue;
5283 };
5284 if keyword != "when" {
5285 continue;
5286 }
5287 match value {
5288 Expr::Call { function, args, .. } => {
5289 let trigger_name = match function.as_ref() {
5295 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => Some(&q.name),
5296 Expr::Ident(id) => Some(&id.name),
5297 _ => None,
5298 };
5299 if let Some(name) = trigger_name {
5300 if let Some(params) = command_param_types.get(name.as_str()) {
5301 for (arg, param) in args.iter().zip(params) {
5302 if let (CallArg::Positional(Expr::Ident(b)), Some(entity)) = (arg, param)
5303 {
5304 binding_entity.insert(b.name.as_str(), entity);
5305 }
5306 }
5307 }
5308 }
5309 }
5310 Expr::Binding { name, value: inner, .. } => {
5311 if let Some((entity, source)) =
5312 qualified_transition_trigger(inner, alias, status_by_entity)
5313 {
5314 binding_entity.insert(name.name.as_str(), entity);
5315 trigger_source.insert(name.name.as_str(), source);
5316 } else if let Some(entity) =
5317 qualified_temporal_trigger_entity(inner, alias, status_by_entity)
5318 {
5319 binding_entity.insert(name.name.as_str(), entity);
5322 }
5323 }
5324 _ => {}
5325 }
5326 }
5327 if binding_entity.is_empty() {
5328 return;
5329 }
5330
5331 let mut froms: HashMap<&str, HashSet<&str>> = HashMap::new();
5334 let mut tos: HashMap<&str, HashSet<&str>> = HashMap::new();
5335 for (binding, source) in &trigger_source {
5336 froms.entry(binding).or_default().insert(source);
5337 }
5338 for_each_rule_clause(&rule.items, &mut |keyword, value| {
5342 let target = match keyword {
5343 "requires" => &mut froms,
5344 "ensures" => &mut tos,
5345 _ => return,
5346 };
5347 collect_binding_status_eq(value, &mut |binding, status| {
5348 target.entry(binding).or_default().insert(status);
5349 });
5350 });
5351
5352 for (binding, entity) in &binding_entity {
5353 let Some(valid) = status_by_entity.get(*entity) else {
5354 continue;
5355 };
5356 let Some(to_set) = tos.get(binding) else {
5357 continue;
5358 };
5359 for to in to_set {
5360 if !valid.contains(to) {
5361 continue;
5362 }
5363 out.assigned_statuses
5364 .entry((*entity).to_string())
5365 .or_default()
5366 .insert((*to).to_string());
5367 if let Some(from_set) = froms.get(binding) {
5368 for from in from_set {
5369 if valid.contains(from) {
5370 out.witnessed_transitions
5371 .entry((*entity).to_string())
5372 .or_default()
5373 .insert(((*from).to_string(), (*to).to_string()));
5374 }
5375 }
5376 }
5377 }
5378 }
5379}
5380
5381fn qualified_transition_trigger<'a>(
5387 expr: &'a Expr,
5388 alias: &str,
5389 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5390) -> Option<(&'a str, &'a str)> {
5391 let (subject, new_state) = match expr {
5392 Expr::Becomes { subject, new_state, .. }
5393 | Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
5394 _ => return None,
5395 };
5396 let Expr::MemberAccess { object, field, .. } = subject else {
5397 return None;
5398 };
5399 if field.name != "status" {
5400 return None;
5401 }
5402 let Expr::QualifiedName(q) = object.as_ref() else {
5403 return None;
5404 };
5405 if q.qualifier.as_deref() != Some(alias) {
5406 return None;
5407 }
5408 let (entity, values) = status_by_entity.get_key_value(q.name.as_str())?;
5409 let source = expr_as_ident(new_state)?;
5410 if !values.contains(source) {
5411 return None;
5412 }
5413 Some((*entity, source))
5414}
5415
5416fn qualified_temporal_trigger_entity<'a>(
5424 expr: &'a Expr,
5425 alias: &str,
5426 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5427) -> Option<&'a str> {
5428 fn member_entity<'a>(
5429 e: &'a Expr,
5430 alias: &str,
5431 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5432 ) -> Option<&'a str> {
5433 let Expr::MemberAccess { object, .. } = e else {
5434 return None;
5435 };
5436 let Expr::QualifiedName(q) = object.as_ref() else {
5437 return None;
5438 };
5439 if q.qualifier.as_deref() != Some(alias) {
5440 return None;
5441 }
5442 status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
5443 }
5444 match expr {
5445 Expr::Comparison { left, right, .. } => member_entity(left, alias, status_by_entity)
5446 .or_else(|| member_entity(right, alias, status_by_entity)),
5447 _ => None,
5448 }
5449}
5450
5451fn local_transition_trigger_source(expr: &Expr) -> Option<(&str, &str, &str)> {
5457 let Expr::Binding { name, value, .. } = expr else {
5458 return None;
5459 };
5460 let (subject, new_state) = match value.as_ref() {
5461 Expr::Becomes { subject, new_state, .. }
5462 | Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
5463 _ => return None,
5464 };
5465 let Expr::MemberAccess { object, field, .. } = subject else {
5466 return None;
5467 };
5468 if field.name != "status" {
5469 return None;
5470 }
5471 Some((name.name.as_str(), expr_as_ident(object)?, expr_as_ident(new_state)?))
5472}
5473
5474fn collect_binding_status_eq<'a>(expr: &'a Expr, cb: &mut impl FnMut(&'a str, &'a str)) {
5477 match expr {
5478 Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
5479 if let (Some((binding, "status")), Some(status)) =
5480 (expr_as_member_access(left), expr_as_ident(right))
5481 {
5482 cb(binding, status);
5483 }
5484 }
5485 Expr::LogicalOp { left, right, .. } => {
5486 collect_binding_status_eq(left, cb);
5487 collect_binding_status_eq(right, cb);
5488 }
5489 Expr::Block { items, .. } => {
5490 for item in items {
5491 collect_binding_status_eq(item, cb);
5492 }
5493 }
5494 Expr::Conditional { branches, else_body, .. } => {
5495 for b in branches {
5496 collect_binding_status_eq(&b.body, cb);
5497 }
5498 if let Some(body) = else_body {
5499 collect_binding_status_eq(body, cb);
5500 }
5501 }
5502 _ => {}
5503 }
5504}
5505
5506pub fn collect_entity_field_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
5512 let mut out: HashMap<String, HashSet<String>> = HashMap::new();
5513 for (name, fields) in collect_local_type_schemas(module) {
5514 out.insert(
5515 name.to_string(),
5516 fields.keys().map(|f| f.to_string()).collect(),
5517 );
5518 }
5519 out
5520}
5521
5522pub fn collect_entity_status_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
5527 EntityInfo::from_module(module)
5528 .status_by_entity()
5529 .into_iter()
5530 .map(|(name, statuses)| {
5531 (
5532 name.to_string(),
5533 statuses.into_iter().map(|s| s.to_string()).collect(),
5534 )
5535 })
5536 .collect()
5537}
5538
5539struct QRef<'a> {
5542 qualifier: &'a str,
5543 name: &'a str,
5544 span: Span,
5545 collection: bool,
5553}
5554
5555fn collect_qrefs_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<QRef<'a>>) {
5556 match kind {
5557 BlockItemKind::Clause { value, .. }
5558 | BlockItemKind::Assignment { value, .. }
5559 | BlockItemKind::ParamAssignment { value, .. }
5560 | BlockItemKind::PathAssignment { value, .. }
5561 | BlockItemKind::InvariantBlock { body: value, .. }
5562 | BlockItemKind::FieldWithWhen { value, .. } => {
5563 collect_qrefs_from_expr(value, out);
5564 }
5565 BlockItemKind::Let { value, .. } => {
5569 collect_qrefs_from_collection(value, out);
5570 }
5571 BlockItemKind::ForBlock {
5572 collection,
5573 filter,
5574 items,
5575 ..
5576 } => {
5577 collect_qrefs_from_collection(collection, out);
5578 if let Some(f) = filter {
5579 collect_qrefs_from_expr(f, out);
5580 }
5581 for item in items {
5582 collect_qrefs_from_item(&item.kind, out);
5583 }
5584 }
5585 BlockItemKind::IfBlock {
5586 branches,
5587 else_items,
5588 } => {
5589 for b in branches {
5590 collect_qrefs_from_expr(&b.condition, out);
5591 for item in &b.items {
5592 collect_qrefs_from_item(&item.kind, out);
5593 }
5594 }
5595 if let Some(items) = else_items {
5596 for item in items {
5597 collect_qrefs_from_item(&item.kind, out);
5598 }
5599 }
5600 }
5601 BlockItemKind::ContractsClause { entries } => {
5602 for e in entries {
5603 if let Some(qualifier) = &e.qualifier {
5604 out.push(QRef { qualifier, name: &e.name.name, span: e.name.span, collection: false });
5605 }
5606 }
5607 }
5608 _ => {}
5611 }
5612}
5613
5614fn collect_qrefs_from_expr<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
5615 match expr {
5616 Expr::QualifiedName(q) => {
5617 if let Some(qualifier) = &q.qualifier {
5618 out.push(QRef { qualifier, name: &q.name, span: q.span, collection: false });
5619 }
5620 }
5621 Expr::MemberAccess { object, field, .. }
5622 | Expr::OptionalAccess { object, field, .. } => {
5623 if let Expr::Ident(id) = object.as_ref() {
5625 if starts_uppercase(&field.name) {
5626 out.push(QRef { qualifier: &id.name, name: &field.name, span: id.span.merge(field.span), collection: false });
5627 }
5628 }
5629 collect_qrefs_from_expr(object, out);
5630 }
5631 Expr::Call { function, args, .. } => {
5632 collect_qrefs_from_expr(function, out);
5633 for a in args {
5634 match a {
5635 CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
5636 CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
5637 }
5638 }
5639 }
5640 Expr::JoinLookup { entity, fields, .. } => {
5641 collect_qrefs_from_expr(entity, out);
5642 for f in fields {
5643 if let Some(v) = &f.value {
5644 collect_qrefs_from_expr(v, out);
5645 }
5646 }
5647 }
5648 Expr::BinaryOp { left, right, .. }
5649 | Expr::Comparison { left, right, .. }
5650 | Expr::LogicalOp { left, right, .. }
5651 | Expr::Pipe { left, right, .. }
5652 | Expr::NullCoalesce { left, right, .. } => {
5653 collect_qrefs_from_expr(left, out);
5654 collect_qrefs_from_expr(right, out);
5655 }
5656 Expr::Not { operand, .. }
5657 | Expr::Exists { operand, .. }
5658 | Expr::NotExists { operand, .. }
5659 | Expr::TypeOptional { inner: operand, .. } => {
5660 collect_qrefs_from_expr(operand, out);
5661 }
5662 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5663 collect_qrefs_from_expr(element, out);
5664 collect_qrefs_from_collection(collection, out);
5665 }
5666 Expr::Where { source, condition, .. }
5667 | Expr::With {
5668 source,
5669 predicate: condition,
5670 ..
5671 } => {
5672 collect_qrefs_from_collection(source, out);
5673 collect_qrefs_from_expr(condition, out);
5674 }
5675 Expr::WhenGuard { action, condition, .. } => {
5676 collect_qrefs_from_expr(action, out);
5677 collect_qrefs_from_expr(condition, out);
5678 }
5679 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
5680 collect_qrefs_from_expr(value, out);
5681 }
5682 Expr::Block { items, .. } => {
5683 for item in items {
5684 collect_qrefs_from_expr(item, out);
5685 }
5686 }
5687 Expr::Conditional {
5688 branches,
5689 else_body,
5690 ..
5691 } => {
5692 for b in branches {
5693 collect_qrefs_from_expr(&b.condition, out);
5694 collect_qrefs_from_expr(&b.body, out);
5695 }
5696 if let Some(body) = else_body {
5697 collect_qrefs_from_expr(body, out);
5698 }
5699 }
5700 Expr::For {
5701 collection,
5702 filter,
5703 body,
5704 ..
5705 } => {
5706 collect_qrefs_from_collection(collection, out);
5707 if let Some(f) = filter {
5708 collect_qrefs_from_expr(f, out);
5709 }
5710 collect_qrefs_from_expr(body, out);
5711 }
5712 Expr::Lambda { body, .. } => {
5713 collect_qrefs_from_expr(body, out);
5714 }
5715 Expr::TransitionsTo {
5716 subject, new_state, ..
5717 }
5718 | Expr::Becomes {
5719 subject, new_state, ..
5720 } => {
5721 collect_qrefs_from_expr(subject, out);
5722 collect_qrefs_from_expr(new_state, out);
5723 }
5724 Expr::GenericType { name, args, .. } => {
5725 collect_qrefs_from_expr(name, out);
5726 for a in args {
5727 collect_qrefs_from_expr(a, out);
5728 }
5729 }
5730 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
5731 for e in elements {
5732 collect_qrefs_from_expr(e, out);
5733 }
5734 }
5735 Expr::ObjectLiteral { fields, .. } => {
5736 for f in fields {
5737 collect_qrefs_from_expr(&f.value, out);
5738 }
5739 }
5740 Expr::ProjectionMap { source, .. } => {
5741 collect_qrefs_from_expr(source, out);
5742 }
5743 _ => {}
5744 }
5745}
5746
5747fn collect_qrefs_from_collection<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
5755 match expr {
5756 Expr::QualifiedName(q) => {
5757 if let Some(qualifier) = &q.qualifier {
5758 out.push(QRef { qualifier, name: &q.name, span: q.span, collection: true });
5759 }
5760 }
5761 Expr::Where { source, condition, .. }
5764 | Expr::With {
5765 source,
5766 predicate: condition,
5767 ..
5768 } => {
5769 collect_qrefs_from_collection(source, out);
5770 collect_qrefs_from_expr(condition, out);
5771 }
5772 other => collect_qrefs_from_expr(other, out),
5775 }
5776}
5777
5778impl Ctx<'_> {
5783 fn check_deferred_location_hints(&mut self, source: &str) {
5784 for d in &self.module.declarations {
5785 let Decl::Deferred(def) = d else {
5786 continue;
5787 };
5788 const LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
5809 let bytes = source.as_bytes();
5810 let kw_start = def.span.start;
5811 let line_start = source[..kw_start]
5812 .rfind(LINE_TERMINATORS)
5813 .map_or(0, |i| {
5814 i + source[i..].chars().next().map_or(1, char::len_utf8)
5815 });
5816 let mut name_start = kw_start + "deferred".len();
5817 while bytes.get(name_start).is_some_and(u8::is_ascii_whitespace) {
5818 name_start += 1;
5819 }
5820 let starts_name =
5821 |b: &u8| b.is_ascii_alphabetic() || *b == b'_';
5822 let continues_name =
5823 |b: &u8| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.';
5824 if !bytes[line_start..kw_start]
5825 .iter()
5826 .all(|b| b.is_ascii_whitespace())
5827 || name_start == kw_start + "deferred".len()
5828 || !bytes.get(name_start).is_some_and(starts_name)
5829 {
5830 continue;
5834 }
5835 let mut name_end = name_start + 1;
5836 while bytes.get(name_end).is_some_and(continues_name) {
5837 name_end += 1;
5838 }
5839 let line_end = source[name_end..]
5840 .find(LINE_TERMINATORS)
5841 .map_or(source.len(), |i| name_end + i);
5842 let suffix = &source[name_end..line_end];
5843 if suffix.contains('"')
5844 || suffix.contains("http://")
5845 || suffix.contains("https://")
5846 || suffix.contains("-- see:")
5847 {
5848 continue;
5849 }
5850 self.push(
5851 Diagnostic::warning(
5852 def.span,
5853 format!(
5854 "Deferred specification '{}' should include a location hint.",
5855 &source[name_start..name_end],
5856 ),
5857 )
5858 .with_code("allium.deferred.missingLocationHint"),
5859 );
5860 }
5861 }
5862}
5863
5864impl Ctx<'_> {
5869 fn check_rule_invalid_triggers(&mut self) {
5870 for rule in self.blocks(BlockKind::Rule) {
5871 let rule_name = match &rule.name {
5872 Some(n) => &n.name,
5873 None => continue,
5874 };
5875
5876 for item in &rule.items {
5877 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5878 continue;
5879 };
5880 if keyword != "when" {
5881 continue;
5882 }
5883 if !is_valid_trigger(value) {
5884 self.push(
5885 Diagnostic::error(
5886 item.span,
5887 format!(
5888 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
5889 ),
5890 )
5891 .with_code("allium.rule.invalidTrigger"),
5892 );
5893 } else if let Some((param, span)) = first_named_trigger_param(value) {
5894 self.push(
5895 Diagnostic::error(
5896 span,
5897 format!(
5898 "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.",
5899 ),
5900 )
5901 .with_code("allium.rule.invalidTrigger"),
5902 );
5903 }
5904 }
5905 }
5906 }
5907}
5908
5909fn first_named_trigger_param(expr: &Expr) -> Option<(&str, Span)> {
5915 match expr {
5916 Expr::Call { args, .. } => args.iter().find_map(|arg| match arg {
5917 CallArg::Named(n) => Some((n.name.name.as_str(), n.span)),
5918 _ => None,
5919 }),
5920 Expr::LogicalOp {
5921 op: LogicalOp::Or,
5922 left,
5923 right,
5924 ..
5925 } => first_named_trigger_param(left).or_else(|| first_named_trigger_param(right)),
5926 _ => None,
5927 }
5928}
5929
5930fn literal_kind(expr: &Expr) -> Option<&'static str> {
5940 match expr {
5941 Expr::StringLiteral(_) => Some("string"),
5942 Expr::NumberLiteral { .. } => Some("number"),
5943 Expr::BoolLiteral { .. } => Some("boolean"),
5944 Expr::DurationLiteral { .. } => Some("duration"),
5945 Expr::BacktickLiteral { .. } => Some("backtick literal"),
5946 _ => None,
5947 }
5948}
5949
5950impl Ctx<'_> {
5951 fn check_list_literal_homogeneity(&mut self) {
5952 let mut lists: Vec<(&[Expr], Span)> = Vec::new();
5953 for d in &self.module.declarations {
5954 match d {
5955 Decl::Block(b) => {
5956 for item in &b.items {
5957 collect_list_literals_from_item(&item.kind, &mut lists);
5958 }
5959 }
5960 Decl::Variant(v) => {
5961 for item in &v.items {
5962 collect_list_literals_from_item(&item.kind, &mut lists);
5963 }
5964 }
5965 Decl::Invariant(inv) => collect_list_literals_from_expr(&inv.body, &mut lists),
5966 Decl::Default(def) => collect_list_literals_from_expr(&def.value, &mut lists),
5967 _ => {}
5968 }
5969 }
5970
5971 for (elements, span) in lists {
5972 let mut first: Option<&'static str> = None;
5976 for e in elements {
5977 let Some(kind) = literal_kind(e) else { continue };
5978 match first {
5979 None => first = Some(kind),
5980 Some(expected) if expected != kind => {
5981 self.push(
5982 Diagnostic::error(
5983 span,
5984 format!(
5985 "List literal has elements of differing types ('{expected}' and '{kind}'); all elements of a list must share a type.",
5986 ),
5987 )
5988 .with_code("allium.list.mixedElementTypes"),
5989 );
5990 break;
5991 }
5992 _ => {}
5993 }
5994 }
5995 }
5996 }
5997}
5998
5999impl Ctx<'_> {
6000
6001 fn check_undefined_import_aliases(&mut self) {
6010 let aliases: HashSet<&str> = self
6011 .module
6012 .declarations
6013 .iter()
6014 .filter_map(|d| match d {
6015 Decl::Use(u) => u.alias.as_ref().map(|a| a.name.as_str()),
6016 _ => None,
6017 })
6018 .collect();
6019
6020 let broken_alias_paths: HashMap<&str, &str> = match self.missing_use_paths {
6026 Some(missing) => self
6027 .module
6028 .declarations
6029 .iter()
6030 .filter_map(|d| match d {
6031 Decl::Use(u) => {
6032 let path = u.path.text();
6033 let alias = u.alias.as_ref()?;
6034 missing
6035 .get(&path)
6036 .map(|p| (alias.name.as_str(), p.as_str()))
6037 }
6038 _ => None,
6039 })
6040 .collect(),
6041 None => HashMap::new(),
6042 };
6043
6044 let mut refs = collect_qref_nodes(self.module);
6045 for d in &self.module.declarations {
6048 if let Decl::Default(def) = d {
6049 if let (Some(a), Some(t)) = (&def.type_alias, &def.type_name) {
6050 refs.push(QRef {
6051 qualifier: &a.name,
6052 name: &t.name,
6053 span: a.span.merge(t.span),
6054 collection: false,
6055 });
6056 }
6057 }
6058 }
6059
6060 for r in refs {
6061 if !aliases.contains(r.qualifier) {
6062 self.push(
6063 Diagnostic::error(
6064 r.span,
6065 format!(
6066 "Reference '{}/{}' uses unknown import alias '{}'.",
6067 r.qualifier, r.name, r.qualifier
6068 ),
6069 )
6070 .with_code("allium.reference.undefinedImportedAlias"),
6071 );
6072 } else if let Some(path) = broken_alias_paths.get(r.qualifier) {
6073 self.push(
6074 Diagnostic::warning(
6075 r.span,
6076 format!(
6077 "Reference '{}/{}' goes through use path \"{}\", which does not resolve to a file in the check set or on disk.",
6078 r.qualifier, r.name, path
6079 ),
6080 )
6081 .with_code("allium.reference.unresolvedImport"),
6082 );
6083 } else if let Some(offered) = self
6084 .imported_referenced_triggers
6085 .and_then(|m| m.get(r.qualifier))
6086 {
6087 if !r.collection && !offered.contains(r.name) {
6094 self.push(
6095 Diagnostic::warning(
6096 r.span,
6097 format!(
6098 "Reference '{}/{}' names '{}', which imported module '{}' does not define.",
6099 r.qualifier, r.name, r.name, r.qualifier
6100 ),
6101 )
6102 .with_code("allium.reference.unknownName"),
6103 );
6104 }
6105 }
6106 }
6107 }
6108
6109 fn check_default_field_schemas(&mut self) {
6120 let schemas = collect_local_type_schemas(self.module);
6121 let mut diagnostics = Vec::new();
6122 for d in &self.module.declarations {
6123 let Decl::Default(def) = d else { continue };
6124 let (Some(type_name), Expr::ObjectLiteral { fields, .. }) =
6125 (&def.type_name, &def.value)
6126 else {
6127 continue;
6128 };
6129 match &def.type_alias {
6130 None => {
6131 validate_object_literal(fields, &type_name.name, &schemas, &mut diagnostics);
6132 }
6133 Some(alias) => {
6134 if let Some(imported) = self
6142 .imported_entity_fields
6143 .and_then(|m| m.get(alias.name.as_str()))
6144 .and_then(|types| types.get(type_name.name.as_str()))
6145 {
6146 for field in fields {
6147 if !imported.contains(field.name.name.as_str()) {
6148 diagnostics.push(
6149 Diagnostic::error(
6150 field.name.span,
6151 format!(
6152 "Default sets field '{}' which is not declared on '{}/{}'.",
6153 field.name.name, alias.name, type_name.name
6154 ),
6155 )
6156 .with_code("allium.default.unknownField"),
6157 );
6158 }
6159 }
6160 }
6161 }
6162 }
6163 }
6164 for diag in diagnostics {
6165 self.push(diag);
6166 }
6167 }
6168}
6169
6170fn collect_local_type_schemas(module: &Module) -> HashMap<&str, HashMap<&str, &Expr>> {
6172 let mut schemas: HashMap<&str, HashMap<&str, &Expr>> = HashMap::new();
6173 for d in &module.declarations {
6174 let Decl::Block(b) = d else { continue };
6175 if !matches!(
6176 b.kind,
6177 BlockKind::Entity | BlockKind::ExternalEntity | BlockKind::Value
6178 ) {
6179 continue;
6180 }
6181 let Some(name) = &b.name else { continue };
6182 let mut fields: HashMap<&str, &Expr> = HashMap::new();
6183 for item in &b.items {
6184 match &item.kind {
6185 BlockItemKind::Assignment { name: f, value }
6186 | BlockItemKind::FieldWithWhen { name: f, value, .. } => {
6187 fields.insert(f.name.as_str(), value);
6188 }
6189 _ => {}
6190 }
6191 }
6192 schemas.insert(name.name.as_str(), fields);
6193 }
6194 schemas
6195}
6196
6197fn is_list_type(expr: &Expr) -> bool {
6200 match expr {
6201 Expr::GenericType { name, .. } => matches!(name.as_ref(), Expr::Ident(id) if id.name == "List"),
6202 Expr::TypeOptional { inner, .. } => is_list_type(inner),
6203 _ => false,
6204 }
6205}
6206
6207fn base_type_name(expr: &Expr) -> Option<&str> {
6211 match expr {
6212 Expr::Ident(id) => Some(id.name.as_str()),
6213 Expr::TypeOptional { inner, .. } => base_type_name(inner),
6214 _ => None,
6215 }
6216}
6217
6218fn validate_object_literal<'a>(
6219 fields: &'a [NamedArg],
6220 type_name: &str,
6221 schemas: &HashMap<&'a str, HashMap<&'a str, &'a Expr>>,
6222 out: &mut Vec<Diagnostic>,
6223) {
6224 let Some(schema) = schemas.get(type_name) else { return };
6227 for field in fields {
6228 let Some(field_type) = schema.get(field.name.name.as_str()) else {
6229 out.push(
6230 Diagnostic::error(
6231 field.name.span,
6232 format!(
6233 "Default sets field '{}' which is not declared on '{}'.",
6234 field.name.name, type_name
6235 ),
6236 )
6237 .with_code("allium.default.unknownField"),
6238 );
6239 continue;
6240 };
6241 if let Expr::ListLiteral { elements, span } = &field.value {
6244 if elements.is_empty() && !is_list_type(field_type) {
6245 out.push(
6246 Diagnostic::error(
6247 *span,
6248 format!(
6249 "Empty list literal has no inferable element type: target field '{}' is not a List<T>.",
6250 field.name.name
6251 ),
6252 )
6253 .with_code("allium.list.emptyListNoElementType"),
6254 );
6255 }
6256 }
6257 if let Expr::ObjectLiteral { fields: nested, .. } = &field.value {
6259 if let Some(nested_type) = base_type_name(field_type) {
6260 validate_object_literal(nested, nested_type, schemas, out);
6261 }
6262 }
6263 }
6264}
6265
6266fn collect_list_literals_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<(&'a [Expr], Span)>) {
6267 match kind {
6268 BlockItemKind::Clause { value, .. }
6269 | BlockItemKind::Assignment { value, .. }
6270 | BlockItemKind::ParamAssignment { value, .. }
6271 | BlockItemKind::Let { value, .. }
6272 | BlockItemKind::PathAssignment { value, .. }
6273 | BlockItemKind::InvariantBlock { body: value, .. }
6274 | BlockItemKind::FieldWithWhen { value, .. } => {
6275 collect_list_literals_from_expr(value, out);
6276 }
6277 BlockItemKind::ForBlock { collection, filter, items, .. } => {
6278 collect_list_literals_from_expr(collection, out);
6279 if let Some(f) = filter {
6280 collect_list_literals_from_expr(f, out);
6281 }
6282 for item in items {
6283 collect_list_literals_from_item(&item.kind, out);
6284 }
6285 }
6286 BlockItemKind::IfBlock { branches, else_items } => {
6287 for b in branches {
6288 collect_list_literals_from_expr(&b.condition, out);
6289 for item in &b.items {
6290 collect_list_literals_from_item(&item.kind, out);
6291 }
6292 }
6293 if let Some(items) = else_items {
6294 for item in items {
6295 collect_list_literals_from_item(&item.kind, out);
6296 }
6297 }
6298 }
6299 _ => {}
6300 }
6301}
6302
6303fn collect_list_literals_from_expr<'a>(expr: &'a Expr, out: &mut Vec<(&'a [Expr], Span)>) {
6304 if let Expr::ListLiteral { elements, span } = expr {
6305 out.push((elements, *span));
6306 }
6307 walk_expr_children(expr, &mut |child| collect_list_literals_from_expr(child, out));
6308}
6309
6310fn walk_expr_children<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) {
6314 match expr {
6315 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => f(object),
6316 Expr::Call { function, args, .. } => {
6317 f(function);
6318 for a in args {
6319 match a {
6320 CallArg::Positional(e) => f(e),
6321 CallArg::Named(n) => f(&n.value),
6322 }
6323 }
6324 }
6325 Expr::BinaryOp { left, right, .. }
6326 | Expr::Comparison { left, right, .. }
6327 | Expr::LogicalOp { left, right, .. }
6328 | Expr::Pipe { left, right, .. }
6329 | Expr::NullCoalesce { left, right, .. } => {
6330 f(left);
6331 f(right);
6332 }
6333 Expr::Not { operand, .. }
6334 | Expr::Exists { operand, .. }
6335 | Expr::NotExists { operand, .. }
6336 | Expr::TypeOptional { inner: operand, .. } => f(operand),
6337 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6338 f(element);
6339 f(collection);
6340 }
6341 Expr::Where { source, condition, .. }
6342 | Expr::With { source, predicate: condition, .. } => {
6343 f(source);
6344 f(condition);
6345 }
6346 Expr::WhenGuard { action, condition, .. } => {
6347 f(action);
6348 f(condition);
6349 }
6350 Expr::Block { items, .. } => {
6351 for item in items {
6352 f(item);
6353 }
6354 }
6355 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => f(value),
6356 Expr::Conditional { branches, else_body, .. } => {
6357 for b in branches {
6358 f(&b.condition);
6359 f(&b.body);
6360 }
6361 if let Some(body) = else_body {
6362 f(body);
6363 }
6364 }
6365 Expr::For { collection, filter, body, .. } => {
6366 f(collection);
6367 if let Some(filt) = filter {
6368 f(filt);
6369 }
6370 f(body);
6371 }
6372 Expr::Lambda { body, .. } => f(body),
6373 Expr::JoinLookup { entity, fields, .. } => {
6374 f(entity);
6375 for jf in fields {
6376 if let Some(v) = &jf.value {
6377 f(v);
6378 }
6379 }
6380 }
6381 Expr::TransitionsTo { subject, new_state, .. }
6382 | Expr::Becomes { subject, new_state, .. } => {
6383 f(subject);
6384 f(new_state);
6385 }
6386 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
6387 for e in elements {
6388 f(e);
6389 }
6390 }
6391 Expr::ObjectLiteral { fields, .. } => {
6392 for fld in fields {
6393 f(&fld.value);
6394 }
6395 }
6396 Expr::GenericType { name, args, .. } => {
6397 f(name);
6398 for a in args {
6399 f(a);
6400 }
6401 }
6402 Expr::ProjectionMap { source, .. } => f(source),
6403 _ => {}
6404 }
6405}
6406
6407fn is_valid_trigger(expr: &Expr) -> bool {
6408 match expr {
6409 Expr::Call { function, .. } => {
6415 matches!(
6416 function.as_ref(),
6417 Expr::Ident(_) | Expr::MemberAccess { .. } | Expr::QualifiedName(_)
6418 )
6419 }
6420 Expr::Binding { value, .. } => {
6422 matches!(
6423 value.as_ref(),
6424 Expr::Becomes { .. }
6425 | Expr::TransitionsTo { .. }
6426 | Expr::MemberAccess { .. }
6427 | Expr::Comparison { .. }
6428 )
6429 }
6430 Expr::LogicalOp {
6432 op: LogicalOp::Or,
6433 left,
6434 right,
6435 ..
6436 } => is_valid_trigger(left) && is_valid_trigger(right),
6437 Expr::Comparison { left, .. } => {
6439 matches!(left.as_ref(), Expr::MemberAccess { .. })
6440 }
6441 _ => false,
6442 }
6443}
6444
6445impl Ctx<'_> {
6450 fn check_rule_undefined_bindings(&mut self) {
6451 let mut given_bindings: HashSet<&str> = HashSet::new();
6453 for given in self.blocks(BlockKind::Given) {
6454 for item in &given.items {
6455 if let BlockItemKind::Assignment { name, .. } = &item.kind {
6456 given_bindings.insert(&name.name);
6457 }
6458 }
6459 }
6460
6461 let mut default_names: HashSet<&str> = HashSet::new();
6463 for d in &self.module.declarations {
6464 if let Decl::Default(def) = d {
6465 default_names.insert(&def.name.name);
6466 }
6467 }
6468
6469 for rule in self.blocks(BlockKind::Rule) {
6470 let rule_name = match &rule.name {
6471 Some(n) => &n.name,
6472 None => continue,
6473 };
6474
6475 let mut bound: HashSet<&str> = HashSet::new();
6476 bound.extend(&given_bindings);
6477 bound.extend(&default_names);
6478
6479 for item in &rule.items {
6481 let BlockItemKind::Clause { keyword, value } = &item.kind else {
6482 continue;
6483 };
6484 if keyword != "when" {
6485 continue;
6486 }
6487 collect_bound_names(value, &mut bound);
6488 }
6489
6490 check_unbound_in_items(&rule.items, &bound, rule_name, &mut self.diagnostics);
6497
6498 for item in &rule.items {
6502 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
6503 if keyword != "when" { continue }
6504 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
6505 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
6506 continue;
6507 }
6508 let mut found = false;
6517 for check_item in &rule.items {
6518 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
6519 if kw != "requires" && kw != "ensures" { continue }
6520 if expr_contains_ident(v, &binding_name.name) {
6521 self.push(
6522 Diagnostic::error(
6523 check_item.span,
6524 format!(
6525 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
6526 binding_name.name
6527 ),
6528 )
6529 .with_code("allium.rule.undefinedBinding"),
6530 );
6531 found = true;
6532 break;
6533 }
6534 }
6535 if found { break; }
6536 }
6537 }
6538 }
6539}
6540
6541fn check_unbound_in_items<'a>(
6547 items: &'a [BlockItem],
6548 parent_bound: &HashSet<&'a str>,
6549 rule_name: &str,
6550 diagnostics: &mut Vec<Diagnostic>,
6551) {
6552 let mut bound: HashSet<&'a str> = parent_bound.clone();
6553 for item in items {
6554 if let BlockItemKind::Let { name, .. } = &item.kind {
6555 bound.insert(&name.name);
6556 }
6557 }
6558 for item in items {
6559 match &item.kind {
6560 BlockItemKind::Clause { keyword, value } => {
6561 if keyword == "requires" || keyword == "ensures" {
6562 check_unbound_roots(value, &bound, rule_name, diagnostics);
6563 }
6564 }
6565 BlockItemKind::IfBlock { branches, else_items } => {
6566 for b in branches {
6567 check_unbound_in_items(&b.items, &bound, rule_name, diagnostics);
6568 }
6569 if let Some(else_items) = else_items {
6570 check_unbound_in_items(else_items, &bound, rule_name, diagnostics);
6571 }
6572 }
6573 BlockItemKind::ForBlock { binding, items: for_items, .. } => {
6574 let mut inner = bound.clone();
6575 match binding {
6576 ForBinding::Single(id) => {
6577 inner.insert(&id.name);
6578 }
6579 ForBinding::Destructured(ids, _) => {
6580 for id in ids {
6581 inner.insert(&id.name);
6582 }
6583 }
6584 }
6585 check_unbound_in_items(for_items, &inner, rule_name, diagnostics);
6586 }
6587 _ => {}
6588 }
6589 }
6590}
6591
6592fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
6593 match expr {
6594 Expr::Binding { name, .. } => {
6595 out.insert(&name.name);
6596 }
6597 Expr::Call { args, .. } => {
6598 for arg in args {
6599 match arg {
6600 CallArg::Positional(Expr::Ident(id)) => {
6601 out.insert(&id.name);
6602 }
6603 CallArg::Named(n) => {
6609 out.insert(&n.name.name);
6610 }
6611 _ => {}
6612 }
6613 }
6614 }
6615 Expr::LogicalOp { left, right, .. } => {
6616 collect_bound_names(left, out);
6617 collect_bound_names(right, out);
6618 }
6619 _ => {}
6620 }
6621}
6622
6623fn check_unbound_roots(
6624 expr: &Expr,
6625 bound: &HashSet<&str>,
6626 rule_name: &str,
6627 diagnostics: &mut Vec<Diagnostic>,
6628) {
6629 match expr {
6630 Expr::MemberAccess { object, .. } => {
6631 if let Expr::Ident(id) = object.as_ref() {
6632 if !starts_uppercase(&id.name)
6633 && !bound.contains(id.name.as_str())
6634 && !is_builtin_name(&id.name)
6635 {
6636 diagnostics.push(
6637 Diagnostic::error(
6638 id.span,
6639 format!(
6640 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
6641 id.name
6642 ),
6643 )
6644 .with_code("allium.rule.undefinedBinding"),
6645 );
6646 }
6647 }
6648 }
6649 Expr::Comparison { left, right, .. } => {
6650 check_unbound_roots(left, bound, rule_name, diagnostics);
6651 check_unbound_roots(right, bound, rule_name, diagnostics);
6652 }
6653 Expr::LogicalOp { left, right, .. } => {
6654 check_unbound_roots(left, bound, rule_name, diagnostics);
6655 check_unbound_roots(right, bound, rule_name, diagnostics);
6656 }
6657 Expr::Block { items, .. } => {
6658 let mut block_bound = bound.clone();
6659 for item in items {
6660 if let Expr::LetExpr { name, value, .. } = item {
6661 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
6662 block_bound.insert(name.name.as_str());
6663 } else {
6664 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
6665 }
6666 }
6667 }
6668 Expr::For { binding, collection, body, .. } => {
6669 check_unbound_roots(collection, bound, rule_name, diagnostics);
6670 let mut inner = bound.clone();
6672 match binding {
6673 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
6674 ForBinding::Destructured(ids, _) => {
6675 for id in ids {
6676 inner.insert(id.name.as_str());
6677 }
6678 }
6679 }
6680 check_unbound_roots(body, &inner, rule_name, diagnostics);
6681 }
6682 Expr::BinaryOp { left, right, .. } => {
6683 check_unbound_roots(left, bound, rule_name, diagnostics);
6684 check_unbound_roots(right, bound, rule_name, diagnostics);
6685 }
6686 Expr::Call { function, args, .. } => {
6687 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
6689 check_unbound_roots(function, bound, rule_name, diagnostics);
6690 }
6691 let mut call_bound = bound.clone();
6693 for a in args {
6694 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
6695 if let Expr::Ident(id) = param.as_ref() {
6696 call_bound.insert(id.name.as_str());
6697 }
6698 }
6699 }
6700 for a in args {
6701 match a {
6702 CallArg::Positional(Expr::Lambda { body, .. }) => {
6703 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
6704 }
6705 CallArg::Positional(e) => {
6706 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
6707 }
6708 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
6709 }
6710 }
6711 }
6712 Expr::Not { operand, .. }
6713 | Expr::Exists { operand, .. }
6714 | Expr::NotExists { operand, .. } => {
6715 check_unbound_roots(operand, bound, rule_name, diagnostics);
6716 }
6717 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6718 check_unbound_roots(element, bound, rule_name, diagnostics);
6719 check_unbound_roots(collection, bound, rule_name, diagnostics);
6720 }
6721 Expr::Conditional { branches, else_body, .. } => {
6722 for b in branches {
6723 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
6724 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
6725 }
6726 if let Some(body) = else_body {
6727 check_unbound_roots(body, bound, rule_name, diagnostics);
6728 }
6729 }
6730 _ => {}
6731 }
6732}
6733
6734fn is_builtin_name(name: &str) -> bool {
6735 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
6736}
6737
6738impl Ctx<'_> {
6743 fn check_duplicate_let_bindings(&mut self) {
6744 for rule in self.blocks(BlockKind::Rule) {
6745 let mut seen: HashMap<&str, Span> = HashMap::new();
6746 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
6747 }
6748 }
6749
6750 fn check_duplicate_lets_in_items<'b>(
6751 &mut self,
6752 items: &'b [BlockItem],
6753 seen: &mut HashMap<&'b str, Span>,
6754 ) {
6755 for item in items {
6756 match &item.kind {
6757 BlockItemKind::Let { name, .. } => {
6758 if seen.contains_key(name.name.as_str()) {
6759 self.push(
6760 Diagnostic::error(
6761 name.span,
6762 format!("Duplicate let binding '{}' in this rule.", name.name),
6763 )
6764 .with_code("allium.let.duplicateBinding"),
6765 );
6766 } else {
6767 seen.insert(&name.name, name.span);
6768 }
6769 }
6770 BlockItemKind::ForBlock { items, .. } => {
6771 self.check_duplicate_lets_in_items(items, seen);
6772 }
6773 BlockItemKind::IfBlock {
6774 branches,
6775 else_items,
6776 } => {
6777 for b in branches {
6778 self.check_duplicate_lets_in_items(&b.items, seen);
6779 }
6780 if let Some(items) = else_items {
6781 self.check_duplicate_lets_in_items(items, seen);
6782 }
6783 }
6784 BlockItemKind::Clause { value, .. } => {
6785 self.check_duplicate_lets_in_expr(value, seen);
6786 }
6787 _ => {}
6788 }
6789 }
6790 }
6791
6792 fn check_duplicate_lets_in_expr<'b>(
6793 &mut self,
6794 expr: &'b Expr,
6795 seen: &mut HashMap<&'b str, Span>,
6796 ) {
6797 match expr {
6798 Expr::LetExpr { name, value, .. } => {
6799 if seen.contains_key(name.name.as_str()) {
6800 self.push(
6801 Diagnostic::error(
6802 name.span,
6803 format!("Duplicate let binding '{}' in this rule.", name.name),
6804 )
6805 .with_code("allium.let.duplicateBinding"),
6806 );
6807 } else {
6808 seen.insert(&name.name, name.span);
6809 }
6810 self.check_duplicate_lets_in_expr(value, seen);
6811 }
6812 Expr::Block { items, .. } => {
6813 for item in items {
6814 self.check_duplicate_lets_in_expr(item, seen);
6815 }
6816 }
6817 Expr::For { body, .. } => {
6818 self.check_duplicate_lets_in_expr(body, seen);
6819 }
6820 Expr::Conditional { branches, else_body, .. } => {
6821 for b in branches {
6822 self.check_duplicate_lets_in_expr(&b.body, seen);
6823 }
6824 if let Some(body) = else_body {
6825 self.check_duplicate_lets_in_expr(body, seen);
6826 }
6827 }
6828 _ => {}
6829 }
6830 }
6831}
6832
6833impl Ctx<'_> {
6838 fn check_config_undefined_references(&mut self) {
6839 let mut config_params: HashSet<&str> = HashSet::new();
6840 for config in self.blocks(BlockKind::Config) {
6841 for item in &config.items {
6842 if let BlockItemKind::Assignment { name, .. } = &item.kind {
6843 config_params.insert(&name.name);
6844 }
6845 }
6846 }
6847
6848 for d in &self.module.declarations {
6850 match d {
6851 Decl::Block(b) => {
6852 if b.kind == BlockKind::Config {
6853 continue;
6854 }
6855 for item in &b.items {
6856 self.check_config_refs_in_item(&item.kind, &config_params);
6857 }
6858 }
6859 Decl::Invariant(inv) => {
6860 self.check_config_refs_in_expr(&inv.body, &config_params);
6861 }
6862 _ => {}
6863 }
6864 }
6865 }
6866
6867 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
6868 match kind {
6869 BlockItemKind::Clause { value, .. }
6870 | BlockItemKind::Assignment { value, .. }
6871 | BlockItemKind::ParamAssignment { value, .. }
6872 | BlockItemKind::Let { value, .. }
6873 | BlockItemKind::FieldWithWhen { value, .. } => {
6874 self.check_config_refs_in_expr(value, params);
6875 }
6876 BlockItemKind::ForBlock { collection, filter, items, .. } => {
6877 self.check_config_refs_in_expr(collection, params);
6878 if let Some(f) = filter {
6879 self.check_config_refs_in_expr(f, params);
6880 }
6881 for item in items {
6882 self.check_config_refs_in_item(&item.kind, params);
6883 }
6884 }
6885 BlockItemKind::IfBlock { branches, else_items } => {
6886 for b in branches {
6887 self.check_config_refs_in_expr(&b.condition, params);
6888 for item in &b.items {
6889 self.check_config_refs_in_item(&item.kind, params);
6890 }
6891 }
6892 if let Some(items) = else_items {
6893 for item in items {
6894 self.check_config_refs_in_item(&item.kind, params);
6895 }
6896 }
6897 }
6898 _ => {}
6899 }
6900 }
6901
6902 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
6903 match expr {
6904 Expr::MemberAccess { object, field, .. } => {
6905 if let Expr::Ident(id) = object.as_ref() {
6906 if id.name == "config" && !params.contains(field.name.as_str()) {
6907 self.push(
6908 Diagnostic::warning(
6909 field.span,
6910 format!(
6911 "Config reference 'config.{}' is not declared in any config block.",
6912 field.name
6913 ),
6914 )
6915 .with_code("allium.config.undefinedReference"),
6916 );
6917 return;
6918 }
6919 }
6920 self.check_config_refs_in_expr(object, params);
6921 }
6922 Expr::Call { function, args, .. } => {
6923 self.check_config_refs_in_expr(function, params);
6924 for a in args {
6925 match a {
6926 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
6927 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
6928 }
6929 }
6930 }
6931 Expr::BinaryOp { left, right, .. }
6932 | Expr::Comparison { left, right, .. }
6933 | Expr::LogicalOp { left, right, .. }
6934 | Expr::Pipe { left, right, .. }
6935 | Expr::NullCoalesce { left, right, .. } => {
6936 self.check_config_refs_in_expr(left, params);
6937 self.check_config_refs_in_expr(right, params);
6938 }
6939 Expr::Not { operand, .. }
6940 | Expr::Exists { operand, .. }
6941 | Expr::NotExists { operand, .. } => {
6942 self.check_config_refs_in_expr(operand, params);
6943 }
6944 Expr::Block { items, .. } => {
6945 for item in items {
6946 self.check_config_refs_in_expr(item, params);
6947 }
6948 }
6949 Expr::Conditional { branches, else_body, .. } => {
6950 for b in branches {
6951 self.check_config_refs_in_expr(&b.condition, params);
6952 self.check_config_refs_in_expr(&b.body, params);
6953 }
6954 if let Some(body) = else_body {
6955 self.check_config_refs_in_expr(body, params);
6956 }
6957 }
6958 Expr::For { collection, filter, body, .. } => {
6959 self.check_config_refs_in_expr(collection, params);
6960 if let Some(f) = filter {
6961 self.check_config_refs_in_expr(f, params);
6962 }
6963 self.check_config_refs_in_expr(body, params);
6964 }
6965 Expr::LetExpr { value, .. } => {
6966 self.check_config_refs_in_expr(value, params);
6967 }
6968 Expr::Lambda { body, .. } => {
6969 self.check_config_refs_in_expr(body, params);
6970 }
6971 _ => {}
6972 }
6973 }
6974}
6975
6976fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
6981 match kind {
6982 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
6983 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
6984 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
6985 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
6986 BlockItemKind::ForBlock {
6987 collection,
6988 filter,
6989 items,
6990 ..
6991 } => {
6992 expr_contains_ident(collection, name)
6993 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
6994 || items.iter().any(|i| item_contains_ident(&i.kind, name))
6995 }
6996 BlockItemKind::IfBlock {
6997 branches,
6998 else_items,
6999 } => {
7000 branches.iter().any(|b| {
7001 expr_contains_ident(&b.condition, name)
7002 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
7003 }) || else_items
7004 .as_ref()
7005 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
7006 }
7007 BlockItemKind::PathAssignment { path, value } => {
7008 expr_contains_ident(path, name) || expr_contains_ident(value, name)
7009 }
7010 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
7011 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
7012 BlockItemKind::ContractsClause { .. }
7013 | BlockItemKind::EnumVariant { .. }
7014 | BlockItemKind::OpenQuestion { .. }
7015 | BlockItemKind::Annotation(_)
7016 | BlockItemKind::TransitionsBlock(_) => false,
7017 }
7018}
7019
7020fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
7021 match expr {
7022 Expr::Ident(id) => id.name == name,
7023 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
7024 expr_contains_ident(object, name)
7025 }
7026 Expr::Call { function, args, .. } => {
7027 expr_contains_ident(function, name)
7028 || args.iter().any(|a| match a {
7029 CallArg::Positional(e) => expr_contains_ident(e, name),
7030 CallArg::Named(n) => expr_contains_ident(&n.value, name),
7031 })
7032 }
7033 Expr::JoinLookup { entity, fields, .. } => {
7034 expr_contains_ident(entity, name)
7035 || fields
7036 .iter()
7037 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
7038 }
7039 Expr::BinaryOp { left, right, .. }
7040 | Expr::Comparison { left, right, .. }
7041 | Expr::LogicalOp { left, right, .. }
7042 | Expr::Pipe { left, right, .. }
7043 | Expr::NullCoalesce { left, right, .. } => {
7044 expr_contains_ident(left, name) || expr_contains_ident(right, name)
7045 }
7046 Expr::Not { operand, .. }
7047 | Expr::Exists { operand, .. }
7048 | Expr::NotExists { operand, .. }
7049 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
7050 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
7051 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
7052 }
7053 Expr::Where {
7054 source, condition, ..
7055 }
7056 | Expr::With {
7057 source,
7058 predicate: condition,
7059 ..
7060 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
7061 Expr::WhenGuard {
7062 action, condition, ..
7063 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
7064 Expr::Lambda { param, body, .. } => {
7065 expr_contains_ident(param, name) || expr_contains_ident(body, name)
7066 }
7067 Expr::Binding { name: n, value, .. } => {
7068 n.name == name || expr_contains_ident(value, name)
7069 }
7070 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
7071 elements.iter().any(|e| expr_contains_ident(e, name))
7072 }
7073 Expr::ObjectLiteral { fields, .. } => {
7074 fields.iter().any(|f| expr_contains_ident(&f.value, name))
7075 }
7076 Expr::GenericType { name: n, args, .. } => {
7077 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
7078 }
7079 Expr::Conditional {
7080 branches,
7081 else_body,
7082 ..
7083 } => {
7084 branches.iter().any(|b| {
7085 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
7086 }) || else_body
7087 .as_ref()
7088 .is_some_and(|e| expr_contains_ident(e, name))
7089 }
7090 Expr::For {
7091 collection,
7092 filter,
7093 body,
7094 ..
7095 } => {
7096 expr_contains_ident(collection, name)
7097 || filter
7098 .as_ref()
7099 .is_some_and(|f| expr_contains_ident(f, name))
7100 || expr_contains_ident(body, name)
7101 }
7102 Expr::TransitionsTo {
7103 subject, new_state, ..
7104 }
7105 | Expr::Becomes {
7106 subject, new_state, ..
7107 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
7108 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
7109 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
7110 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
7111 Expr::QualifiedName(_)
7112 | Expr::StringLiteral(_)
7113 | Expr::BacktickLiteral { .. }
7114 | Expr::NumberLiteral { .. }
7115 | Expr::BoolLiteral { .. }
7116 | Expr::Null { .. }
7117 | Expr::Now { .. }
7118 | Expr::This { .. }
7119 | Expr::Within { .. }
7120 | Expr::DurationLiteral { .. } => false,
7121 }
7122}
7123
7124#[cfg(test)]
7129mod tests {
7130 use super::*;
7131 use crate::diagnostic::Severity;
7132 use crate::parser::parse;
7133
7134 fn analyze_src(src: &str) -> Vec<Diagnostic> {
7135 let input = if src.starts_with("-- allium:") {
7136 src.to_string()
7137 } else {
7138 format!("-- allium: 3\n{src}")
7139 };
7140 let result = parse(&input);
7141 analyze(&result.module, &input)
7142 }
7143
7144 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
7145 diagnostics.iter().any(|d| d.code == Some(code))
7146 }
7147
7148 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
7149 diagnostics.iter().filter(|d| d.code == Some(code)).count()
7150 }
7151
7152 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
7153 let input = if src.starts_with("-- allium:") {
7154 src.to_string()
7155 } else {
7156 format!("-- allium: 3\n{src}")
7157 };
7158 let result = parse(&input);
7159 analyse(&result.module, &input)
7160 }
7161
7162 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
7163 result.findings.iter().any(|f| f["type"] == finding_type)
7164 }
7165
7166 #[test]
7169 fn suppression_on_previous_line() {
7170 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
7171 assert!(!has_code(&ds, "allium.field.unused"));
7172 }
7173
7174 #[test]
7175 fn suppression_all() {
7176 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
7177 assert!(!has_code(&ds, "allium.field.unused"));
7178 }
7179
7180 #[test]
7183 fn related_clause_with_binding_and_guard() {
7184 let ds = analyze_src(
7185 "surface QuoteVersions {\n facing user: User\n}\n\n\
7186 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
7187 );
7188 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
7189 }
7190
7191 #[test]
7192 fn related_clause_reports_unknown_surface() {
7193 let ds = analyze_src(
7194 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
7195 );
7196 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
7197 }
7198
7199 #[test]
7202 fn v1_capitalised_inline_enum() {
7203 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
7204 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
7205 }
7206
7207 #[test]
7210 fn discard_binding_no_warning() {
7211 let ds = analyze_src(
7212 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
7213 );
7214 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
7215 }
7216
7217 #[test]
7220 fn variable_status_assignment_suppresses_unreachable() {
7221 let ds = analyze_src(
7222 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
7223 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
7224 ensures: update.status = new_status\n}\n",
7225 );
7226 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7227 assert!(!has_code(&ds, "allium.status.noExit"));
7228 }
7229
7230 #[test]
7231 fn surface_param_types_disambiguate_shared_status_values() {
7232 let ds = analyze_src(
7235 "entity Account {\n status: active | suspended\n}\n\n\
7236 entity Subscription {\n status: active | expired | cancelled\n}\n\n\
7237 surface AccountAdmin {\n facing admin: Admin\n provides:\n \
7238 SuspendAccount(admin, account: Account)\n when account.status = active\n \
7239 ReinstateAccount(admin, account: Account)\n when account.status = suspended\n}\n\n\
7240 surface SubscriptionAdmin {\n facing admin: Admin\n provides:\n \
7241 CancelSubscription(admin, sub: Subscription)\n when sub.status = active\n \
7242 RenewSubscription(admin, sub: Subscription)\n when sub.status != active\n \
7243 ExpireSubscription(admin, sub: Subscription)\n when sub.status = active\n}\n\n\
7244 rule AccountSuspended {\n when: SuspendAccount(admin, account)\n \
7245 requires: account.status = active\n ensures: account.status = suspended\n}\n\n\
7246 rule AccountReinstated {\n when: ReinstateAccount(admin, account)\n \
7247 requires: account.status = suspended\n ensures: account.status = active\n}\n\n\
7248 rule SubscriptionCancelled {\n when: CancelSubscription(admin, sub)\n \
7249 requires: sub.status = active\n ensures: sub.status = cancelled\n}\n\n\
7250 rule SubscriptionExpired {\n when: ExpireSubscription(admin, sub)\n \
7251 requires: sub.status = active\n ensures: sub.status = expired\n}\n\n\
7252 rule SubscriptionRenewed {\n when: RenewSubscription(admin, sub)\n \
7253 requires: sub.status != active\n ensures: sub.status = active\n}\n",
7254 );
7255 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7256 assert!(!has_code(&ds, "allium.status.noExit"));
7257 }
7258
7259 #[test]
7260 fn negated_requires_counts_as_exit_for_complement_values() {
7261 let ds = analyze_src(
7264 "entity Order {\n status: draft | submitted | approved | rejected\n}\n\n\
7265 rule OrderSubmitted {\n when: SubmitOrder(clerk, order)\n \
7266 requires: order.status = draft\n ensures: order.status = submitted\n}\n\n\
7267 rule OrderApproved {\n when: ApproveOrder(clerk, order)\n \
7268 requires: order.status = submitted\n ensures: order.status = approved\n}\n\n\
7269 rule OrderRejected {\n when: RejectOrder(clerk, order)\n \
7270 requires: order.status = submitted\n ensures: order.status = rejected\n}\n\n\
7271 rule OrderReactivated {\n when: ReactivateOrder(clerk, order)\n \
7272 requires: order.status != draft\n ensures: order.status = draft\n}\n",
7273 );
7274 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7275 assert!(!has_code(&ds, "allium.status.noExit"));
7276 }
7277
7278 #[test]
7281 fn created_with_status_suppresses_unreachable() {
7282 let ds = analyze_src(
7283 "entity Order {\n status: pending | confirmed\n customer: String\n \
7284 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
7285 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
7286 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
7287 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
7288 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
7289 );
7290 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7291 }
7292
7293 #[test]
7294 fn created_omitting_status_warns() {
7295 let ds = analyze_src(
7296 "entity Order {\n status: pending | confirmed\n customer: String\n \
7297 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
7298 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
7299 Order.created(\n customer: customer\n )\n}\n",
7300 );
7301 assert!(has_code(&ds, "allium.created.missingStatus"));
7302 }
7303
7304 #[test]
7305 fn created_multiple_initial_statuses() {
7306 let ds = analyze_src(
7307 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
7308 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
7309 terminal: reviewed\n }\n}\n\n\
7310 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
7311 Proposal.created(status: draft, author: author)\n}\n\n\
7312 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
7313 Proposal.created(status: submitted, author: author)\n}\n\n\
7314 rule Review {\n when: ReviewerReviews(proposal)\n \
7315 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
7316 );
7317 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7319 }
7320
7321 #[test]
7322 fn created_invalid_status_errors() {
7323 let ds = analyze_src(
7324 "entity Task {\n status: open | in_progress | done\n title: String\n \
7325 transitions status {\n open -> in_progress\n in_progress -> done\n \
7326 terminal: done\n }\n}\n\n\
7327 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
7328 Task.created(status: archived, title: title)\n}\n",
7329 );
7330 assert!(has_code(&ds, "allium.created.invalidStatus"));
7331 }
7332
7333 #[test]
7334 fn created_without_transitions_no_missing_status_warning() {
7335 let ds = analyze_src(
7337 "entity Note {\n status: draft | published\n content: String\n}\n\n\
7338 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
7339 Note.created(content: content)\n}\n",
7340 );
7341 assert!(!has_code(&ds, "allium.created.missingStatus"));
7342 }
7343
7344 #[test]
7347 fn terminal_declared_suppresses_no_exit() {
7348 let ds = analyze_src(
7349 "entity Subscription {\n status: active | paused | completed | cancelled\n \
7350 transitions status {\n active -> paused\n paused -> active\n \
7351 active -> completed\n active -> cancelled\n paused -> cancelled\n \
7352 terminal: completed, cancelled\n }\n}\n\n\
7353 rule Activate {\n when: UserActivates(user, subscription)\n \
7354 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
7355 rule Pause {\n when: UserPauses(user, subscription)\n \
7356 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
7357 rule Complete {\n when: PeriodEnds(subscription)\n \
7358 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
7359 rule Cancel {\n when: UserCancels(user, subscription)\n \
7360 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
7361 );
7362 assert!(!has_code(&ds, "allium.status.noExit"));
7363 }
7364
7365 #[test]
7366 fn non_terminal_no_exit_still_warns() {
7367 let ds = analyze_src(
7368 "entity Ticket {\n status: open | stuck | resolved\n \
7369 transitions status {\n open -> stuck\n open -> resolved\n \
7370 terminal: resolved\n }\n}\n\n\
7371 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
7372 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
7373 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
7374 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
7375 );
7376 assert!(has_code(&ds, "allium.status.noExit"));
7378 }
7379
7380 #[test]
7383 fn cross_entity_trigger_param_recognised() {
7384 let ds = analyze_src(
7385 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
7386 transitions status {\n scheduled -> confirmed\n \
7387 confirmed -> completed\n terminal: completed\n }\n}\n\n\
7388 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
7389 InterviewSlot.created(status: scheduled)\n}\n\n\
7390 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
7391 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
7392 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
7393 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
7394 );
7395 assert!(!ds.iter().any(|d| {
7397 d.code == Some("allium.status.unreachableValue")
7398 && d.message.contains("InterviewSlot")
7399 }));
7400 assert!(!ds.iter().any(|d| {
7401 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
7402 }));
7403 }
7404
7405 #[test]
7406 fn cross_entity_undeclared_transition() {
7407 let ds = analyze_src(
7408 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
7409 transitions status {\n scheduled -> confirmed\n \
7410 confirmed -> completed\n terminal: completed\n }\n}\n\n\
7411 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
7412 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
7413 );
7414 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
7415 }
7416
7417 #[test]
7418 fn nested_entity_status_recognised() {
7419 let ds = analyze_src(
7420 "entity Order {\n status: placed | paid\n payment: Payment\n \
7421 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
7422 entity Payment {\n status: pending | captured | failed\n \
7423 transitions status {\n pending -> captured\n pending -> failed\n \
7424 terminal: captured, failed\n }\n}\n\n\
7425 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
7426 requires: order.payment.status = pending\n \
7427 ensures: order.payment.status = captured\n}\n",
7428 );
7429 assert!(!ds.iter().any(|d| {
7431 (d.code == Some("allium.status.unreachableValue")
7432 || d.code == Some("allium.status.noExit"))
7433 && d.message.contains("'captured'")
7434 }));
7435 }
7436
7437 #[test]
7440 fn dead_transition_missing_producer() {
7441 let r = analyse_src(
7442 "entity App {\n status: submitted | screening | approved | rejected\n \
7443 verified: Boolean\n \
7444 transitions status {\n submitted -> screening\n screening -> approved\n \
7445 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7446 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
7447 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
7448 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
7449 requires:\n app.status = screening\n app.verified = true\n \
7450 ensures: app.status = approved\n}\n\n\
7451 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
7452 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
7453 );
7454 assert!(has_finding(&r, "dead_transition"));
7455 assert!(has_finding(&r, "missing_producer"));
7456 }
7457
7458 #[test]
7459 fn satisfied_requires_no_dead_transition() {
7460 let r = analyse_src(
7461 "entity App {\n status: submitted | screening | approved | rejected\n \
7462 verified: Boolean\n \
7463 transitions status {\n submitted -> screening\n screening -> approved\n \
7464 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7465 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
7466 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
7467 rule Verify {\n when: SystemVerifies(app, result)\n \
7468 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
7469 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
7470 requires:\n app.status = screening\n app.verified = true\n \
7471 ensures: app.status = approved\n}\n\n\
7472 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
7473 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
7474 );
7475 assert!(!has_finding(&r, "dead_transition"));
7476 assert!(!has_finding(&r, "missing_producer"));
7477 }
7478
7479 #[test]
7480 fn deadlock_detected() {
7481 let r = analyse_src(
7482 "entity Doc {\n status: submitted | review | approved | rejected\n \
7483 reviewer_assigned: Boolean\n \
7484 transitions status {\n submitted -> review\n review -> approved\n \
7485 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7486 rule Submit {\n when: AuthorSubmits(author, doc)\n \
7487 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
7488 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
7489 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
7490 ensures: doc.status = approved\n}\n\n\
7491 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
7492 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
7493 ensures: doc.status = rejected\n}\n",
7494 );
7495 assert!(has_finding(&r, "deadlock"));
7496 }
7497
7498 #[test]
7499 fn no_deadlock_when_paths_open() {
7500 let r = analyse_src(
7501 "entity Invoice {\n status: draft | sent | paid | void\n \
7502 transitions status {\n draft -> sent\n draft -> void\n \
7503 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
7504 rule Send {\n when: AccountantSends(accountant, invoice)\n \
7505 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
7506 rule Pay {\n when: PaymentReceived(invoice)\n \
7507 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
7508 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
7509 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
7510 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
7511 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
7512 );
7513 assert!(!has_finding(&r, "deadlock"));
7514 }
7515
7516 #[test]
7519 fn conflict_temporal_vs_external() {
7520 let r = analyse_src(
7521 "entity Membership {\n status: active | expired | extended\n \
7522 expires_at: Timestamp\n \
7523 transitions status {\n active -> expired\n active -> extended\n \
7524 terminal: expired, extended\n }\n}\n\n\
7525 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
7526 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
7527 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
7528 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
7529 );
7530 assert!(has_finding(&r, "conflict"));
7531 }
7532
7533 #[test]
7534 fn conflict_detected_when_effect_nested_in_branch() {
7535 let r = analyse_src(
7539 "entity Membership {\n status: active | expired | extended\n \
7540 expires_at: Timestamp\n \
7541 transitions status {\n active -> expired\n active -> extended\n \
7542 terminal: expired, extended\n }\n}\n\n\
7543 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
7544 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
7545 rule ManualExtend {\n when: AdminExtends(admin, membership, flag)\n \
7546 requires: membership.status = active\n \
7547 if flag:\n ensures: membership.status = extended\n \
7548 else:\n ensures: membership.status = extended\n}\n",
7549 );
7550 assert!(has_finding(&r, "conflict"));
7551 }
7552
7553 #[test]
7554 fn no_conflict_actor_choice() {
7555 let r = analyse_src(
7556 "entity LeaveRequest {\n status: pending | approved | denied\n \
7557 transitions status {\n pending -> approved\n pending -> denied\n \
7558 terminal: approved, denied\n }\n}\n\n\
7559 rule Approve {\n when: ManagerApproves(manager, request)\n \
7560 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
7561 rule Deny {\n when: ManagerDenies(manager, request)\n \
7562 requires: request.status = pending\n ensures: request.status = denied\n}\n",
7563 );
7564 assert!(!has_finding(&r, "conflict"));
7565 }
7566
7567 #[test]
7570 fn invariant_violation_detected() {
7571 let r = analyse_src(
7572 "entity JobRole {\n status: open | filled\n \
7573 candidacies: Candidacy with role = this\n \
7574 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
7575 entity Candidacy {\n status: active | hired | rejected\n \
7576 role: JobRole\n \
7577 transitions status {\n active -> hired\n active -> rejected\n \
7578 terminal: hired, rejected\n }\n}\n\n\
7579 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
7580 requires: candidacy.status = active\n \
7581 ensures: candidacy.status = hired\n}\n\n\
7582 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
7583 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
7584 );
7585 assert!(has_finding(&r, "invariant_risk"));
7586 }
7587
7588 #[test]
7589 fn invariant_guarded_no_violation() {
7590 let r = analyse_src(
7591 "entity JobRole {\n status: open | filled\n \
7592 candidacies: Candidacy with role = this\n \
7593 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
7594 entity Candidacy {\n status: active | hired | rejected\n \
7595 role: JobRole\n \
7596 transitions status {\n active -> hired\n active -> rejected\n \
7597 terminal: hired, rejected\n }\n}\n\n\
7598 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
7599 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
7600 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
7601 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
7602 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
7603 );
7604 assert!(!has_finding(&r, "invariant_risk"));
7605 }
7606
7607 #[test]
7610 fn external_entity_referenced_in_rules_info() {
7611 let ds = analyze_src(
7612 "external entity Client {\n id: String\n}\n\n\
7613 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
7614 );
7615 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
7616 assert!(hint.is_some());
7617 assert_eq!(hint.unwrap().severity, Severity::Info);
7618 }
7619
7620 #[test]
7623 fn undefined_type_reference() {
7624 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
7625 assert!(has_code(&ds, "allium.type.undefinedReference"));
7626 }
7627
7628 #[test]
7629 fn known_type_reference_ok() {
7630 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
7631 assert!(!has_code(&ds, "allium.type.undefinedReference"));
7632 }
7633
7634 #[test]
7637 fn unreachable_trigger_reported() {
7638 let ds = analyze_src(
7639 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
7640 );
7641 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
7642 }
7643
7644 fn analyze_with_imports(
7647 src: &str,
7648 imports: &[(&str, &[&str])],
7649 ) -> Vec<Diagnostic> {
7650 let input = format!("-- allium: 3\n{src}");
7651 let result = parse(&input);
7652 let imported: HashMap<String, HashSet<String>> = imports
7653 .iter()
7654 .map(|(alias, triggers)| {
7655 (
7656 alias.to_string(),
7657 triggers.iter().map(|t| t.to_string()).collect(),
7658 )
7659 })
7660 .collect();
7661 analyze_with_cross_module(
7662 &result.module,
7663 &input,
7664 &HashSet::new(),
7665 &HashSet::new(),
7666 &imported,
7667 &HashMap::new(),
7668 &AmbiguousImports::default(),
7669 &ReverseContributions::default(),
7670 &HashMap::new(),
7671 &HashSet::new(),
7672 )
7673 }
7674
7675 fn analyze_with_ambiguous(
7681 src: &str,
7682 names: &[(&str, &[&str])],
7683 triggers: &[(&str, &[&str])],
7684 ) -> Vec<Diagnostic> {
7685 let input = format!("-- allium: 3\n{src}");
7686 let result = parse(&input);
7687 let to_map = |entries: &[(&str, &[&str])]| -> HashMap<String, Vec<String>> {
7688 entries
7689 .iter()
7690 .map(|(name, aliases)| {
7691 (
7692 name.to_string(),
7693 aliases.iter().map(|a| a.to_string()).collect(),
7694 )
7695 })
7696 .collect()
7697 };
7698 let ambiguous = AmbiguousImports {
7699 names: to_map(names),
7700 triggers: to_map(triggers),
7701 };
7702 let mut imported: HashMap<String, HashSet<String>> = HashMap::new();
7703 for (trigger, aliases) in triggers {
7704 for alias in *aliases {
7705 imported
7706 .entry(alias.to_string())
7707 .or_default()
7708 .insert(trigger.to_string());
7709 }
7710 }
7711 analyze_with_cross_module(
7712 &result.module,
7713 &input,
7714 &HashSet::new(),
7715 &HashSet::new(),
7716 &imported,
7717 &HashMap::new(),
7718 &ambiguous,
7719 &ReverseContributions::default(),
7720 &HashMap::new(),
7721 &HashSet::new(),
7722 )
7723 }
7724
7725 #[test]
7726 fn qualified_trigger_suppressed_in_single_file_mode() {
7727 let ds = analyze_src(
7730 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7731 );
7732 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7733 }
7734
7735 #[test]
7736 fn qualified_trigger_reachable_via_imported_module() {
7737 let ds = analyze_with_imports(
7738 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7739 &[("emitter", &["Pinged"])],
7740 );
7741 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7742 }
7743
7744 #[test]
7745 fn qualified_trigger_unreachable_when_imported_module_lacks_it() {
7746 let ds = analyze_with_imports(
7747 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7748 &[("emitter", &["SomethingElse"])],
7749 );
7750 let flagged: Vec<&Diagnostic> = ds
7751 .iter()
7752 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7753 .collect();
7754 assert_eq!(flagged.len(), 1);
7755 assert!(flagged[0].message.contains("'emitter/Pinged'"));
7756 assert!(flagged[0].message.contains("imported module 'emitter'"));
7757 }
7758
7759 #[test]
7760 fn qualified_trigger_suppressed_for_alias_outside_check_set() {
7761 let ds = analyze_with_imports(
7764 "use \"github.com/allium-specs/oauth/abc\" as oauth\n\nrule Audit {\n when: oauth/SessionCreated(session)\n ensures: Logged(session: session)\n}\n",
7765 &[],
7766 );
7767 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7768 }
7769
7770 #[test]
7771 fn unqualified_trigger_reachable_via_imported_module() {
7772 let ds = analyze_with_imports(
7773 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7774 &[("emitter", &["Pinged"])],
7775 );
7776 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7777 }
7778
7779 #[test]
7780 fn unqualified_trigger_still_flagged_when_no_import_emits_it() {
7781 let ds = analyze_with_imports(
7782 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7783 &[("emitter", &["SomethingElse"])],
7784 );
7785 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
7786 }
7787
7788 #[test]
7791 fn ambiguous_trigger_subscription_warns() {
7792 let ds = analyze_with_ambiguous(
7793 "use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7794 &[],
7795 &[("Pinged", &["a", "b"])],
7796 );
7797 let diag = ds
7798 .iter()
7799 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7800 .expect("ambiguous trigger subscription should warn");
7801 assert!(diag.message.contains("'a' and 'b'"), "message: {}", diag.message);
7802 assert!(diag.message.contains("a/Pinged"), "message: {}", diag.message);
7803 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7805 }
7806
7807 #[test]
7808 fn ambiguous_trigger_not_flagged_when_emitted_locally() {
7809 let ds = analyze_with_ambiguous(
7811 "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",
7812 &[],
7813 &[("Pinged", &["a", "b"])],
7814 );
7815 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7816 }
7817
7818 #[test]
7819 fn qualified_trigger_subscription_not_flagged_as_ambiguous() {
7820 let ds = analyze_with_ambiguous(
7821 "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",
7822 &[],
7823 &[("Pinged", &["a", "b"])],
7824 );
7825 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7826 }
7827
7828 #[test]
7829 fn ambiguous_name_reference_warns() {
7830 let ds = analyze_with_ambiguous(
7831 "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",
7832 &[("Invoice", &["billing", "orders"])],
7833 &[],
7834 );
7835 let diag = ds
7836 .iter()
7837 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7838 .expect("ambiguous unqualified name should warn");
7839 assert!(
7840 diag.message.contains("'billing' and 'orders'"),
7841 "message: {}",
7842 diag.message
7843 );
7844 assert!(diag.message.contains("billing/Invoice"), "message: {}", diag.message);
7845 }
7846
7847 #[test]
7848 fn ambiguous_name_shadowed_by_local_declaration() {
7849 let ds = analyze_with_ambiguous(
7850 "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",
7851 &[("Invoice", &["billing", "orders"])],
7852 &[],
7853 );
7854 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7855 }
7856
7857 #[test]
7858 fn ambiguous_name_flagged_once_per_name() {
7859 let ds = analyze_with_ambiguous(
7860 "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",
7861 &[("Invoice", &["billing", "orders"])],
7862 &[],
7863 );
7864 let count = ds
7865 .iter()
7866 .filter(|d| d.code == Some("allium.use.ambiguousReference"))
7867 .count();
7868 assert_eq!(count, 1, "expected a single warning per ambiguous name");
7869 }
7870
7871 #[test]
7872 fn no_ambiguity_warnings_in_single_file_mode() {
7873 let ds = analyze_src(
7874 "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",
7875 );
7876 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7877 }
7878
7879 #[test]
7880 fn conditional_ensures_emission_registers() {
7881 let ds = analyze_src(
7884 "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",
7885 );
7886 let unreachable: Vec<&Diagnostic> = ds
7887 .iter()
7888 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7889 .collect();
7890 assert_eq!(unreachable.len(), 1);
7893 assert!(unreachable[0].message.contains("'AdvertReceived'"));
7894 }
7895
7896 #[test]
7897 fn for_body_ensures_emission_registers() {
7898 let ds = analyze_src(
7899 "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",
7900 );
7901 let unreachable: Vec<&Diagnostic> = ds
7902 .iter()
7903 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7904 .collect();
7905 assert_eq!(unreachable.len(), 1);
7906 assert!(unreachable[0].message.contains("'Broadcast'"));
7907 }
7908
7909 #[test]
7910 fn collect_trigger_outputs_includes_provides_ensures_and_branches() {
7911 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";
7912 let result = parse(input);
7913 let outputs = collect_trigger_outputs(&result.module);
7914 assert!(outputs.contains("Submit"));
7915 assert!(outputs.contains("Accepted"));
7916 assert!(outputs.contains("Rejected"));
7917 }
7918
7919 #[test]
7922 fn unused_field_reported() {
7923 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");
7924 assert!(has_code(&ds, "allium.field.unused"));
7925 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7927 assert!(unused.iter().any(|d| d.message.contains("A.y")));
7928 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
7929 }
7930
7931 #[test]
7932 fn field_used_by_sibling_derived_field_not_unused() {
7933 let ds = analyze_src("entity Widget {\n count: Integer\n is_positive: count > 0\n}\n");
7936 let unused: Vec<_> =
7937 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7938 assert!(
7939 !unused.iter().any(|d| d.message.contains("Widget.count")),
7940 "count is referenced by is_positive and must not be flagged unused. Got: {:?}",
7941 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7942 );
7943 assert!(
7945 unused.iter().any(|d| d.message.contains("Widget.is_positive")),
7946 "is_positive is unreferenced and should still warn. Got: {:?}",
7947 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7948 );
7949 }
7950
7951 #[test]
7952 fn field_set_via_created_named_arg_not_unused() {
7953 let ds = analyze_src(
7956 "entity Widget {\n name: String\n}\n\nrule MakeWidget {\n when: MakeWidget(label)\n ensures: Widget.created(name: label)\n}\n",
7957 );
7958 let unused: Vec<_> =
7959 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7960 assert!(
7961 !unused.iter().any(|d| d.message.contains("Widget.name")),
7962 "name is set by Widget.created(name: ...) and must not be flagged unused. Got: {:?}",
7963 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7964 );
7965 }
7966
7967 #[test]
7968 fn field_named_like_a_rule_binding_still_unused() {
7969 let ds = analyze_src(
7973 "entity Widget {\n order: String\n}\n\nrule R {\n when: Ping(order)\n ensures: Done()\n}\n",
7974 );
7975 let unused: Vec<_> =
7976 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7977 assert!(
7978 unused.iter().any(|d| d.message.contains("Widget.order")),
7979 "a rule binding named 'order' must not suppress the unused field 'Widget.order'. Got: {:?}",
7980 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7981 );
7982 }
7983
7984 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";
7987
7988 #[test]
7989 fn conditional_ensures_makes_branch_statuses_reachable() {
7990 let ds = analyze_src(DECIDE_BRANCH_SPEC);
7993 let unreachable: Vec<_> = ds
7994 .iter()
7995 .filter(|d| d.code == Some("allium.status.unreachableValue"))
7996 .collect();
7997 assert!(
7998 !unreachable.iter().any(|d| d.message.contains("approved")),
7999 "approved is assigned in the if-branch and must be reachable. Got: {:?}",
8000 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
8001 );
8002 assert!(
8003 !unreachable.iter().any(|d| d.message.contains("rejected")),
8004 "rejected is assigned in the else-branch and must be reachable. Got: {:?}",
8005 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
8006 );
8007 }
8008
8009 #[test]
8010 fn conditional_ensures_transitions_are_witnessed_no_deadlock() {
8011 let r = analyse_src(DECIDE_BRANCH_SPEC);
8015 assert!(
8016 !has_finding(&r, "deadlock"),
8017 "branch-witnessed exits from pending must clear the deadlock. Findings: {:?}",
8018 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
8019 );
8020 }
8021
8022 const FOR_IN_PROVIDES_SPEC: &str = r#"external entity Person { name: String }
8023
8024entity User {
8025 person: Person
8026 sessions: Session with user = this
8027}
8028
8029entity Session {
8030 user: User
8031 status: active | ended
8032 transitions status { active -> ended terminal: ended }
8033}
8034
8035rule LogOut {
8036 when: UserLogsOut(session)
8037 requires: session.status = active
8038 ensures: session.status = ended
8039}
8040
8041surface AccountManagement {
8042 facing person: Person
8043 context user: User where person = person
8044 exposes:
8045 for session in user.sessions:
8046 session.status
8047 provides:
8048 for session in user.sessions:
8049 UserLogsOut(session)
8050}
8051"#;
8052
8053 #[test]
8054 fn trigger_provided_in_for_block_is_reachable() {
8055 let ds = analyze_src(FOR_IN_PROVIDES_SPEC);
8058 assert!(
8059 !ds.iter().any(|d| d.code == Some("allium.rule.unreachableTrigger")
8060 && d.message.contains("UserLogsOut")),
8061 "UserLogsOut is provided inside a for-block and must be reachable. Got: {:?}",
8062 ds.iter()
8063 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
8064 .map(|d| &d.message)
8065 .collect::<Vec<_>>()
8066 );
8067 }
8068
8069 #[test]
8070 fn trigger_provided_in_for_block_no_unreachable_finding() {
8071 let r = analyse_src(FOR_IN_PROVIDES_SPEC);
8073 assert!(
8074 !has_finding(&r, "unreachable_trigger"),
8075 "for-block-provided trigger must not yield an unreachable_trigger finding. Findings: {:?}",
8076 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
8077 );
8078 }
8079
8080 #[test]
8083 fn deferred_declarations_and_config_are_offered_to_importers() {
8084 let input = "-- allium: 3\nconfig {\n page_size: Integer = 25\n}\n\ndeferred ExternalHelper\ndeferred Matching.suggest\n";
8085 let result = parse(input);
8086 let names = collect_referenced_trigger_names(&result.module);
8087 assert!(names.contains("config"), "a declared config block is referenceable as alias/config");
8088 assert!(names.contains("ExternalHelper"), "a deferred declaration is referenceable by name");
8089 assert!(names.contains("Matching"), "a dotted deferred offers its root name");
8090 }
8091
8092 #[test]
8093 fn a_module_without_config_or_deferred_offers_neither() {
8094 let input = "-- allium: 3\nentity Thing {\n id: Integer\n}\n";
8095 let result = parse(input);
8096 let names = collect_referenced_trigger_names(&result.module);
8097 assert!(!names.contains("config"), "no config block, no config offering");
8098 assert!(!names.contains("ExternalHelper"));
8099 }
8100
8101 #[test]
8102 fn a_deferred_declared_against_another_modules_alias_is_not_offered() {
8103 let input = "-- allium: 3\nuse \"./other.allium\" as other\n\ndeferred other/Foreign\n";
8106 let result = parse(input);
8107 let names = collect_referenced_trigger_names(&result.module);
8108 assert!(!names.contains("Foreign"), "a foreign deferred is not a local offering");
8109 assert!(!names.contains("other"), "the alias itself is not an offering");
8110 }
8111
8112 const MULTI_EMISSION_SPEC: &str = r#"
8115surface Api {
8116 provides:
8117 Do1(x)
8118 Do2(x)
8119}
8120
8121rule EmitsTwo {
8122 when: Do1(x)
8123
8124 ensures:
8125 FirstEmitted(value: x)
8126 SecondEmitted(value: x)
8127}
8128
8129rule EmitsAfterAssignment {
8130 when: Do2(x)
8131
8132 ensures:
8133 x.field = 1
8134 AfterAssignEmitted(value: x)
8135}
8136
8137rule ConsumesSecond {
8138 when: SecondEmitted(value)
8139
8140 ensures: Done1(value: value)
8141}
8142
8143rule ConsumesAfterAssign {
8144 when: AfterAssignEmitted(value)
8145
8146 ensures: Done2(value: value)
8147}
8148"#;
8149
8150 #[test]
8151 fn trigger_emitted_after_the_first_ensures_statement_is_reachable() {
8152 let ds = analyze_src(MULTI_EMISSION_SPEC);
8156 let false_positives: Vec<_> = ds
8157 .iter()
8158 .filter(|d| {
8159 d.code == Some("allium.rule.unreachableTrigger")
8160 && (d.message.contains("SecondEmitted")
8161 || d.message.contains("AfterAssignEmitted"))
8162 })
8163 .map(|d| &d.message)
8164 .collect();
8165 assert!(
8166 false_positives.is_empty(),
8167 "emissions after the first ensures statement are emissions. Got: {false_positives:?}"
8168 );
8169 }
8170
8171 const OTHERWISE_EMISSION_SPEC: &str = r#"
8172surface Api {
8173 provides:
8174 Submit(form)
8175}
8176
8177rule ValidatesForm {
8178 when: Submit(form)
8179
8180 requires: form.name != null
8181 otherwise: ValidationFailed(form, "name_required")
8182
8183 ensures: FormAccepted(form: form)
8184}
8185
8186rule ShowsError {
8187 when: ValidationFailed(form, reason)
8188
8189 ensures: ErrorShown(form: form)
8190}
8191"#;
8192
8193 #[test]
8194 fn trigger_emitted_via_otherwise_is_reachable() {
8195 let ds = analyze_src(OTHERWISE_EMISSION_SPEC);
8199 let false_positives: Vec<_> = ds
8200 .iter()
8201 .filter(|d| {
8202 d.code == Some("allium.rule.unreachableTrigger")
8203 && d.message.contains("ValidationFailed")
8204 })
8205 .map(|d| &d.message)
8206 .collect();
8207 assert!(
8208 false_positives.is_empty(),
8209 "an otherwise: emission is an emission. Got: {false_positives:?}"
8210 );
8211 }
8212
8213 #[test]
8214 fn a_trigger_no_rule_emits_is_still_unreachable() {
8215 let src = "rule Lonely {\n when: NeverEmitted(x)\n\n ensures: Done(x: x)\n}\n";
8218 let ds = analyze_src(src);
8219 assert!(
8220 ds.iter().any(|d| d.code == Some("allium.rule.unreachableTrigger")
8221 && d.message.contains("NeverEmitted")),
8222 "a trigger nothing emits must stay unreachable"
8223 );
8224 }
8225
8226 #[test]
8229 fn unused_entity_reported() {
8230 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
8231 assert!(has_code(&ds, "allium.entity.unused"));
8232 }
8233
8234 #[test]
8235 fn external_ref_suppresses_unused_entity() {
8236 let src = "entity InputEvent {\n payload: String\n}\n";
8237 let input = format!("-- allium: 3\n{src}");
8238 let result = parse(&input);
8239 let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
8240 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8241 assert!(!has_code(&ds, "allium.entity.unused"));
8242 }
8243
8244 #[test]
8245 fn external_ref_suppresses_unused_definition() {
8246 let src = "value Snapshot {\n version: Integer\n}\n";
8247 let input = format!("-- allium: 3\n{src}");
8248 let result = parse(&input);
8249 let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
8250 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8251 assert!(!has_code(&ds, "allium.definition.unused"));
8252 }
8253
8254 #[test]
8255 fn unreferenced_entity_still_warns_without_external_ref() {
8256 let src = "entity InputEvent {\n payload: String\n}\n";
8257 let input = format!("-- allium: 3\n{src}");
8258 let result = parse(&input);
8259 let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
8260 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8261 assert!(has_code(&ds, "allium.entity.unused"));
8262 }
8263
8264 #[test]
8265 fn collect_qualified_refs_from_rule_clause() {
8266 let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
8267 let input = format!("-- allium: 3\n{src}");
8268 let result = parse(&input);
8269 let refs = collect_qualified_references(&result.module);
8270 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8271 }
8272
8273 #[test]
8274 fn collect_qualified_refs_from_field_type() {
8275 let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
8276 let input = format!("-- allium: 3\n{src}");
8277 let result = parse(&input);
8278 let refs = collect_qualified_references(&result.module);
8279 assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
8280 }
8281
8282 #[test]
8283 fn collect_qualified_refs_from_requires() {
8284 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";
8285 let input = format!("-- allium: 3\n{src}");
8286 let result = parse(&input);
8287 let refs = collect_qualified_references(&result.module);
8288 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
8289 }
8290
8291 #[test]
8292 fn collect_qualified_refs_from_for_block() {
8293 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";
8294 let input = format!("-- allium: 3\n{src}");
8295 let result = parse(&input);
8296 let refs = collect_qualified_references(&result.module);
8297 assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
8298 }
8299
8300 #[test]
8301 fn collect_qualified_refs_from_member_access() {
8302 let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
8303 let input = format!("-- allium: 3\n{src}");
8304 let result = parse(&input);
8305 let refs = collect_qualified_references(&result.module);
8306 assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
8307 }
8308
8309 #[test]
8310 fn collect_qualified_refs_multiple_from_same_module() {
8311 let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
8312 let input = format!("-- allium: 3\n{src}");
8313 let result = parse(&input);
8314 let refs = collect_qualified_references(&result.module);
8315 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8316 assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
8317 }
8318
8319 #[test]
8320 fn collect_qualified_refs_multiple_modules() {
8321 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";
8322 let input = format!("-- allium: 3\n{src}");
8323 let result = parse(&input);
8324 let refs = collect_qualified_references(&result.module);
8325 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8326 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
8327 }
8328
8329 #[test]
8330 fn collect_qualified_refs_empty_when_none() {
8331 let src = "entity Order {\n total: Decimal\n}\n";
8332 let input = format!("-- allium: 3\n{src}");
8333 let result = parse(&input);
8334 let refs = collect_qualified_references(&result.module);
8335 assert!(refs.is_empty());
8336 }
8337
8338 #[test]
8339 fn collect_qualified_refs_from_ensures() {
8340 let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
8341 let input = format!("-- allium: 3\n{src}");
8342 let result = parse(&input);
8343 let refs = collect_qualified_references(&result.module);
8344 assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
8345 }
8346
8347 #[test]
8348 fn collect_qualified_refs_from_invariant() {
8349 let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
8350 let input = format!("-- allium: 3\n{src}");
8351 let result = parse(&input);
8352 let refs = collect_qualified_references(&result.module);
8353 assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
8354 }
8355
8356 #[test]
8357 fn collect_qualified_refs_from_deferred() {
8358 let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
8359 let input = format!("-- allium: 3\n{src}");
8360 let result = parse(&input);
8361 let refs = collect_qualified_references(&result.module);
8362 assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
8363 }
8364
8365 #[test]
8366 fn collect_qualified_refs_from_alias_dot_member() {
8367 let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
8368 let input = format!("-- allium: 3\n{src}");
8369 let result = parse(&input);
8370 let refs = collect_qualified_references(&result.module);
8371 assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
8372 }
8373
8374 #[test]
8375 fn collect_all_idents_includes_unqualified_entity_ref() {
8376 let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
8377 let input = format!("-- allium: 3\n{src}");
8378 let result = parse(&input);
8379 let idents = collect_all_referenced_idents(&result.module);
8380 assert!(idents.contains("InputPartition"));
8381 }
8382
8383 #[test]
8384 fn collect_declared_names_returns_entity_and_value_names() {
8385 let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
8386 let input = format!("-- allium: 3\n{src}");
8387 let result = parse(&input);
8388 let names = collect_declared_names(&result.module);
8389 assert!(names.contains("Order"));
8390 assert!(names.contains("Money"));
8391 assert!(names.contains("Status"));
8392 }
8393
8394 #[test]
8395 fn external_ref_only_suppresses_matching_name() {
8396 let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
8398 let input = format!("-- allium: 3\n{src}");
8399 let result = parse(&input);
8400 let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
8401 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8402 assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
8403 && d.message.contains("Used")));
8404 assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
8405 && d.message.contains("Orphan")));
8406 }
8407
8408 #[test]
8409 fn external_ref_suppresses_unused_external_entity() {
8410 let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
8411 let input = format!("-- allium: 3\n{src}");
8412 let result = parse(&input);
8413 let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
8414 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8415 assert!(!has_code(&ds, "allium.entity.unused"));
8416 }
8417
8418 #[test]
8419 fn external_ref_suppresses_unused_enum() {
8420 let src = "enum Priority {\n low\n medium\n high\n}\n";
8421 let input = format!("-- allium: 3\n{src}");
8422 let result = parse(&input);
8423 let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
8424 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8425 assert!(!has_code(&ds, "allium.definition.unused"));
8426 }
8427
8428 #[test]
8429 fn empty_external_refs_same_as_plain_analyze() {
8430 let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
8431 let input = format!("-- allium: 3\n{src}");
8432 let result = parse(&input);
8433 let plain = analyze(&result.module, &input);
8434 let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
8435 assert_eq!(plain.len(), with_empty.len());
8436 for (a, b) in plain.iter().zip(with_empty.iter()) {
8437 assert_eq!(a.code, b.code);
8438 assert_eq!(a.message, b.message);
8439 }
8440 }
8441
8442 #[test]
8445 fn resolved_use_path_no_warning() {
8446 let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
8447 let input = format!("-- allium: 3\n{src}");
8448 let result = parse(&input);
8449 let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
8450 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8451 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8452 }
8453
8454 #[test]
8455 fn unresolved_use_path_warns() {
8456 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8457 let input = format!("-- allium: 3\n{src}");
8458 let result = parse(&input);
8459 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8461 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8462 assert!(has_code(&ds, "allium.use.unresolvedPath"));
8463 }
8464
8465 #[test]
8466 fn unresolved_use_path_skipped_in_single_file_mode() {
8467 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8469 let input = format!("-- allium: 3\n{src}");
8470 let result = parse(&input);
8471 let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
8472 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8473 }
8474
8475 #[test]
8476 fn unresolved_use_path_fires_with_empty_resolved_set() {
8477 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8480 let input = format!("-- allium: 3\n{src}");
8481 let result = parse(&input);
8482 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new(), &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8483 assert!(has_code(&ds, "allium.use.unresolvedPath"));
8484 }
8485
8486 #[test]
8487 fn unresolved_use_path_message_includes_path() {
8488 let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
8489 let input = format!("-- allium: 3\n{src}");
8490 let result = parse(&input);
8491 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8492 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8493 let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
8494 assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
8495 }
8496
8497 #[test]
8498 fn unresolved_use_path_suppressible() {
8499 let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8500 let input = format!("-- allium: 3\n{src}");
8501 let result = parse(&input);
8502 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8503 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8504 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8505 }
8506
8507 #[test]
8508 fn multiple_use_paths_mixed_resolution() {
8509 let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
8510 let input = format!("-- allium: 3\n{src}");
8511 let result = parse(&input);
8512 let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
8513 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new(), &HashSet::new());
8514 let unresolved: Vec<_> = ds.iter()
8515 .filter(|d| d.code == Some("allium.use.unresolvedPath"))
8516 .collect();
8517 assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
8518 assert!(unresolved[0].message.contains("lost.allium"));
8519 }
8520
8521 #[test]
8524 fn deferred_missing_location_hint() {
8525 let ds = analyze_src("deferred Foo.bar\n");
8526 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8527 }
8528
8529 #[test]
8530 fn deferred_with_quoted_path_hint_ok() {
8531 let ds = analyze_src("deferred Foo.bar \"detailed/foo.allium\"\n");
8532 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8533 }
8534
8535 #[test]
8536 fn deferred_with_see_comment_hint_ok() {
8537 let ds = analyze_src("deferred Foo.bar -- see: detailed/foo.allium\n");
8539 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8540 }
8541
8542 #[test]
8543 fn deferred_with_url_hint_ok() {
8544 let ds = analyze_src("deferred Foo.bar -- https://example.com/foo.allium\n");
8545 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8546 }
8547
8548 #[test]
8549 fn deferred_with_url_glued_to_path_warns() {
8550 assert!(has_code(
8554 &analyze_src("deferred Foohttps://x\n"),
8555 "allium.deferred.missingLocationHint"
8556 ));
8557 assert!(has_code(
8558 &analyze_src("deferred Foohttp://x\n"),
8559 "allium.deferred.missingLocationHint"
8560 ));
8561 }
8562
8563 #[test]
8564 fn deferred_with_non_hint_comment_warns() {
8565 let ds = analyze_src("deferred Foo.bar -- TODO write this\n");
8568 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8569 }
8570
8571 #[test]
8572 fn deferred_expression_path_with_quote_suppresses() {
8573 let ds = analyze_src("deferred Foo(\"x\")\n");
8578 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8579 let ds = analyze_src("deferred Foo = \"x\"\n");
8580 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8581 }
8582
8583 #[test]
8584 fn deferred_trailing_dot_warns_with_captured_name() {
8585 let ds = analyze_src("deferred Dangling.\n");
8590 let hints: Vec<&Diagnostic> = ds
8591 .iter()
8592 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8593 .collect();
8594 assert_eq!(hints.len(), 1);
8595 assert!(hints[0].message.contains("'Dangling.'"));
8596 }
8597
8598 #[test]
8599 fn deferred_lone_cr_is_a_line_boundary() {
8600 let ds = analyze_src("deferred Foo\rdeferred Bar\n");
8604 let hints: Vec<&Diagnostic> = ds
8605 .iter()
8606 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8607 .collect();
8608 assert_eq!(hints.len(), 2, "both CR-separated declarations warn");
8609 let ds = analyze_src("deferred Foo\r-- see: x.allium\n");
8611 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8612 }
8613
8614 #[test]
8615 fn deferred_unmatchable_path_stays_silent() {
8616 let ds = analyze_src("deferred (Foo)\n");
8621 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8622 }
8623
8624 #[test]
8625 fn deferred_qualified_path_warns_with_flat_name() {
8626 let ds = analyze_src("deferred billing/InvoiceWorkflow\n");
8629 let hints: Vec<&Diagnostic> = ds
8630 .iter()
8631 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8632 .collect();
8633 assert_eq!(hints.len(), 1);
8634 assert!(hints[0].message.contains("'billing'"));
8635 }
8636
8637 #[test]
8638 fn deferred_location_hint_is_per_line() {
8639 let ds = analyze_src(
8643 "deferred A.one -- see: a.allium\ndeferred B.two\ndeferred C.three \"c.allium\"\n",
8644 );
8645 let hints: Vec<&Diagnostic> = ds
8646 .iter()
8647 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8648 .collect();
8649 assert_eq!(hints.len(), 1);
8650 assert!(hints[0].message.contains("B.two"));
8651 }
8652
8653 #[test]
8656 fn valid_trigger_ok() {
8657 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
8658 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8659 }
8660
8661 #[test]
8662 fn qualified_trigger_call_is_valid() {
8663 let ds = analyze_src(
8667 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
8668 );
8669 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8670 }
8671
8672 #[test]
8673 fn typed_trigger_param_reported_at_trigger() {
8674 let ds = analyze_src(
8679 "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",
8680 );
8681 let invalid: Vec<&Diagnostic> = ds
8682 .iter()
8683 .filter(|d| d.code == Some("allium.rule.invalidTrigger"))
8684 .collect();
8685 assert_eq!(invalid.len(), 1, "exactly one invalidTrigger diagnostic");
8686 assert!(invalid[0].message.contains("'account'"));
8687 assert!(invalid[0].message.contains("bare names"));
8688 assert!(
8690 !has_code(&ds, "allium.rule.undefinedBinding"),
8691 "typed trigger param must not also fire undefinedBinding on the body"
8692 );
8693 }
8694
8695 #[test]
8696 fn untyped_trigger_param_ok() {
8697 let ds = analyze_src(
8699 "entity Account { name: String }\nentity Greeting { label: String }\n\nrule UntypedParam {\n when: AccountSeen(account)\n ensures: Greeting.created(label: account.name)\n}\n",
8700 );
8701 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8702 assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
8703 }
8704
8705 #[test]
8708 fn homogeneous_list_literal_ok() {
8709 let ds = analyze_src("default E e = { items: [\"a\", \"b\", \"c\"] }");
8710 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8711 }
8712
8713 #[test]
8714 fn empty_list_literal_ok() {
8715 let ds = analyze_src("default E e = { items: [] }");
8716 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8717 }
8718
8719 #[test]
8720 fn heterogeneous_list_literal_flagged() {
8721 let ds = analyze_src("default E e = { items: [\"a\", 5] }");
8722 assert!(has_code(&ds, "allium.list.mixedElementTypes"));
8723 }
8724
8725 #[test]
8726 fn list_literal_of_identifiers_not_flagged() {
8727 let ds = analyze_src("default E e = { items: [foo, bar] }");
8730 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8731 }
8732
8733 #[test]
8736 fn qualified_default_known_alias_ok() {
8737 let ds = analyze_src(
8738 "use \"./p.allium\" as gp\n\ndefault gp/Policy my_policy = { id: \"x\" }",
8739 );
8740 assert!(!has_code(&ds, "allium.reference.undefinedImportedAlias"));
8741 }
8742
8743 #[test]
8744 fn qualified_default_unknown_alias_flagged() {
8745 let ds = analyze_src("default zz/Policy my_policy = { id: \"x\" }");
8747 assert!(has_code(&ds, "allium.reference.undefinedImportedAlias"));
8748 }
8749
8750 #[test]
8753 fn default_unknown_field_flagged() {
8754 let ds = analyze_src(
8755 "entity Policy { id: String }\ndefault Policy p = { id: \"x\", naem: \"typo\" }",
8756 );
8757 assert!(has_code(&ds, "allium.default.unknownField"));
8758 }
8759
8760 #[test]
8761 fn default_known_fields_ok() {
8762 let ds = analyze_src(
8763 "entity Policy { id: String\n label: String }\ndefault Policy p = { id: \"x\", label: \"y\" }",
8764 );
8765 assert!(!has_code(&ds, "allium.default.unknownField"));
8766 }
8767
8768 #[test]
8769 fn default_nested_object_unknown_field_flagged() {
8770 let ds = analyze_src(
8772 "value Predicate { clause_order: List<String> }\nentity Policy { id: String\n predicate: Predicate }\ndefault Policy p = { id: \"x\", predicate: { bogus: 5 } }",
8773 );
8774 assert!(has_code(&ds, "allium.default.unknownField"));
8775 }
8776
8777 #[test]
8778 fn empty_list_in_list_field_ok() {
8779 let ds = analyze_src(
8780 "entity E { tags: List<String> }\ndefault E e = { tags: [] }",
8781 );
8782 assert!(!has_code(&ds, "allium.list.emptyListNoElementType"));
8783 }
8784
8785 #[test]
8786 fn empty_list_in_non_list_field_flagged() {
8787 let ds = analyze_src(
8788 "entity E { id: String }\ndefault E e = { id: [] }",
8789 );
8790 assert!(has_code(&ds, "allium.list.emptyListNoElementType"));
8791 }
8792
8793 #[test]
8794 fn qualified_default_fields_not_validated() {
8795 let ds = analyze_src(
8798 "use \"./p.allium\" as gp\n\ndefault gp/Policy p = { anything: 1, goes: 2 }",
8799 );
8800 assert!(!has_code(&ds, "allium.default.unknownField"));
8801 }
8802
8803 #[test]
8806 fn duplicate_let_binding() {
8807 let ds = analyze_src(
8808 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
8809 );
8810 assert!(has_code(&ds, "allium.let.duplicateBinding"));
8811 }
8812
8813 #[test]
8816 fn config_undefined_reference() {
8817 let ds = analyze_src(
8818 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
8819 );
8820 assert!(has_code(&ds, "allium.config.undefinedReference"));
8821 }
8822
8823 #[test]
8824 fn config_valid_reference_ok() {
8825 let ds = analyze_src(
8826 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
8827 );
8828 assert!(!has_code(&ds, "allium.config.undefinedReference"));
8829 }
8830
8831 fn module_of(src: &str) -> Module {
8834 parse(&format!("-- allium: 3\n{src}")).module
8835 }
8836
8837 #[test]
8838 fn reverse_contributions_credit_qualified_creation() {
8839 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8840 let importer = module_of(
8841 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
8842 );
8843 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8844 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("open")));
8845 assert!(rc.witnessed_transitions.is_empty());
8846 assert!(rc.provided_triggers.is_empty());
8847 }
8848
8849 #[test]
8850 fn reverse_contributions_credit_qualified_provides() {
8851 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8852 let importer = module_of(
8853 "use \"./t.allium\" as tickets\nsurface Intake {\n provides:\n tickets/OpenTicket()\n tickets/CloseTicket(ticket)\n}\n",
8854 );
8855 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8856 assert!(rc.provided_triggers.contains("OpenTicket"));
8857 assert!(rc.provided_triggers.contains("CloseTicket"));
8858 }
8859
8860 #[test]
8861 fn reverse_contributions_credit_witnessed_transition() {
8862 let imported = module_of(
8863 "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",
8864 );
8865 let importer = module_of(
8866 "use \"./t.allium\" as tickets\nrule Archive {\n when: tickets/ArchiveTicketRequested(ticket)\n requires: ticket.status = closed\n ensures: ticket.status = archived\n}\n",
8867 );
8868 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8869 assert!(rc
8870 .witnessed_transitions
8871 .get("Ticket")
8872 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
8873 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8875 }
8876
8877 #[test]
8878 fn reverse_contributions_credit_importer_owned_trigger_via_qualified_context() {
8879 let imported = module_of(
8883 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
8884 );
8885 let importer = module_of(
8886 "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",
8887 );
8888 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8889 assert!(
8890 rc.witnessed_transitions
8891 .get("Ticket")
8892 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))),
8893 "importer-owned trigger typed via qualified context must witness the transition. Got: {:?}",
8894 rc.witnessed_transitions
8895 );
8896 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8897 }
8898
8899 #[test]
8900 fn reverse_contributions_credit_becomes_transition_trigger_witness() {
8901 let imported = module_of(
8904 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
8905 );
8906 let importer = module_of(
8907 "use \"./t.allium\" as tickets\nrule Archive {\n when: t: tickets/Ticket.status becomes closed\n ensures: t.status = archived\n}\n",
8908 );
8909 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8910 assert!(rc
8911 .witnessed_transitions
8912 .get("Ticket")
8913 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
8914 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8915 }
8916
8917 #[test]
8918 fn reverse_contributions_require_the_matching_alias() {
8919 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8920 let importer = module_of(
8921 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
8922 );
8923 let rc = collect_reverse_contributions(&importer, "other", &imported);
8925 assert!(rc.is_empty());
8926 }
8927
8928 #[test]
8929 fn reverse_contributions_credit_qualified_field_reference() {
8930 let imported =
8931 module_of("entity Ticket {\n status: open | closed\n due_at: Timestamp\n}\n");
8932 let importer = module_of(
8933 "use \"./t.allium\" as tickets\nrule Sweep {\n when: t: tickets/Ticket.due_at <= now\n requires: t.status = open\n ensures: t.status = closed\n}\n",
8934 );
8935 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8938 assert!(rc.referenced_fields.contains("due_at"));
8939 let other = collect_reverse_contributions(&importer, "other", &imported);
8940 assert!(!other.referenced_fields.contains("due_at"));
8941 }
8942
8943 #[test]
8944 fn reverse_contributions_filter_undeclared_status() {
8945 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8946 let importer = module_of(
8948 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: pending)\n}\n",
8949 );
8950 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8951 assert!(rc.is_empty());
8952 }
8953}