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