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 augment_binding_types_from_status<'a>(
4927 rule: &'a BlockDecl,
4928 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4929 binding_types: &mut HashMap<&'a str, &'a str>,
4930) {
4931 let status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
4932 .iter()
4933 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
4934 .collect();
4935 let mut pairs: Vec<(&str, &str)> = Vec::new();
4936 for_each_rule_clause(&rule.items, &mut |keyword, value| {
4937 if keyword == "requires" || keyword == "ensures" {
4938 collect_status_equations(value, &mut pairs);
4939 }
4940 });
4941 for (binding, target) in pairs {
4942 if binding_types.contains_key(binding) {
4943 continue;
4944 }
4945 if let Some(entity) =
4946 resolve_binding_entity_from_status(binding, Some(target), binding_types, &status_values)
4947 {
4948 binding_types.insert(binding, entity);
4949 }
4950 }
4951}
4952
4953fn collect_status_equations<'a>(expr: &'a Expr, out: &mut Vec<(&'a str, &'a str)>) {
4956 match expr {
4957 Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
4958 if let (Some((binding, "status")), Some(target)) =
4959 (expr_as_member_access(left), expr_as_ident(right))
4960 {
4961 out.push((binding, target));
4962 }
4963 }
4964 Expr::LogicalOp { left, right, .. } => {
4965 collect_status_equations(left, out);
4966 collect_status_equations(right, out);
4967 }
4968 Expr::Block { items, .. } => {
4969 for item in items {
4970 collect_status_equations(item, out);
4971 }
4972 }
4973 Expr::Conditional { branches, else_body, .. } => {
4974 for b in branches {
4975 collect_status_equations(&b.body, out);
4976 }
4977 if let Some(body) = else_body {
4978 collect_status_equations(body, out);
4979 }
4980 }
4981 _ => {}
4982 }
4983}
4984
4985fn collect_emitted_event_param_types<'a>(
4986 imported: &'a Module,
4987 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
4988 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
4989) {
4990 let mut surface_params: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
4995 for surface in module_blocks(imported, BlockKind::Surface) {
4996 for item in &surface.items {
4997 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4998 if keyword == "provides" {
4999 collect_command_param_types(value, status_by_entity, &mut surface_params);
5000 }
5001 }
5002 }
5003 }
5004 for rule in module_blocks(imported, BlockKind::Rule) {
5005 let mut binding_types = collect_rule_binding_types(rule, status_by_entity);
5006 augment_binding_types_from_commands(rule, &surface_params, &mut binding_types);
5007 augment_binding_types_from_status(rule, status_by_entity, &mut binding_types);
5008 if binding_types.is_empty() {
5009 continue;
5010 }
5011 for_each_rule_clause(&rule.items, &mut |keyword, value| {
5012 if keyword == "ensures" {
5013 collect_emission_param_types(value, &binding_types, out);
5014 }
5015 });
5016 }
5017}
5018
5019fn collect_emission_param_types<'a>(
5022 expr: &'a Expr,
5023 binding_types: &HashMap<&'a str, &'a str>,
5024 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
5025) {
5026 match expr {
5027 Expr::Call { function, args, .. } => {
5028 if let Expr::Ident(event) = function.as_ref() {
5029 let params: Vec<Option<&str>> = args
5030 .iter()
5031 .map(|arg| {
5032 let val = match arg {
5033 CallArg::Named(n) => &n.value,
5034 CallArg::Positional(e) => e,
5035 };
5036 match val {
5037 Expr::Ident(v) => binding_types.get(v.name.as_str()).copied(),
5038 _ => None,
5039 }
5040 })
5041 .collect();
5042 if params.iter().any(Option::is_some) {
5043 out.entry(&event.name).or_insert(params);
5044 }
5045 }
5046 }
5047 Expr::Block { items, .. } => {
5048 for item in items {
5049 collect_emission_param_types(item, binding_types, out);
5050 }
5051 }
5052 Expr::Conditional { branches, else_body, .. } => {
5053 for b in branches {
5054 collect_emission_param_types(&b.body, binding_types, out);
5055 }
5056 if let Some(body) = else_body {
5057 collect_emission_param_types(body, binding_types, out);
5058 }
5059 }
5060 _ => {}
5061 }
5062}
5063
5064fn unwrap_type_refinement(expr: &Expr) -> &Expr {
5074 let mut cur = expr;
5075 loop {
5076 cur = match cur {
5077 Expr::Where { source, .. } | Expr::With { source, .. } => source,
5078 Expr::TypeOptional { inner, .. } => inner,
5079 other => return other,
5080 };
5081 }
5082}
5083
5084fn qualified_context_binding<'a>(
5085 expr: &'a Expr,
5086 alias: &str,
5087 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5088 out: &mut HashMap<&'a str, &'a str>,
5089) {
5090 match expr {
5091 Expr::Binding { name, value, .. } => {
5092 let type_expr = unwrap_type_refinement(value.as_ref());
5095 if let Expr::QualifiedName(q) = type_expr {
5096 if q.qualifier.as_deref() == Some(alias) {
5097 if let Some((entity, _)) = status_by_entity.get_key_value(q.name.as_str()) {
5098 out.insert(&name.name, entity);
5099 }
5100 }
5101 }
5102 }
5103 Expr::Block { items, .. } => {
5104 for item in items {
5105 qualified_context_binding(item, alias, status_by_entity, out);
5106 }
5107 }
5108 _ => {}
5109 }
5110}
5111
5112fn collect_provides_param_types<'a>(
5115 expr: &'a Expr,
5116 alias: &str,
5117 context_types: &HashMap<&'a str, &'a str>,
5118 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5119 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
5120) {
5121 match expr {
5122 Expr::Call { function, args, .. } => {
5123 if let Expr::Ident(fn_name) = function.as_ref() {
5124 let params: Vec<Option<&str>> = args
5125 .iter()
5126 .map(|arg| match arg {
5127 CallArg::Positional(Expr::Ident(id)) => {
5128 context_types.get(id.name.as_str()).copied()
5129 }
5130 CallArg::Named(n) => match unwrap_type_refinement(&n.value) {
5131 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => {
5132 status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
5133 }
5134 Expr::Ident(v) => context_types.get(v.name.as_str()).copied(),
5135 _ => None,
5136 },
5137 _ => None,
5138 })
5139 .collect();
5140 if params.iter().any(Option::is_some) {
5141 out.insert(&fn_name.name, params);
5142 }
5143 }
5144 }
5145 Expr::WhenGuard { action, .. } => {
5146 collect_provides_param_types(action, alias, context_types, status_by_entity, out)
5147 }
5148 Expr::Block { items, .. } => {
5149 for item in items {
5150 collect_provides_param_types(item, alias, context_types, status_by_entity, out);
5151 }
5152 }
5153 Expr::Conditional { branches, else_body, .. } => {
5154 for b in branches {
5155 collect_provides_param_types(&b.body, alias, context_types, status_by_entity, out);
5156 }
5157 if let Some(body) = else_body {
5158 collect_provides_param_types(body, alias, context_types, status_by_entity, out);
5159 }
5160 }
5161 Expr::For { body, .. } => {
5162 collect_provides_param_types(body, alias, context_types, status_by_entity, out)
5163 }
5164 _ => {}
5165 }
5166}
5167
5168fn collect_qualified_created(
5171 expr: &Expr,
5172 alias: &str,
5173 status_by_entity: &HashMap<&str, HashSet<&str>>,
5174 out: &mut HashMap<String, HashSet<String>>,
5175) {
5176 match expr {
5177 Expr::Call { function, args, .. } => {
5178 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
5179 if field.name == "created" {
5180 if let Expr::QualifiedName(q) = object.as_ref() {
5181 if q.qualifier.as_deref() == Some(alias) {
5182 if let Some(statuses) = status_by_entity.get(q.name.as_str()) {
5183 for arg in args {
5184 if let CallArg::Named(named) = arg {
5185 if named.name.name == "status" {
5186 if let Expr::Ident(val) = &named.value {
5187 if statuses.contains(val.name.as_str()) {
5188 out.entry(q.name.clone())
5189 .or_default()
5190 .insert(val.name.clone());
5191 }
5192 }
5193 }
5194 }
5195 }
5196 }
5197 }
5198 }
5199 }
5200 }
5201 }
5202 Expr::Block { items, .. } => {
5203 for item in items {
5204 collect_qualified_created(item, alias, status_by_entity, out);
5205 }
5206 }
5207 Expr::Conditional { branches, else_body, .. } => {
5208 for b in branches {
5209 collect_qualified_created(&b.body, alias, status_by_entity, out);
5210 }
5211 if let Some(body) = else_body {
5212 collect_qualified_created(body, alias, status_by_entity, out);
5213 }
5214 }
5215 _ => {}
5216 }
5217}
5218
5219fn collect_witnessed_transition(
5230 rule: &BlockDecl,
5231 alias: &str,
5232 command_param_types: &HashMap<&str, Vec<Option<&str>>>,
5233 status_by_entity: &HashMap<&str, HashSet<&str>>,
5234 out: &mut ReverseContributions,
5235) {
5236 let mut binding_entity: HashMap<&str, &str> = HashMap::new();
5243 let mut trigger_source: HashMap<&str, &str> = HashMap::new();
5244 for item in &rule.items {
5245 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5246 continue;
5247 };
5248 if keyword != "when" {
5249 continue;
5250 }
5251 match value {
5252 Expr::Call { function, args, .. } => {
5253 let trigger_name = match function.as_ref() {
5259 Expr::QualifiedName(q) if q.qualifier.as_deref() == Some(alias) => Some(&q.name),
5260 Expr::Ident(id) => Some(&id.name),
5261 _ => None,
5262 };
5263 if let Some(name) = trigger_name {
5264 if let Some(params) = command_param_types.get(name.as_str()) {
5265 for (arg, param) in args.iter().zip(params) {
5266 if let (CallArg::Positional(Expr::Ident(b)), Some(entity)) = (arg, param)
5267 {
5268 binding_entity.insert(b.name.as_str(), entity);
5269 }
5270 }
5271 }
5272 }
5273 }
5274 Expr::Binding { name, value: inner, .. } => {
5275 if let Some((entity, source)) =
5276 qualified_transition_trigger(inner, alias, status_by_entity)
5277 {
5278 binding_entity.insert(name.name.as_str(), entity);
5279 trigger_source.insert(name.name.as_str(), source);
5280 } else if let Some(entity) =
5281 qualified_temporal_trigger_entity(inner, alias, status_by_entity)
5282 {
5283 binding_entity.insert(name.name.as_str(), entity);
5286 }
5287 }
5288 _ => {}
5289 }
5290 }
5291 if binding_entity.is_empty() {
5292 return;
5293 }
5294
5295 let mut froms: HashMap<&str, HashSet<&str>> = HashMap::new();
5298 let mut tos: HashMap<&str, HashSet<&str>> = HashMap::new();
5299 for (binding, source) in &trigger_source {
5300 froms.entry(binding).or_default().insert(source);
5301 }
5302 for_each_rule_clause(&rule.items, &mut |keyword, value| {
5306 let target = match keyword {
5307 "requires" => &mut froms,
5308 "ensures" => &mut tos,
5309 _ => return,
5310 };
5311 collect_binding_status_eq(value, &mut |binding, status| {
5312 target.entry(binding).or_default().insert(status);
5313 });
5314 });
5315
5316 for (binding, entity) in &binding_entity {
5317 let Some(valid) = status_by_entity.get(*entity) else {
5318 continue;
5319 };
5320 let Some(to_set) = tos.get(binding) else {
5321 continue;
5322 };
5323 for to in to_set {
5324 if !valid.contains(to) {
5325 continue;
5326 }
5327 out.assigned_statuses
5328 .entry((*entity).to_string())
5329 .or_default()
5330 .insert((*to).to_string());
5331 if let Some(from_set) = froms.get(binding) {
5332 for from in from_set {
5333 if valid.contains(from) {
5334 out.witnessed_transitions
5335 .entry((*entity).to_string())
5336 .or_default()
5337 .insert(((*from).to_string(), (*to).to_string()));
5338 }
5339 }
5340 }
5341 }
5342 }
5343}
5344
5345fn qualified_transition_trigger<'a>(
5351 expr: &'a Expr,
5352 alias: &str,
5353 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5354) -> Option<(&'a str, &'a str)> {
5355 let (subject, new_state) = match expr {
5356 Expr::Becomes { subject, new_state, .. }
5357 | Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
5358 _ => return None,
5359 };
5360 let Expr::MemberAccess { object, field, .. } = subject else {
5361 return None;
5362 };
5363 if field.name != "status" {
5364 return None;
5365 }
5366 let Expr::QualifiedName(q) = object.as_ref() else {
5367 return None;
5368 };
5369 if q.qualifier.as_deref() != Some(alias) {
5370 return None;
5371 }
5372 let (entity, values) = status_by_entity.get_key_value(q.name.as_str())?;
5373 let source = expr_as_ident(new_state)?;
5374 if !values.contains(source) {
5375 return None;
5376 }
5377 Some((*entity, source))
5378}
5379
5380fn qualified_temporal_trigger_entity<'a>(
5388 expr: &'a Expr,
5389 alias: &str,
5390 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5391) -> Option<&'a str> {
5392 fn member_entity<'a>(
5393 e: &'a Expr,
5394 alias: &str,
5395 status_by_entity: &HashMap<&'a str, HashSet<&'a str>>,
5396 ) -> Option<&'a str> {
5397 let Expr::MemberAccess { object, .. } = e else {
5398 return None;
5399 };
5400 let Expr::QualifiedName(q) = object.as_ref() else {
5401 return None;
5402 };
5403 if q.qualifier.as_deref() != Some(alias) {
5404 return None;
5405 }
5406 status_by_entity.get_key_value(q.name.as_str()).map(|(k, _)| *k)
5407 }
5408 match expr {
5409 Expr::Comparison { left, right, .. } => member_entity(left, alias, status_by_entity)
5410 .or_else(|| member_entity(right, alias, status_by_entity)),
5411 _ => None,
5412 }
5413}
5414
5415fn local_transition_trigger_source(expr: &Expr) -> Option<(&str, &str, &str)> {
5421 let Expr::Binding { name, value, .. } = expr else {
5422 return None;
5423 };
5424 let (subject, new_state) = match value.as_ref() {
5425 Expr::Becomes { subject, new_state, .. }
5426 | Expr::TransitionsTo { subject, new_state, .. } => (subject.as_ref(), new_state.as_ref()),
5427 _ => return None,
5428 };
5429 let Expr::MemberAccess { object, field, .. } = subject else {
5430 return None;
5431 };
5432 if field.name != "status" {
5433 return None;
5434 }
5435 Some((name.name.as_str(), expr_as_ident(object)?, expr_as_ident(new_state)?))
5436}
5437
5438fn collect_binding_status_eq<'a>(expr: &'a Expr, cb: &mut impl FnMut(&'a str, &'a str)) {
5441 match expr {
5442 Expr::Comparison { left, op: ComparisonOp::Eq, right, .. } => {
5443 if let (Some((binding, "status")), Some(status)) =
5444 (expr_as_member_access(left), expr_as_ident(right))
5445 {
5446 cb(binding, status);
5447 }
5448 }
5449 Expr::LogicalOp { left, right, .. } => {
5450 collect_binding_status_eq(left, cb);
5451 collect_binding_status_eq(right, cb);
5452 }
5453 Expr::Block { items, .. } => {
5454 for item in items {
5455 collect_binding_status_eq(item, cb);
5456 }
5457 }
5458 Expr::Conditional { branches, else_body, .. } => {
5459 for b in branches {
5460 collect_binding_status_eq(&b.body, cb);
5461 }
5462 if let Some(body) = else_body {
5463 collect_binding_status_eq(body, cb);
5464 }
5465 }
5466 _ => {}
5467 }
5468}
5469
5470pub fn collect_entity_field_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
5476 let mut out: HashMap<String, HashSet<String>> = HashMap::new();
5477 for (name, fields) in collect_local_type_schemas(module) {
5478 out.insert(
5479 name.to_string(),
5480 fields.keys().map(|f| f.to_string()).collect(),
5481 );
5482 }
5483 out
5484}
5485
5486pub fn collect_entity_status_schemas(module: &Module) -> HashMap<String, HashSet<String>> {
5491 EntityInfo::from_module(module)
5492 .status_by_entity()
5493 .into_iter()
5494 .map(|(name, statuses)| {
5495 (
5496 name.to_string(),
5497 statuses.into_iter().map(|s| s.to_string()).collect(),
5498 )
5499 })
5500 .collect()
5501}
5502
5503struct QRef<'a> {
5506 qualifier: &'a str,
5507 name: &'a str,
5508 span: Span,
5509 collection: bool,
5517}
5518
5519fn collect_qrefs_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<QRef<'a>>) {
5520 match kind {
5521 BlockItemKind::Clause { value, .. }
5522 | BlockItemKind::Assignment { value, .. }
5523 | BlockItemKind::ParamAssignment { value, .. }
5524 | BlockItemKind::PathAssignment { value, .. }
5525 | BlockItemKind::InvariantBlock { body: value, .. }
5526 | BlockItemKind::FieldWithWhen { value, .. } => {
5527 collect_qrefs_from_expr(value, out);
5528 }
5529 BlockItemKind::Let { value, .. } => {
5533 collect_qrefs_from_collection(value, out);
5534 }
5535 BlockItemKind::ForBlock {
5536 collection,
5537 filter,
5538 items,
5539 ..
5540 } => {
5541 collect_qrefs_from_collection(collection, out);
5542 if let Some(f) = filter {
5543 collect_qrefs_from_expr(f, out);
5544 }
5545 for item in items {
5546 collect_qrefs_from_item(&item.kind, out);
5547 }
5548 }
5549 BlockItemKind::IfBlock {
5550 branches,
5551 else_items,
5552 } => {
5553 for b in branches {
5554 collect_qrefs_from_expr(&b.condition, out);
5555 for item in &b.items {
5556 collect_qrefs_from_item(&item.kind, out);
5557 }
5558 }
5559 if let Some(items) = else_items {
5560 for item in items {
5561 collect_qrefs_from_item(&item.kind, out);
5562 }
5563 }
5564 }
5565 BlockItemKind::ContractsClause { entries } => {
5566 for e in entries {
5567 if let Some(qualifier) = &e.qualifier {
5568 out.push(QRef { qualifier, name: &e.name.name, span: e.name.span, collection: false });
5569 }
5570 }
5571 }
5572 _ => {}
5575 }
5576}
5577
5578fn collect_qrefs_from_expr<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
5579 match expr {
5580 Expr::QualifiedName(q) => {
5581 if let Some(qualifier) = &q.qualifier {
5582 out.push(QRef { qualifier, name: &q.name, span: q.span, collection: false });
5583 }
5584 }
5585 Expr::MemberAccess { object, field, .. }
5586 | Expr::OptionalAccess { object, field, .. } => {
5587 if let Expr::Ident(id) = object.as_ref() {
5589 if starts_uppercase(&field.name) {
5590 out.push(QRef { qualifier: &id.name, name: &field.name, span: id.span.merge(field.span), collection: false });
5591 }
5592 }
5593 collect_qrefs_from_expr(object, out);
5594 }
5595 Expr::Call { function, args, .. } => {
5596 collect_qrefs_from_expr(function, out);
5597 for a in args {
5598 match a {
5599 CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
5600 CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
5601 }
5602 }
5603 }
5604 Expr::JoinLookup { entity, fields, .. } => {
5605 collect_qrefs_from_expr(entity, out);
5606 for f in fields {
5607 if let Some(v) = &f.value {
5608 collect_qrefs_from_expr(v, out);
5609 }
5610 }
5611 }
5612 Expr::BinaryOp { left, right, .. }
5613 | Expr::Comparison { left, right, .. }
5614 | Expr::LogicalOp { left, right, .. }
5615 | Expr::Pipe { left, right, .. }
5616 | Expr::NullCoalesce { left, right, .. } => {
5617 collect_qrefs_from_expr(left, out);
5618 collect_qrefs_from_expr(right, out);
5619 }
5620 Expr::Not { operand, .. }
5621 | Expr::Exists { operand, .. }
5622 | Expr::NotExists { operand, .. }
5623 | Expr::TypeOptional { inner: operand, .. } => {
5624 collect_qrefs_from_expr(operand, out);
5625 }
5626 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5627 collect_qrefs_from_expr(element, out);
5628 collect_qrefs_from_collection(collection, out);
5629 }
5630 Expr::Where { source, condition, .. }
5631 | Expr::With {
5632 source,
5633 predicate: condition,
5634 ..
5635 } => {
5636 collect_qrefs_from_collection(source, out);
5637 collect_qrefs_from_expr(condition, out);
5638 }
5639 Expr::WhenGuard { action, condition, .. } => {
5640 collect_qrefs_from_expr(action, out);
5641 collect_qrefs_from_expr(condition, out);
5642 }
5643 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
5644 collect_qrefs_from_expr(value, out);
5645 }
5646 Expr::Block { items, .. } => {
5647 for item in items {
5648 collect_qrefs_from_expr(item, out);
5649 }
5650 }
5651 Expr::Conditional {
5652 branches,
5653 else_body,
5654 ..
5655 } => {
5656 for b in branches {
5657 collect_qrefs_from_expr(&b.condition, out);
5658 collect_qrefs_from_expr(&b.body, out);
5659 }
5660 if let Some(body) = else_body {
5661 collect_qrefs_from_expr(body, out);
5662 }
5663 }
5664 Expr::For {
5665 collection,
5666 filter,
5667 body,
5668 ..
5669 } => {
5670 collect_qrefs_from_collection(collection, out);
5671 if let Some(f) = filter {
5672 collect_qrefs_from_expr(f, out);
5673 }
5674 collect_qrefs_from_expr(body, out);
5675 }
5676 Expr::Lambda { body, .. } => {
5677 collect_qrefs_from_expr(body, out);
5678 }
5679 Expr::TransitionsTo {
5680 subject, new_state, ..
5681 }
5682 | Expr::Becomes {
5683 subject, new_state, ..
5684 } => {
5685 collect_qrefs_from_expr(subject, out);
5686 collect_qrefs_from_expr(new_state, out);
5687 }
5688 Expr::GenericType { name, args, .. } => {
5689 collect_qrefs_from_expr(name, out);
5690 for a in args {
5691 collect_qrefs_from_expr(a, out);
5692 }
5693 }
5694 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
5695 for e in elements {
5696 collect_qrefs_from_expr(e, out);
5697 }
5698 }
5699 Expr::ObjectLiteral { fields, .. } => {
5700 for f in fields {
5701 collect_qrefs_from_expr(&f.value, out);
5702 }
5703 }
5704 Expr::ProjectionMap { source, .. } => {
5705 collect_qrefs_from_expr(source, out);
5706 }
5707 _ => {}
5708 }
5709}
5710
5711fn collect_qrefs_from_collection<'a>(expr: &'a Expr, out: &mut Vec<QRef<'a>>) {
5719 match expr {
5720 Expr::QualifiedName(q) => {
5721 if let Some(qualifier) = &q.qualifier {
5722 out.push(QRef { qualifier, name: &q.name, span: q.span, collection: true });
5723 }
5724 }
5725 Expr::Where { source, condition, .. }
5728 | Expr::With {
5729 source,
5730 predicate: condition,
5731 ..
5732 } => {
5733 collect_qrefs_from_collection(source, out);
5734 collect_qrefs_from_expr(condition, out);
5735 }
5736 other => collect_qrefs_from_expr(other, out),
5739 }
5740}
5741
5742impl Ctx<'_> {
5747 fn check_deferred_location_hints(&mut self, source: &str) {
5748 for d in &self.module.declarations {
5749 let Decl::Deferred(def) = d else {
5750 continue;
5751 };
5752 const LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
5773 let bytes = source.as_bytes();
5774 let kw_start = def.span.start;
5775 let line_start = source[..kw_start]
5776 .rfind(LINE_TERMINATORS)
5777 .map_or(0, |i| {
5778 i + source[i..].chars().next().map_or(1, char::len_utf8)
5779 });
5780 let mut name_start = kw_start + "deferred".len();
5781 while bytes.get(name_start).is_some_and(u8::is_ascii_whitespace) {
5782 name_start += 1;
5783 }
5784 let starts_name =
5785 |b: &u8| b.is_ascii_alphabetic() || *b == b'_';
5786 let continues_name =
5787 |b: &u8| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.';
5788 if !bytes[line_start..kw_start]
5789 .iter()
5790 .all(|b| b.is_ascii_whitespace())
5791 || name_start == kw_start + "deferred".len()
5792 || !bytes.get(name_start).is_some_and(starts_name)
5793 {
5794 continue;
5798 }
5799 let mut name_end = name_start + 1;
5800 while bytes.get(name_end).is_some_and(continues_name) {
5801 name_end += 1;
5802 }
5803 let line_end = source[name_end..]
5804 .find(LINE_TERMINATORS)
5805 .map_or(source.len(), |i| name_end + i);
5806 let suffix = &source[name_end..line_end];
5807 if suffix.contains('"')
5808 || suffix.contains("http://")
5809 || suffix.contains("https://")
5810 || suffix.contains("-- see:")
5811 {
5812 continue;
5813 }
5814 self.push(
5815 Diagnostic::warning(
5816 def.span,
5817 format!(
5818 "Deferred specification '{}' should include a location hint.",
5819 &source[name_start..name_end],
5820 ),
5821 )
5822 .with_code("allium.deferred.missingLocationHint"),
5823 );
5824 }
5825 }
5826}
5827
5828impl Ctx<'_> {
5833 fn check_rule_invalid_triggers(&mut self) {
5834 for rule in self.blocks(BlockKind::Rule) {
5835 let rule_name = match &rule.name {
5836 Some(n) => &n.name,
5837 None => continue,
5838 };
5839
5840 for item in &rule.items {
5841 let BlockItemKind::Clause { keyword, value } = &item.kind else {
5842 continue;
5843 };
5844 if keyword != "when" {
5845 continue;
5846 }
5847 if !is_valid_trigger(value) {
5848 self.push(
5849 Diagnostic::error(
5850 item.span,
5851 format!(
5852 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
5853 ),
5854 )
5855 .with_code("allium.rule.invalidTrigger"),
5856 );
5857 } else if let Some((param, span)) = first_named_trigger_param(value) {
5858 self.push(
5859 Diagnostic::error(
5860 span,
5861 format!(
5862 "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.",
5863 ),
5864 )
5865 .with_code("allium.rule.invalidTrigger"),
5866 );
5867 }
5868 }
5869 }
5870 }
5871}
5872
5873fn first_named_trigger_param(expr: &Expr) -> Option<(&str, Span)> {
5879 match expr {
5880 Expr::Call { args, .. } => args.iter().find_map(|arg| match arg {
5881 CallArg::Named(n) => Some((n.name.name.as_str(), n.span)),
5882 _ => None,
5883 }),
5884 Expr::LogicalOp {
5885 op: LogicalOp::Or,
5886 left,
5887 right,
5888 ..
5889 } => first_named_trigger_param(left).or_else(|| first_named_trigger_param(right)),
5890 _ => None,
5891 }
5892}
5893
5894fn literal_kind(expr: &Expr) -> Option<&'static str> {
5904 match expr {
5905 Expr::StringLiteral(_) => Some("string"),
5906 Expr::NumberLiteral { .. } => Some("number"),
5907 Expr::BoolLiteral { .. } => Some("boolean"),
5908 Expr::DurationLiteral { .. } => Some("duration"),
5909 Expr::BacktickLiteral { .. } => Some("backtick literal"),
5910 _ => None,
5911 }
5912}
5913
5914impl Ctx<'_> {
5915 fn check_list_literal_homogeneity(&mut self) {
5916 let mut lists: Vec<(&[Expr], Span)> = Vec::new();
5917 for d in &self.module.declarations {
5918 match d {
5919 Decl::Block(b) => {
5920 for item in &b.items {
5921 collect_list_literals_from_item(&item.kind, &mut lists);
5922 }
5923 }
5924 Decl::Variant(v) => {
5925 for item in &v.items {
5926 collect_list_literals_from_item(&item.kind, &mut lists);
5927 }
5928 }
5929 Decl::Invariant(inv) => collect_list_literals_from_expr(&inv.body, &mut lists),
5930 Decl::Default(def) => collect_list_literals_from_expr(&def.value, &mut lists),
5931 _ => {}
5932 }
5933 }
5934
5935 for (elements, span) in lists {
5936 let mut first: Option<&'static str> = None;
5940 for e in elements {
5941 let Some(kind) = literal_kind(e) else { continue };
5942 match first {
5943 None => first = Some(kind),
5944 Some(expected) if expected != kind => {
5945 self.push(
5946 Diagnostic::error(
5947 span,
5948 format!(
5949 "List literal has elements of differing types ('{expected}' and '{kind}'); all elements of a list must share a type.",
5950 ),
5951 )
5952 .with_code("allium.list.mixedElementTypes"),
5953 );
5954 break;
5955 }
5956 _ => {}
5957 }
5958 }
5959 }
5960 }
5961}
5962
5963impl Ctx<'_> {
5964
5965 fn check_undefined_import_aliases(&mut self) {
5974 let aliases: HashSet<&str> = self
5975 .module
5976 .declarations
5977 .iter()
5978 .filter_map(|d| match d {
5979 Decl::Use(u) => u.alias.as_ref().map(|a| a.name.as_str()),
5980 _ => None,
5981 })
5982 .collect();
5983
5984 let mut refs = collect_qref_nodes(self.module);
5985 for d in &self.module.declarations {
5988 if let Decl::Default(def) = d {
5989 if let (Some(a), Some(t)) = (&def.type_alias, &def.type_name) {
5990 refs.push(QRef {
5991 qualifier: &a.name,
5992 name: &t.name,
5993 span: a.span.merge(t.span),
5994 collection: false,
5995 });
5996 }
5997 }
5998 }
5999
6000 for r in refs {
6001 if !aliases.contains(r.qualifier) {
6002 self.push(
6003 Diagnostic::error(
6004 r.span,
6005 format!(
6006 "Reference '{}/{}' uses unknown import alias '{}'.",
6007 r.qualifier, r.name, r.qualifier
6008 ),
6009 )
6010 .with_code("allium.reference.undefinedImportedAlias"),
6011 );
6012 } else if let Some(offered) = self
6013 .imported_referenced_triggers
6014 .and_then(|m| m.get(r.qualifier))
6015 {
6016 if !r.collection && !offered.contains(r.name) {
6023 self.push(
6024 Diagnostic::warning(
6025 r.span,
6026 format!(
6027 "Reference '{}/{}' names '{}', which imported module '{}' does not define.",
6028 r.qualifier, r.name, r.name, r.qualifier
6029 ),
6030 )
6031 .with_code("allium.reference.unknownName"),
6032 );
6033 }
6034 }
6035 }
6036 }
6037
6038 fn check_default_field_schemas(&mut self) {
6049 let schemas = collect_local_type_schemas(self.module);
6050 let mut diagnostics = Vec::new();
6051 for d in &self.module.declarations {
6052 let Decl::Default(def) = d else { continue };
6053 let (Some(type_name), Expr::ObjectLiteral { fields, .. }) =
6054 (&def.type_name, &def.value)
6055 else {
6056 continue;
6057 };
6058 match &def.type_alias {
6059 None => {
6060 validate_object_literal(fields, &type_name.name, &schemas, &mut diagnostics);
6061 }
6062 Some(alias) => {
6063 if let Some(imported) = self
6071 .imported_entity_fields
6072 .and_then(|m| m.get(alias.name.as_str()))
6073 .and_then(|types| types.get(type_name.name.as_str()))
6074 {
6075 for field in fields {
6076 if !imported.contains(field.name.name.as_str()) {
6077 diagnostics.push(
6078 Diagnostic::error(
6079 field.name.span,
6080 format!(
6081 "Default sets field '{}' which is not declared on '{}/{}'.",
6082 field.name.name, alias.name, type_name.name
6083 ),
6084 )
6085 .with_code("allium.default.unknownField"),
6086 );
6087 }
6088 }
6089 }
6090 }
6091 }
6092 }
6093 for diag in diagnostics {
6094 self.push(diag);
6095 }
6096 }
6097}
6098
6099fn collect_local_type_schemas(module: &Module) -> HashMap<&str, HashMap<&str, &Expr>> {
6101 let mut schemas: HashMap<&str, HashMap<&str, &Expr>> = HashMap::new();
6102 for d in &module.declarations {
6103 let Decl::Block(b) = d else { continue };
6104 if !matches!(
6105 b.kind,
6106 BlockKind::Entity | BlockKind::ExternalEntity | BlockKind::Value
6107 ) {
6108 continue;
6109 }
6110 let Some(name) = &b.name else { continue };
6111 let mut fields: HashMap<&str, &Expr> = HashMap::new();
6112 for item in &b.items {
6113 match &item.kind {
6114 BlockItemKind::Assignment { name: f, value }
6115 | BlockItemKind::FieldWithWhen { name: f, value, .. } => {
6116 fields.insert(f.name.as_str(), value);
6117 }
6118 _ => {}
6119 }
6120 }
6121 schemas.insert(name.name.as_str(), fields);
6122 }
6123 schemas
6124}
6125
6126fn is_list_type(expr: &Expr) -> bool {
6129 match expr {
6130 Expr::GenericType { name, .. } => matches!(name.as_ref(), Expr::Ident(id) if id.name == "List"),
6131 Expr::TypeOptional { inner, .. } => is_list_type(inner),
6132 _ => false,
6133 }
6134}
6135
6136fn base_type_name(expr: &Expr) -> Option<&str> {
6140 match expr {
6141 Expr::Ident(id) => Some(id.name.as_str()),
6142 Expr::TypeOptional { inner, .. } => base_type_name(inner),
6143 _ => None,
6144 }
6145}
6146
6147fn validate_object_literal<'a>(
6148 fields: &'a [NamedArg],
6149 type_name: &str,
6150 schemas: &HashMap<&'a str, HashMap<&'a str, &'a Expr>>,
6151 out: &mut Vec<Diagnostic>,
6152) {
6153 let Some(schema) = schemas.get(type_name) else { return };
6156 for field in fields {
6157 let Some(field_type) = schema.get(field.name.name.as_str()) else {
6158 out.push(
6159 Diagnostic::error(
6160 field.name.span,
6161 format!(
6162 "Default sets field '{}' which is not declared on '{}'.",
6163 field.name.name, type_name
6164 ),
6165 )
6166 .with_code("allium.default.unknownField"),
6167 );
6168 continue;
6169 };
6170 if let Expr::ListLiteral { elements, span } = &field.value {
6173 if elements.is_empty() && !is_list_type(field_type) {
6174 out.push(
6175 Diagnostic::error(
6176 *span,
6177 format!(
6178 "Empty list literal has no inferable element type: target field '{}' is not a List<T>.",
6179 field.name.name
6180 ),
6181 )
6182 .with_code("allium.list.emptyListNoElementType"),
6183 );
6184 }
6185 }
6186 if let Expr::ObjectLiteral { fields: nested, .. } = &field.value {
6188 if let Some(nested_type) = base_type_name(field_type) {
6189 validate_object_literal(nested, nested_type, schemas, out);
6190 }
6191 }
6192 }
6193}
6194
6195fn collect_list_literals_from_item<'a>(kind: &'a BlockItemKind, out: &mut Vec<(&'a [Expr], Span)>) {
6196 match kind {
6197 BlockItemKind::Clause { value, .. }
6198 | BlockItemKind::Assignment { value, .. }
6199 | BlockItemKind::ParamAssignment { value, .. }
6200 | BlockItemKind::Let { value, .. }
6201 | BlockItemKind::PathAssignment { value, .. }
6202 | BlockItemKind::InvariantBlock { body: value, .. }
6203 | BlockItemKind::FieldWithWhen { value, .. } => {
6204 collect_list_literals_from_expr(value, out);
6205 }
6206 BlockItemKind::ForBlock { collection, filter, items, .. } => {
6207 collect_list_literals_from_expr(collection, out);
6208 if let Some(f) = filter {
6209 collect_list_literals_from_expr(f, out);
6210 }
6211 for item in items {
6212 collect_list_literals_from_item(&item.kind, out);
6213 }
6214 }
6215 BlockItemKind::IfBlock { branches, else_items } => {
6216 for b in branches {
6217 collect_list_literals_from_expr(&b.condition, out);
6218 for item in &b.items {
6219 collect_list_literals_from_item(&item.kind, out);
6220 }
6221 }
6222 if let Some(items) = else_items {
6223 for item in items {
6224 collect_list_literals_from_item(&item.kind, out);
6225 }
6226 }
6227 }
6228 _ => {}
6229 }
6230}
6231
6232fn collect_list_literals_from_expr<'a>(expr: &'a Expr, out: &mut Vec<(&'a [Expr], Span)>) {
6233 if let Expr::ListLiteral { elements, span } = expr {
6234 out.push((elements, *span));
6235 }
6236 walk_expr_children(expr, &mut |child| collect_list_literals_from_expr(child, out));
6237}
6238
6239fn walk_expr_children<'a>(expr: &'a Expr, f: &mut impl FnMut(&'a Expr)) {
6243 match expr {
6244 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => f(object),
6245 Expr::Call { function, args, .. } => {
6246 f(function);
6247 for a in args {
6248 match a {
6249 CallArg::Positional(e) => f(e),
6250 CallArg::Named(n) => f(&n.value),
6251 }
6252 }
6253 }
6254 Expr::BinaryOp { left, right, .. }
6255 | Expr::Comparison { left, right, .. }
6256 | Expr::LogicalOp { left, right, .. }
6257 | Expr::Pipe { left, right, .. }
6258 | Expr::NullCoalesce { left, right, .. } => {
6259 f(left);
6260 f(right);
6261 }
6262 Expr::Not { operand, .. }
6263 | Expr::Exists { operand, .. }
6264 | Expr::NotExists { operand, .. }
6265 | Expr::TypeOptional { inner: operand, .. } => f(operand),
6266 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6267 f(element);
6268 f(collection);
6269 }
6270 Expr::Where { source, condition, .. }
6271 | Expr::With { source, predicate: condition, .. } => {
6272 f(source);
6273 f(condition);
6274 }
6275 Expr::WhenGuard { action, condition, .. } => {
6276 f(action);
6277 f(condition);
6278 }
6279 Expr::Block { items, .. } => {
6280 for item in items {
6281 f(item);
6282 }
6283 }
6284 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => f(value),
6285 Expr::Conditional { branches, else_body, .. } => {
6286 for b in branches {
6287 f(&b.condition);
6288 f(&b.body);
6289 }
6290 if let Some(body) = else_body {
6291 f(body);
6292 }
6293 }
6294 Expr::For { collection, filter, body, .. } => {
6295 f(collection);
6296 if let Some(filt) = filter {
6297 f(filt);
6298 }
6299 f(body);
6300 }
6301 Expr::Lambda { body, .. } => f(body),
6302 Expr::JoinLookup { entity, fields, .. } => {
6303 f(entity);
6304 for jf in fields {
6305 if let Some(v) = &jf.value {
6306 f(v);
6307 }
6308 }
6309 }
6310 Expr::TransitionsTo { subject, new_state, .. }
6311 | Expr::Becomes { subject, new_state, .. } => {
6312 f(subject);
6313 f(new_state);
6314 }
6315 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
6316 for e in elements {
6317 f(e);
6318 }
6319 }
6320 Expr::ObjectLiteral { fields, .. } => {
6321 for fld in fields {
6322 f(&fld.value);
6323 }
6324 }
6325 Expr::GenericType { name, args, .. } => {
6326 f(name);
6327 for a in args {
6328 f(a);
6329 }
6330 }
6331 Expr::ProjectionMap { source, .. } => f(source),
6332 _ => {}
6333 }
6334}
6335
6336fn is_valid_trigger(expr: &Expr) -> bool {
6337 match expr {
6338 Expr::Call { function, .. } => {
6344 matches!(
6345 function.as_ref(),
6346 Expr::Ident(_) | Expr::MemberAccess { .. } | Expr::QualifiedName(_)
6347 )
6348 }
6349 Expr::Binding { value, .. } => {
6351 matches!(
6352 value.as_ref(),
6353 Expr::Becomes { .. }
6354 | Expr::TransitionsTo { .. }
6355 | Expr::MemberAccess { .. }
6356 | Expr::Comparison { .. }
6357 )
6358 }
6359 Expr::LogicalOp {
6361 op: LogicalOp::Or,
6362 left,
6363 right,
6364 ..
6365 } => is_valid_trigger(left) && is_valid_trigger(right),
6366 Expr::Comparison { left, .. } => {
6368 matches!(left.as_ref(), Expr::MemberAccess { .. })
6369 }
6370 _ => false,
6371 }
6372}
6373
6374impl Ctx<'_> {
6379 fn check_rule_undefined_bindings(&mut self) {
6380 let mut given_bindings: HashSet<&str> = HashSet::new();
6382 for given in self.blocks(BlockKind::Given) {
6383 for item in &given.items {
6384 if let BlockItemKind::Assignment { name, .. } = &item.kind {
6385 given_bindings.insert(&name.name);
6386 }
6387 }
6388 }
6389
6390 let mut default_names: HashSet<&str> = HashSet::new();
6392 for d in &self.module.declarations {
6393 if let Decl::Default(def) = d {
6394 default_names.insert(&def.name.name);
6395 }
6396 }
6397
6398 for rule in self.blocks(BlockKind::Rule) {
6399 let rule_name = match &rule.name {
6400 Some(n) => &n.name,
6401 None => continue,
6402 };
6403
6404 let mut bound: HashSet<&str> = HashSet::new();
6405 bound.extend(&given_bindings);
6406 bound.extend(&default_names);
6407
6408 for item in &rule.items {
6410 let BlockItemKind::Clause { keyword, value } = &item.kind else {
6411 continue;
6412 };
6413 if keyword != "when" {
6414 continue;
6415 }
6416 collect_bound_names(value, &mut bound);
6417 }
6418
6419 check_unbound_in_items(&rule.items, &bound, rule_name, &mut self.diagnostics);
6426
6427 for item in &rule.items {
6431 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
6432 if keyword != "when" { continue }
6433 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
6434 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
6435 continue;
6436 }
6437 let mut found = false;
6446 for check_item in &rule.items {
6447 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
6448 if kw != "requires" && kw != "ensures" { continue }
6449 if expr_contains_ident(v, &binding_name.name) {
6450 self.push(
6451 Diagnostic::error(
6452 check_item.span,
6453 format!(
6454 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
6455 binding_name.name
6456 ),
6457 )
6458 .with_code("allium.rule.undefinedBinding"),
6459 );
6460 found = true;
6461 break;
6462 }
6463 }
6464 if found { break; }
6465 }
6466 }
6467 }
6468}
6469
6470fn check_unbound_in_items<'a>(
6476 items: &'a [BlockItem],
6477 parent_bound: &HashSet<&'a str>,
6478 rule_name: &str,
6479 diagnostics: &mut Vec<Diagnostic>,
6480) {
6481 let mut bound: HashSet<&'a str> = parent_bound.clone();
6482 for item in items {
6483 if let BlockItemKind::Let { name, .. } = &item.kind {
6484 bound.insert(&name.name);
6485 }
6486 }
6487 for item in items {
6488 match &item.kind {
6489 BlockItemKind::Clause { keyword, value } => {
6490 if keyword == "requires" || keyword == "ensures" {
6491 check_unbound_roots(value, &bound, rule_name, diagnostics);
6492 }
6493 }
6494 BlockItemKind::IfBlock { branches, else_items } => {
6495 for b in branches {
6496 check_unbound_in_items(&b.items, &bound, rule_name, diagnostics);
6497 }
6498 if let Some(else_items) = else_items {
6499 check_unbound_in_items(else_items, &bound, rule_name, diagnostics);
6500 }
6501 }
6502 BlockItemKind::ForBlock { binding, items: for_items, .. } => {
6503 let mut inner = bound.clone();
6504 match binding {
6505 ForBinding::Single(id) => {
6506 inner.insert(&id.name);
6507 }
6508 ForBinding::Destructured(ids, _) => {
6509 for id in ids {
6510 inner.insert(&id.name);
6511 }
6512 }
6513 }
6514 check_unbound_in_items(for_items, &inner, rule_name, diagnostics);
6515 }
6516 _ => {}
6517 }
6518 }
6519}
6520
6521fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
6522 match expr {
6523 Expr::Binding { name, .. } => {
6524 out.insert(&name.name);
6525 }
6526 Expr::Call { args, .. } => {
6527 for arg in args {
6528 match arg {
6529 CallArg::Positional(Expr::Ident(id)) => {
6530 out.insert(&id.name);
6531 }
6532 CallArg::Named(n) => {
6538 out.insert(&n.name.name);
6539 }
6540 _ => {}
6541 }
6542 }
6543 }
6544 Expr::LogicalOp { left, right, .. } => {
6545 collect_bound_names(left, out);
6546 collect_bound_names(right, out);
6547 }
6548 _ => {}
6549 }
6550}
6551
6552fn check_unbound_roots(
6553 expr: &Expr,
6554 bound: &HashSet<&str>,
6555 rule_name: &str,
6556 diagnostics: &mut Vec<Diagnostic>,
6557) {
6558 match expr {
6559 Expr::MemberAccess { object, .. } => {
6560 if let Expr::Ident(id) = object.as_ref() {
6561 if !starts_uppercase(&id.name)
6562 && !bound.contains(id.name.as_str())
6563 && !is_builtin_name(&id.name)
6564 {
6565 diagnostics.push(
6566 Diagnostic::error(
6567 id.span,
6568 format!(
6569 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
6570 id.name
6571 ),
6572 )
6573 .with_code("allium.rule.undefinedBinding"),
6574 );
6575 }
6576 }
6577 }
6578 Expr::Comparison { left, right, .. } => {
6579 check_unbound_roots(left, bound, rule_name, diagnostics);
6580 check_unbound_roots(right, bound, rule_name, diagnostics);
6581 }
6582 Expr::LogicalOp { left, right, .. } => {
6583 check_unbound_roots(left, bound, rule_name, diagnostics);
6584 check_unbound_roots(right, bound, rule_name, diagnostics);
6585 }
6586 Expr::Block { items, .. } => {
6587 let mut block_bound = bound.clone();
6588 for item in items {
6589 if let Expr::LetExpr { name, value, .. } = item {
6590 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
6591 block_bound.insert(name.name.as_str());
6592 } else {
6593 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
6594 }
6595 }
6596 }
6597 Expr::For { binding, collection, body, .. } => {
6598 check_unbound_roots(collection, bound, rule_name, diagnostics);
6599 let mut inner = bound.clone();
6601 match binding {
6602 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
6603 ForBinding::Destructured(ids, _) => {
6604 for id in ids {
6605 inner.insert(id.name.as_str());
6606 }
6607 }
6608 }
6609 check_unbound_roots(body, &inner, rule_name, diagnostics);
6610 }
6611 Expr::BinaryOp { left, right, .. } => {
6612 check_unbound_roots(left, bound, rule_name, diagnostics);
6613 check_unbound_roots(right, bound, rule_name, diagnostics);
6614 }
6615 Expr::Call { function, args, .. } => {
6616 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
6618 check_unbound_roots(function, bound, rule_name, diagnostics);
6619 }
6620 let mut call_bound = bound.clone();
6622 for a in args {
6623 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
6624 if let Expr::Ident(id) = param.as_ref() {
6625 call_bound.insert(id.name.as_str());
6626 }
6627 }
6628 }
6629 for a in args {
6630 match a {
6631 CallArg::Positional(Expr::Lambda { body, .. }) => {
6632 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
6633 }
6634 CallArg::Positional(e) => {
6635 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
6636 }
6637 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
6638 }
6639 }
6640 }
6641 Expr::Not { operand, .. }
6642 | Expr::Exists { operand, .. }
6643 | Expr::NotExists { operand, .. } => {
6644 check_unbound_roots(operand, bound, rule_name, diagnostics);
6645 }
6646 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6647 check_unbound_roots(element, bound, rule_name, diagnostics);
6648 check_unbound_roots(collection, bound, rule_name, diagnostics);
6649 }
6650 Expr::Conditional { branches, else_body, .. } => {
6651 for b in branches {
6652 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
6653 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
6654 }
6655 if let Some(body) = else_body {
6656 check_unbound_roots(body, bound, rule_name, diagnostics);
6657 }
6658 }
6659 _ => {}
6660 }
6661}
6662
6663fn is_builtin_name(name: &str) -> bool {
6664 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
6665}
6666
6667impl Ctx<'_> {
6672 fn check_duplicate_let_bindings(&mut self) {
6673 for rule in self.blocks(BlockKind::Rule) {
6674 let mut seen: HashMap<&str, Span> = HashMap::new();
6675 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
6676 }
6677 }
6678
6679 fn check_duplicate_lets_in_items<'b>(
6680 &mut self,
6681 items: &'b [BlockItem],
6682 seen: &mut HashMap<&'b str, Span>,
6683 ) {
6684 for item in items {
6685 match &item.kind {
6686 BlockItemKind::Let { name, .. } => {
6687 if seen.contains_key(name.name.as_str()) {
6688 self.push(
6689 Diagnostic::error(
6690 name.span,
6691 format!("Duplicate let binding '{}' in this rule.", name.name),
6692 )
6693 .with_code("allium.let.duplicateBinding"),
6694 );
6695 } else {
6696 seen.insert(&name.name, name.span);
6697 }
6698 }
6699 BlockItemKind::ForBlock { items, .. } => {
6700 self.check_duplicate_lets_in_items(items, seen);
6701 }
6702 BlockItemKind::IfBlock {
6703 branches,
6704 else_items,
6705 } => {
6706 for b in branches {
6707 self.check_duplicate_lets_in_items(&b.items, seen);
6708 }
6709 if let Some(items) = else_items {
6710 self.check_duplicate_lets_in_items(items, seen);
6711 }
6712 }
6713 BlockItemKind::Clause { value, .. } => {
6714 self.check_duplicate_lets_in_expr(value, seen);
6715 }
6716 _ => {}
6717 }
6718 }
6719 }
6720
6721 fn check_duplicate_lets_in_expr<'b>(
6722 &mut self,
6723 expr: &'b Expr,
6724 seen: &mut HashMap<&'b str, Span>,
6725 ) {
6726 match expr {
6727 Expr::LetExpr { name, value, .. } => {
6728 if seen.contains_key(name.name.as_str()) {
6729 self.push(
6730 Diagnostic::error(
6731 name.span,
6732 format!("Duplicate let binding '{}' in this rule.", name.name),
6733 )
6734 .with_code("allium.let.duplicateBinding"),
6735 );
6736 } else {
6737 seen.insert(&name.name, name.span);
6738 }
6739 self.check_duplicate_lets_in_expr(value, seen);
6740 }
6741 Expr::Block { items, .. } => {
6742 for item in items {
6743 self.check_duplicate_lets_in_expr(item, seen);
6744 }
6745 }
6746 Expr::For { body, .. } => {
6747 self.check_duplicate_lets_in_expr(body, seen);
6748 }
6749 Expr::Conditional { branches, else_body, .. } => {
6750 for b in branches {
6751 self.check_duplicate_lets_in_expr(&b.body, seen);
6752 }
6753 if let Some(body) = else_body {
6754 self.check_duplicate_lets_in_expr(body, seen);
6755 }
6756 }
6757 _ => {}
6758 }
6759 }
6760}
6761
6762impl Ctx<'_> {
6767 fn check_config_undefined_references(&mut self) {
6768 let mut config_params: HashSet<&str> = HashSet::new();
6769 for config in self.blocks(BlockKind::Config) {
6770 for item in &config.items {
6771 if let BlockItemKind::Assignment { name, .. } = &item.kind {
6772 config_params.insert(&name.name);
6773 }
6774 }
6775 }
6776
6777 for d in &self.module.declarations {
6779 match d {
6780 Decl::Block(b) => {
6781 if b.kind == BlockKind::Config {
6782 continue;
6783 }
6784 for item in &b.items {
6785 self.check_config_refs_in_item(&item.kind, &config_params);
6786 }
6787 }
6788 Decl::Invariant(inv) => {
6789 self.check_config_refs_in_expr(&inv.body, &config_params);
6790 }
6791 _ => {}
6792 }
6793 }
6794 }
6795
6796 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
6797 match kind {
6798 BlockItemKind::Clause { value, .. }
6799 | BlockItemKind::Assignment { value, .. }
6800 | BlockItemKind::ParamAssignment { value, .. }
6801 | BlockItemKind::Let { value, .. }
6802 | BlockItemKind::FieldWithWhen { value, .. } => {
6803 self.check_config_refs_in_expr(value, params);
6804 }
6805 BlockItemKind::ForBlock { collection, filter, items, .. } => {
6806 self.check_config_refs_in_expr(collection, params);
6807 if let Some(f) = filter {
6808 self.check_config_refs_in_expr(f, params);
6809 }
6810 for item in items {
6811 self.check_config_refs_in_item(&item.kind, params);
6812 }
6813 }
6814 BlockItemKind::IfBlock { branches, else_items } => {
6815 for b in branches {
6816 self.check_config_refs_in_expr(&b.condition, params);
6817 for item in &b.items {
6818 self.check_config_refs_in_item(&item.kind, params);
6819 }
6820 }
6821 if let Some(items) = else_items {
6822 for item in items {
6823 self.check_config_refs_in_item(&item.kind, params);
6824 }
6825 }
6826 }
6827 _ => {}
6828 }
6829 }
6830
6831 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
6832 match expr {
6833 Expr::MemberAccess { object, field, .. } => {
6834 if let Expr::Ident(id) = object.as_ref() {
6835 if id.name == "config" && !params.contains(field.name.as_str()) {
6836 self.push(
6837 Diagnostic::warning(
6838 field.span,
6839 format!(
6840 "Config reference 'config.{}' is not declared in any config block.",
6841 field.name
6842 ),
6843 )
6844 .with_code("allium.config.undefinedReference"),
6845 );
6846 return;
6847 }
6848 }
6849 self.check_config_refs_in_expr(object, params);
6850 }
6851 Expr::Call { function, args, .. } => {
6852 self.check_config_refs_in_expr(function, params);
6853 for a in args {
6854 match a {
6855 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
6856 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
6857 }
6858 }
6859 }
6860 Expr::BinaryOp { left, right, .. }
6861 | Expr::Comparison { left, right, .. }
6862 | Expr::LogicalOp { left, right, .. }
6863 | Expr::Pipe { left, right, .. }
6864 | Expr::NullCoalesce { left, right, .. } => {
6865 self.check_config_refs_in_expr(left, params);
6866 self.check_config_refs_in_expr(right, params);
6867 }
6868 Expr::Not { operand, .. }
6869 | Expr::Exists { operand, .. }
6870 | Expr::NotExists { operand, .. } => {
6871 self.check_config_refs_in_expr(operand, params);
6872 }
6873 Expr::Block { items, .. } => {
6874 for item in items {
6875 self.check_config_refs_in_expr(item, params);
6876 }
6877 }
6878 Expr::Conditional { branches, else_body, .. } => {
6879 for b in branches {
6880 self.check_config_refs_in_expr(&b.condition, params);
6881 self.check_config_refs_in_expr(&b.body, params);
6882 }
6883 if let Some(body) = else_body {
6884 self.check_config_refs_in_expr(body, params);
6885 }
6886 }
6887 Expr::For { collection, filter, body, .. } => {
6888 self.check_config_refs_in_expr(collection, params);
6889 if let Some(f) = filter {
6890 self.check_config_refs_in_expr(f, params);
6891 }
6892 self.check_config_refs_in_expr(body, params);
6893 }
6894 Expr::LetExpr { value, .. } => {
6895 self.check_config_refs_in_expr(value, params);
6896 }
6897 Expr::Lambda { body, .. } => {
6898 self.check_config_refs_in_expr(body, params);
6899 }
6900 _ => {}
6901 }
6902 }
6903}
6904
6905fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
6910 match kind {
6911 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
6912 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
6913 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
6914 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
6915 BlockItemKind::ForBlock {
6916 collection,
6917 filter,
6918 items,
6919 ..
6920 } => {
6921 expr_contains_ident(collection, name)
6922 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
6923 || items.iter().any(|i| item_contains_ident(&i.kind, name))
6924 }
6925 BlockItemKind::IfBlock {
6926 branches,
6927 else_items,
6928 } => {
6929 branches.iter().any(|b| {
6930 expr_contains_ident(&b.condition, name)
6931 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
6932 }) || else_items
6933 .as_ref()
6934 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
6935 }
6936 BlockItemKind::PathAssignment { path, value } => {
6937 expr_contains_ident(path, name) || expr_contains_ident(value, name)
6938 }
6939 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
6940 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
6941 BlockItemKind::ContractsClause { .. }
6942 | BlockItemKind::EnumVariant { .. }
6943 | BlockItemKind::OpenQuestion { .. }
6944 | BlockItemKind::Annotation(_)
6945 | BlockItemKind::TransitionsBlock(_) => false,
6946 }
6947}
6948
6949fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
6950 match expr {
6951 Expr::Ident(id) => id.name == name,
6952 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
6953 expr_contains_ident(object, name)
6954 }
6955 Expr::Call { function, args, .. } => {
6956 expr_contains_ident(function, name)
6957 || args.iter().any(|a| match a {
6958 CallArg::Positional(e) => expr_contains_ident(e, name),
6959 CallArg::Named(n) => expr_contains_ident(&n.value, name),
6960 })
6961 }
6962 Expr::JoinLookup { entity, fields, .. } => {
6963 expr_contains_ident(entity, name)
6964 || fields
6965 .iter()
6966 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
6967 }
6968 Expr::BinaryOp { left, right, .. }
6969 | Expr::Comparison { left, right, .. }
6970 | Expr::LogicalOp { left, right, .. }
6971 | Expr::Pipe { left, right, .. }
6972 | Expr::NullCoalesce { left, right, .. } => {
6973 expr_contains_ident(left, name) || expr_contains_ident(right, name)
6974 }
6975 Expr::Not { operand, .. }
6976 | Expr::Exists { operand, .. }
6977 | Expr::NotExists { operand, .. }
6978 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
6979 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
6980 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
6981 }
6982 Expr::Where {
6983 source, condition, ..
6984 }
6985 | Expr::With {
6986 source,
6987 predicate: condition,
6988 ..
6989 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
6990 Expr::WhenGuard {
6991 action, condition, ..
6992 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
6993 Expr::Lambda { param, body, .. } => {
6994 expr_contains_ident(param, name) || expr_contains_ident(body, name)
6995 }
6996 Expr::Binding { name: n, value, .. } => {
6997 n.name == name || expr_contains_ident(value, name)
6998 }
6999 Expr::SetLiteral { elements, .. } | Expr::ListLiteral { elements, .. } => {
7000 elements.iter().any(|e| expr_contains_ident(e, name))
7001 }
7002 Expr::ObjectLiteral { fields, .. } => {
7003 fields.iter().any(|f| expr_contains_ident(&f.value, name))
7004 }
7005 Expr::GenericType { name: n, args, .. } => {
7006 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
7007 }
7008 Expr::Conditional {
7009 branches,
7010 else_body,
7011 ..
7012 } => {
7013 branches.iter().any(|b| {
7014 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
7015 }) || else_body
7016 .as_ref()
7017 .is_some_and(|e| expr_contains_ident(e, name))
7018 }
7019 Expr::For {
7020 collection,
7021 filter,
7022 body,
7023 ..
7024 } => {
7025 expr_contains_ident(collection, name)
7026 || filter
7027 .as_ref()
7028 .is_some_and(|f| expr_contains_ident(f, name))
7029 || expr_contains_ident(body, name)
7030 }
7031 Expr::TransitionsTo {
7032 subject, new_state, ..
7033 }
7034 | Expr::Becomes {
7035 subject, new_state, ..
7036 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
7037 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
7038 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
7039 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
7040 Expr::QualifiedName(_)
7041 | Expr::StringLiteral(_)
7042 | Expr::BacktickLiteral { .. }
7043 | Expr::NumberLiteral { .. }
7044 | Expr::BoolLiteral { .. }
7045 | Expr::Null { .. }
7046 | Expr::Now { .. }
7047 | Expr::This { .. }
7048 | Expr::Within { .. }
7049 | Expr::DurationLiteral { .. } => false,
7050 }
7051}
7052
7053#[cfg(test)]
7058mod tests {
7059 use super::*;
7060 use crate::diagnostic::Severity;
7061 use crate::parser::parse;
7062
7063 fn analyze_src(src: &str) -> Vec<Diagnostic> {
7064 let input = if src.starts_with("-- allium:") {
7065 src.to_string()
7066 } else {
7067 format!("-- allium: 3\n{src}")
7068 };
7069 let result = parse(&input);
7070 analyze(&result.module, &input)
7071 }
7072
7073 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
7074 diagnostics.iter().any(|d| d.code == Some(code))
7075 }
7076
7077 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
7078 diagnostics.iter().filter(|d| d.code == Some(code)).count()
7079 }
7080
7081 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
7082 let input = if src.starts_with("-- allium:") {
7083 src.to_string()
7084 } else {
7085 format!("-- allium: 3\n{src}")
7086 };
7087 let result = parse(&input);
7088 analyse(&result.module, &input)
7089 }
7090
7091 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
7092 result.findings.iter().any(|f| f["type"] == finding_type)
7093 }
7094
7095 #[test]
7098 fn suppression_on_previous_line() {
7099 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
7100 assert!(!has_code(&ds, "allium.field.unused"));
7101 }
7102
7103 #[test]
7104 fn suppression_all() {
7105 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
7106 assert!(!has_code(&ds, "allium.field.unused"));
7107 }
7108
7109 #[test]
7112 fn related_clause_with_binding_and_guard() {
7113 let ds = analyze_src(
7114 "surface QuoteVersions {\n facing user: User\n}\n\n\
7115 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
7116 );
7117 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
7118 }
7119
7120 #[test]
7121 fn related_clause_reports_unknown_surface() {
7122 let ds = analyze_src(
7123 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
7124 );
7125 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
7126 }
7127
7128 #[test]
7131 fn v1_capitalised_inline_enum() {
7132 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
7133 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
7134 }
7135
7136 #[test]
7139 fn discard_binding_no_warning() {
7140 let ds = analyze_src(
7141 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
7142 );
7143 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
7144 }
7145
7146 #[test]
7149 fn variable_status_assignment_suppresses_unreachable() {
7150 let ds = analyze_src(
7151 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
7152 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
7153 ensures: update.status = new_status\n}\n",
7154 );
7155 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7156 assert!(!has_code(&ds, "allium.status.noExit"));
7157 }
7158
7159 #[test]
7160 fn surface_param_types_disambiguate_shared_status_values() {
7161 let ds = analyze_src(
7164 "entity Account {\n status: active | suspended\n}\n\n\
7165 entity Subscription {\n status: active | expired | cancelled\n}\n\n\
7166 surface AccountAdmin {\n facing admin: Admin\n provides:\n \
7167 SuspendAccount(admin, account: Account)\n when account.status = active\n \
7168 ReinstateAccount(admin, account: Account)\n when account.status = suspended\n}\n\n\
7169 surface SubscriptionAdmin {\n facing admin: Admin\n provides:\n \
7170 CancelSubscription(admin, sub: Subscription)\n when sub.status = active\n \
7171 RenewSubscription(admin, sub: Subscription)\n when sub.status != active\n \
7172 ExpireSubscription(admin, sub: Subscription)\n when sub.status = active\n}\n\n\
7173 rule AccountSuspended {\n when: SuspendAccount(admin, account)\n \
7174 requires: account.status = active\n ensures: account.status = suspended\n}\n\n\
7175 rule AccountReinstated {\n when: ReinstateAccount(admin, account)\n \
7176 requires: account.status = suspended\n ensures: account.status = active\n}\n\n\
7177 rule SubscriptionCancelled {\n when: CancelSubscription(admin, sub)\n \
7178 requires: sub.status = active\n ensures: sub.status = cancelled\n}\n\n\
7179 rule SubscriptionExpired {\n when: ExpireSubscription(admin, sub)\n \
7180 requires: sub.status = active\n ensures: sub.status = expired\n}\n\n\
7181 rule SubscriptionRenewed {\n when: RenewSubscription(admin, sub)\n \
7182 requires: sub.status != active\n ensures: sub.status = active\n}\n",
7183 );
7184 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7185 assert!(!has_code(&ds, "allium.status.noExit"));
7186 }
7187
7188 #[test]
7189 fn negated_requires_counts_as_exit_for_complement_values() {
7190 let ds = analyze_src(
7193 "entity Order {\n status: draft | submitted | approved | rejected\n}\n\n\
7194 rule OrderSubmitted {\n when: SubmitOrder(clerk, order)\n \
7195 requires: order.status = draft\n ensures: order.status = submitted\n}\n\n\
7196 rule OrderApproved {\n when: ApproveOrder(clerk, order)\n \
7197 requires: order.status = submitted\n ensures: order.status = approved\n}\n\n\
7198 rule OrderRejected {\n when: RejectOrder(clerk, order)\n \
7199 requires: order.status = submitted\n ensures: order.status = rejected\n}\n\n\
7200 rule OrderReactivated {\n when: ReactivateOrder(clerk, order)\n \
7201 requires: order.status != draft\n ensures: order.status = draft\n}\n",
7202 );
7203 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7204 assert!(!has_code(&ds, "allium.status.noExit"));
7205 }
7206
7207 #[test]
7210 fn created_with_status_suppresses_unreachable() {
7211 let ds = analyze_src(
7212 "entity Order {\n status: pending | confirmed\n customer: String\n \
7213 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
7214 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
7215 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
7216 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
7217 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
7218 );
7219 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7220 }
7221
7222 #[test]
7223 fn created_omitting_status_warns() {
7224 let ds = analyze_src(
7225 "entity Order {\n status: pending | confirmed\n customer: String\n \
7226 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
7227 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
7228 Order.created(\n customer: customer\n )\n}\n",
7229 );
7230 assert!(has_code(&ds, "allium.created.missingStatus"));
7231 }
7232
7233 #[test]
7234 fn created_multiple_initial_statuses() {
7235 let ds = analyze_src(
7236 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
7237 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
7238 terminal: reviewed\n }\n}\n\n\
7239 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
7240 Proposal.created(status: draft, author: author)\n}\n\n\
7241 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
7242 Proposal.created(status: submitted, author: author)\n}\n\n\
7243 rule Review {\n when: ReviewerReviews(proposal)\n \
7244 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
7245 );
7246 assert!(!has_code(&ds, "allium.status.unreachableValue"));
7248 }
7249
7250 #[test]
7251 fn created_invalid_status_errors() {
7252 let ds = analyze_src(
7253 "entity Task {\n status: open | in_progress | done\n title: String\n \
7254 transitions status {\n open -> in_progress\n in_progress -> done\n \
7255 terminal: done\n }\n}\n\n\
7256 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
7257 Task.created(status: archived, title: title)\n}\n",
7258 );
7259 assert!(has_code(&ds, "allium.created.invalidStatus"));
7260 }
7261
7262 #[test]
7263 fn created_without_transitions_no_missing_status_warning() {
7264 let ds = analyze_src(
7266 "entity Note {\n status: draft | published\n content: String\n}\n\n\
7267 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
7268 Note.created(content: content)\n}\n",
7269 );
7270 assert!(!has_code(&ds, "allium.created.missingStatus"));
7271 }
7272
7273 #[test]
7276 fn terminal_declared_suppresses_no_exit() {
7277 let ds = analyze_src(
7278 "entity Subscription {\n status: active | paused | completed | cancelled\n \
7279 transitions status {\n active -> paused\n paused -> active\n \
7280 active -> completed\n active -> cancelled\n paused -> cancelled\n \
7281 terminal: completed, cancelled\n }\n}\n\n\
7282 rule Activate {\n when: UserActivates(user, subscription)\n \
7283 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
7284 rule Pause {\n when: UserPauses(user, subscription)\n \
7285 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
7286 rule Complete {\n when: PeriodEnds(subscription)\n \
7287 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
7288 rule Cancel {\n when: UserCancels(user, subscription)\n \
7289 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
7290 );
7291 assert!(!has_code(&ds, "allium.status.noExit"));
7292 }
7293
7294 #[test]
7295 fn non_terminal_no_exit_still_warns() {
7296 let ds = analyze_src(
7297 "entity Ticket {\n status: open | stuck | resolved\n \
7298 transitions status {\n open -> stuck\n open -> resolved\n \
7299 terminal: resolved\n }\n}\n\n\
7300 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
7301 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
7302 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
7303 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
7304 );
7305 assert!(has_code(&ds, "allium.status.noExit"));
7307 }
7308
7309 #[test]
7312 fn cross_entity_trigger_param_recognised() {
7313 let ds = analyze_src(
7314 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
7315 transitions status {\n scheduled -> confirmed\n \
7316 confirmed -> completed\n terminal: completed\n }\n}\n\n\
7317 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
7318 InterviewSlot.created(status: scheduled)\n}\n\n\
7319 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
7320 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
7321 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
7322 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
7323 );
7324 assert!(!ds.iter().any(|d| {
7326 d.code == Some("allium.status.unreachableValue")
7327 && d.message.contains("InterviewSlot")
7328 }));
7329 assert!(!ds.iter().any(|d| {
7330 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
7331 }));
7332 }
7333
7334 #[test]
7335 fn cross_entity_undeclared_transition() {
7336 let ds = analyze_src(
7337 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
7338 transitions status {\n scheduled -> confirmed\n \
7339 confirmed -> completed\n terminal: completed\n }\n}\n\n\
7340 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
7341 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
7342 );
7343 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
7344 }
7345
7346 #[test]
7347 fn nested_entity_status_recognised() {
7348 let ds = analyze_src(
7349 "entity Order {\n status: placed | paid\n payment: Payment\n \
7350 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
7351 entity Payment {\n status: pending | captured | failed\n \
7352 transitions status {\n pending -> captured\n pending -> failed\n \
7353 terminal: captured, failed\n }\n}\n\n\
7354 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
7355 requires: order.payment.status = pending\n \
7356 ensures: order.payment.status = captured\n}\n",
7357 );
7358 assert!(!ds.iter().any(|d| {
7360 (d.code == Some("allium.status.unreachableValue")
7361 || d.code == Some("allium.status.noExit"))
7362 && d.message.contains("'captured'")
7363 }));
7364 }
7365
7366 #[test]
7369 fn dead_transition_missing_producer() {
7370 let r = analyse_src(
7371 "entity App {\n status: submitted | screening | approved | rejected\n \
7372 verified: Boolean\n \
7373 transitions status {\n submitted -> screening\n screening -> approved\n \
7374 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7375 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
7376 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
7377 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
7378 requires:\n app.status = screening\n app.verified = true\n \
7379 ensures: app.status = approved\n}\n\n\
7380 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
7381 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
7382 );
7383 assert!(has_finding(&r, "dead_transition"));
7384 assert!(has_finding(&r, "missing_producer"));
7385 }
7386
7387 #[test]
7388 fn satisfied_requires_no_dead_transition() {
7389 let r = analyse_src(
7390 "entity App {\n status: submitted | screening | approved | rejected\n \
7391 verified: Boolean\n \
7392 transitions status {\n submitted -> screening\n screening -> approved\n \
7393 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7394 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
7395 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
7396 rule Verify {\n when: SystemVerifies(app, result)\n \
7397 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
7398 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
7399 requires:\n app.status = screening\n app.verified = true\n \
7400 ensures: app.status = approved\n}\n\n\
7401 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
7402 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
7403 );
7404 assert!(!has_finding(&r, "dead_transition"));
7405 assert!(!has_finding(&r, "missing_producer"));
7406 }
7407
7408 #[test]
7409 fn deadlock_detected() {
7410 let r = analyse_src(
7411 "entity Doc {\n status: submitted | review | approved | rejected\n \
7412 reviewer_assigned: Boolean\n \
7413 transitions status {\n submitted -> review\n review -> approved\n \
7414 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
7415 rule Submit {\n when: AuthorSubmits(author, doc)\n \
7416 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
7417 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
7418 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
7419 ensures: doc.status = approved\n}\n\n\
7420 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
7421 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
7422 ensures: doc.status = rejected\n}\n",
7423 );
7424 assert!(has_finding(&r, "deadlock"));
7425 }
7426
7427 #[test]
7428 fn no_deadlock_when_paths_open() {
7429 let r = analyse_src(
7430 "entity Invoice {\n status: draft | sent | paid | void\n \
7431 transitions status {\n draft -> sent\n draft -> void\n \
7432 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
7433 rule Send {\n when: AccountantSends(accountant, invoice)\n \
7434 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
7435 rule Pay {\n when: PaymentReceived(invoice)\n \
7436 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
7437 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
7438 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
7439 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
7440 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
7441 );
7442 assert!(!has_finding(&r, "deadlock"));
7443 }
7444
7445 #[test]
7448 fn conflict_temporal_vs_external() {
7449 let r = analyse_src(
7450 "entity Membership {\n status: active | expired | extended\n \
7451 expires_at: Timestamp\n \
7452 transitions status {\n active -> expired\n active -> extended\n \
7453 terminal: expired, extended\n }\n}\n\n\
7454 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
7455 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
7456 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
7457 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
7458 );
7459 assert!(has_finding(&r, "conflict"));
7460 }
7461
7462 #[test]
7463 fn conflict_detected_when_effect_nested_in_branch() {
7464 let r = analyse_src(
7468 "entity Membership {\n status: active | expired | extended\n \
7469 expires_at: Timestamp\n \
7470 transitions status {\n active -> expired\n active -> extended\n \
7471 terminal: expired, extended\n }\n}\n\n\
7472 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
7473 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
7474 rule ManualExtend {\n when: AdminExtends(admin, membership, flag)\n \
7475 requires: membership.status = active\n \
7476 if flag:\n ensures: membership.status = extended\n \
7477 else:\n ensures: membership.status = extended\n}\n",
7478 );
7479 assert!(has_finding(&r, "conflict"));
7480 }
7481
7482 #[test]
7483 fn no_conflict_actor_choice() {
7484 let r = analyse_src(
7485 "entity LeaveRequest {\n status: pending | approved | denied\n \
7486 transitions status {\n pending -> approved\n pending -> denied\n \
7487 terminal: approved, denied\n }\n}\n\n\
7488 rule Approve {\n when: ManagerApproves(manager, request)\n \
7489 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
7490 rule Deny {\n when: ManagerDenies(manager, request)\n \
7491 requires: request.status = pending\n ensures: request.status = denied\n}\n",
7492 );
7493 assert!(!has_finding(&r, "conflict"));
7494 }
7495
7496 #[test]
7499 fn invariant_violation_detected() {
7500 let r = analyse_src(
7501 "entity JobRole {\n status: open | filled\n \
7502 candidacies: Candidacy with role = this\n \
7503 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
7504 entity Candidacy {\n status: active | hired | rejected\n \
7505 role: JobRole\n \
7506 transitions status {\n active -> hired\n active -> rejected\n \
7507 terminal: hired, rejected\n }\n}\n\n\
7508 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
7509 requires: candidacy.status = active\n \
7510 ensures: candidacy.status = hired\n}\n\n\
7511 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
7512 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
7513 );
7514 assert!(has_finding(&r, "invariant_risk"));
7515 }
7516
7517 #[test]
7518 fn invariant_guarded_no_violation() {
7519 let r = analyse_src(
7520 "entity JobRole {\n status: open | filled\n \
7521 candidacies: Candidacy with role = this\n \
7522 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
7523 entity Candidacy {\n status: active | hired | rejected\n \
7524 role: JobRole\n \
7525 transitions status {\n active -> hired\n active -> rejected\n \
7526 terminal: hired, rejected\n }\n}\n\n\
7527 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
7528 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
7529 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
7530 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
7531 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
7532 );
7533 assert!(!has_finding(&r, "invariant_risk"));
7534 }
7535
7536 #[test]
7539 fn external_entity_referenced_in_rules_info() {
7540 let ds = analyze_src(
7541 "external entity Client {\n id: String\n}\n\n\
7542 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
7543 );
7544 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
7545 assert!(hint.is_some());
7546 assert_eq!(hint.unwrap().severity, Severity::Info);
7547 }
7548
7549 #[test]
7552 fn undefined_type_reference() {
7553 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
7554 assert!(has_code(&ds, "allium.type.undefinedReference"));
7555 }
7556
7557 #[test]
7558 fn known_type_reference_ok() {
7559 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
7560 assert!(!has_code(&ds, "allium.type.undefinedReference"));
7561 }
7562
7563 #[test]
7566 fn unreachable_trigger_reported() {
7567 let ds = analyze_src(
7568 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
7569 );
7570 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
7571 }
7572
7573 fn analyze_with_imports(
7576 src: &str,
7577 imports: &[(&str, &[&str])],
7578 ) -> Vec<Diagnostic> {
7579 let input = format!("-- allium: 3\n{src}");
7580 let result = parse(&input);
7581 let imported: HashMap<String, HashSet<String>> = imports
7582 .iter()
7583 .map(|(alias, triggers)| {
7584 (
7585 alias.to_string(),
7586 triggers.iter().map(|t| t.to_string()).collect(),
7587 )
7588 })
7589 .collect();
7590 analyze_with_cross_module(
7591 &result.module,
7592 &input,
7593 &HashSet::new(),
7594 &HashSet::new(),
7595 &imported,
7596 &HashMap::new(),
7597 &AmbiguousImports::default(),
7598 &ReverseContributions::default(),
7599 &HashMap::new(),
7600 )
7601 }
7602
7603 fn analyze_with_ambiguous(
7609 src: &str,
7610 names: &[(&str, &[&str])],
7611 triggers: &[(&str, &[&str])],
7612 ) -> Vec<Diagnostic> {
7613 let input = format!("-- allium: 3\n{src}");
7614 let result = parse(&input);
7615 let to_map = |entries: &[(&str, &[&str])]| -> HashMap<String, Vec<String>> {
7616 entries
7617 .iter()
7618 .map(|(name, aliases)| {
7619 (
7620 name.to_string(),
7621 aliases.iter().map(|a| a.to_string()).collect(),
7622 )
7623 })
7624 .collect()
7625 };
7626 let ambiguous = AmbiguousImports {
7627 names: to_map(names),
7628 triggers: to_map(triggers),
7629 };
7630 let mut imported: HashMap<String, HashSet<String>> = HashMap::new();
7631 for (trigger, aliases) in triggers {
7632 for alias in *aliases {
7633 imported
7634 .entry(alias.to_string())
7635 .or_default()
7636 .insert(trigger.to_string());
7637 }
7638 }
7639 analyze_with_cross_module(
7640 &result.module,
7641 &input,
7642 &HashSet::new(),
7643 &HashSet::new(),
7644 &imported,
7645 &HashMap::new(),
7646 &ambiguous,
7647 &ReverseContributions::default(),
7648 &HashMap::new(),
7649 )
7650 }
7651
7652 #[test]
7653 fn qualified_trigger_suppressed_in_single_file_mode() {
7654 let ds = analyze_src(
7657 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7658 );
7659 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7660 }
7661
7662 #[test]
7663 fn qualified_trigger_reachable_via_imported_module() {
7664 let ds = analyze_with_imports(
7665 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7666 &[("emitter", &["Pinged"])],
7667 );
7668 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7669 }
7670
7671 #[test]
7672 fn qualified_trigger_unreachable_when_imported_module_lacks_it() {
7673 let ds = analyze_with_imports(
7674 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7675 &[("emitter", &["SomethingElse"])],
7676 );
7677 let flagged: Vec<&Diagnostic> = ds
7678 .iter()
7679 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7680 .collect();
7681 assert_eq!(flagged.len(), 1);
7682 assert!(flagged[0].message.contains("'emitter/Pinged'"));
7683 assert!(flagged[0].message.contains("imported module 'emitter'"));
7684 }
7685
7686 #[test]
7687 fn qualified_trigger_suppressed_for_alias_outside_check_set() {
7688 let ds = analyze_with_imports(
7691 "use \"github.com/allium-specs/oauth/abc\" as oauth\n\nrule Audit {\n when: oauth/SessionCreated(session)\n ensures: Logged(session: session)\n}\n",
7692 &[],
7693 );
7694 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7695 }
7696
7697 #[test]
7698 fn unqualified_trigger_reachable_via_imported_module() {
7699 let ds = analyze_with_imports(
7700 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7701 &[("emitter", &["Pinged"])],
7702 );
7703 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7704 }
7705
7706 #[test]
7707 fn unqualified_trigger_still_flagged_when_no_import_emits_it() {
7708 let ds = analyze_with_imports(
7709 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7710 &[("emitter", &["SomethingElse"])],
7711 );
7712 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
7713 }
7714
7715 #[test]
7718 fn ambiguous_trigger_subscription_warns() {
7719 let ds = analyze_with_ambiguous(
7720 "use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
7721 &[],
7722 &[("Pinged", &["a", "b"])],
7723 );
7724 let diag = ds
7725 .iter()
7726 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7727 .expect("ambiguous trigger subscription should warn");
7728 assert!(diag.message.contains("'a' and 'b'"), "message: {}", diag.message);
7729 assert!(diag.message.contains("a/Pinged"), "message: {}", diag.message);
7730 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
7732 }
7733
7734 #[test]
7735 fn ambiguous_trigger_not_flagged_when_emitted_locally() {
7736 let ds = analyze_with_ambiguous(
7738 "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",
7739 &[],
7740 &[("Pinged", &["a", "b"])],
7741 );
7742 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7743 }
7744
7745 #[test]
7746 fn qualified_trigger_subscription_not_flagged_as_ambiguous() {
7747 let ds = analyze_with_ambiguous(
7748 "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",
7749 &[],
7750 &[("Pinged", &["a", "b"])],
7751 );
7752 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7753 }
7754
7755 #[test]
7756 fn ambiguous_name_reference_warns() {
7757 let ds = analyze_with_ambiguous(
7758 "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",
7759 &[("Invoice", &["billing", "orders"])],
7760 &[],
7761 );
7762 let diag = ds
7763 .iter()
7764 .find(|d| d.code == Some("allium.use.ambiguousReference"))
7765 .expect("ambiguous unqualified name should warn");
7766 assert!(
7767 diag.message.contains("'billing' and 'orders'"),
7768 "message: {}",
7769 diag.message
7770 );
7771 assert!(diag.message.contains("billing/Invoice"), "message: {}", diag.message);
7772 }
7773
7774 #[test]
7775 fn ambiguous_name_shadowed_by_local_declaration() {
7776 let ds = analyze_with_ambiguous(
7777 "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",
7778 &[("Invoice", &["billing", "orders"])],
7779 &[],
7780 );
7781 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7782 }
7783
7784 #[test]
7785 fn ambiguous_name_flagged_once_per_name() {
7786 let ds = analyze_with_ambiguous(
7787 "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",
7788 &[("Invoice", &["billing", "orders"])],
7789 &[],
7790 );
7791 let count = ds
7792 .iter()
7793 .filter(|d| d.code == Some("allium.use.ambiguousReference"))
7794 .count();
7795 assert_eq!(count, 1, "expected a single warning per ambiguous name");
7796 }
7797
7798 #[test]
7799 fn no_ambiguity_warnings_in_single_file_mode() {
7800 let ds = analyze_src(
7801 "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",
7802 );
7803 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
7804 }
7805
7806 #[test]
7807 fn conditional_ensures_emission_registers() {
7808 let ds = analyze_src(
7811 "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",
7812 );
7813 let unreachable: Vec<&Diagnostic> = ds
7814 .iter()
7815 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7816 .collect();
7817 assert_eq!(unreachable.len(), 1);
7820 assert!(unreachable[0].message.contains("'AdvertReceived'"));
7821 }
7822
7823 #[test]
7824 fn for_body_ensures_emission_registers() {
7825 let ds = analyze_src(
7826 "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",
7827 );
7828 let unreachable: Vec<&Diagnostic> = ds
7829 .iter()
7830 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7831 .collect();
7832 assert_eq!(unreachable.len(), 1);
7833 assert!(unreachable[0].message.contains("'Broadcast'"));
7834 }
7835
7836 #[test]
7837 fn collect_trigger_outputs_includes_provides_ensures_and_branches() {
7838 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";
7839 let result = parse(input);
7840 let outputs = collect_trigger_outputs(&result.module);
7841 assert!(outputs.contains("Submit"));
7842 assert!(outputs.contains("Accepted"));
7843 assert!(outputs.contains("Rejected"));
7844 }
7845
7846 #[test]
7849 fn unused_field_reported() {
7850 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");
7851 assert!(has_code(&ds, "allium.field.unused"));
7852 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7854 assert!(unused.iter().any(|d| d.message.contains("A.y")));
7855 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
7856 }
7857
7858 #[test]
7859 fn field_used_by_sibling_derived_field_not_unused() {
7860 let ds = analyze_src("entity Widget {\n count: Integer\n is_positive: count > 0\n}\n");
7863 let unused: Vec<_> =
7864 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7865 assert!(
7866 !unused.iter().any(|d| d.message.contains("Widget.count")),
7867 "count is referenced by is_positive and must not be flagged unused. Got: {:?}",
7868 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7869 );
7870 assert!(
7872 unused.iter().any(|d| d.message.contains("Widget.is_positive")),
7873 "is_positive is unreferenced and should still warn. Got: {:?}",
7874 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7875 );
7876 }
7877
7878 #[test]
7879 fn field_set_via_created_named_arg_not_unused() {
7880 let ds = analyze_src(
7883 "entity Widget {\n name: String\n}\n\nrule MakeWidget {\n when: MakeWidget(label)\n ensures: Widget.created(name: label)\n}\n",
7884 );
7885 let unused: Vec<_> =
7886 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7887 assert!(
7888 !unused.iter().any(|d| d.message.contains("Widget.name")),
7889 "name is set by Widget.created(name: ...) and must not be flagged unused. Got: {:?}",
7890 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7891 );
7892 }
7893
7894 #[test]
7895 fn field_named_like_a_rule_binding_still_unused() {
7896 let ds = analyze_src(
7900 "entity Widget {\n order: String\n}\n\nrule R {\n when: Ping(order)\n ensures: Done()\n}\n",
7901 );
7902 let unused: Vec<_> =
7903 ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
7904 assert!(
7905 unused.iter().any(|d| d.message.contains("Widget.order")),
7906 "a rule binding named 'order' must not suppress the unused field 'Widget.order'. Got: {:?}",
7907 unused.iter().map(|d| &d.message).collect::<Vec<_>>()
7908 );
7909 }
7910
7911 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";
7914
7915 #[test]
7916 fn conditional_ensures_makes_branch_statuses_reachable() {
7917 let ds = analyze_src(DECIDE_BRANCH_SPEC);
7920 let unreachable: Vec<_> = ds
7921 .iter()
7922 .filter(|d| d.code == Some("allium.status.unreachableValue"))
7923 .collect();
7924 assert!(
7925 !unreachable.iter().any(|d| d.message.contains("approved")),
7926 "approved is assigned in the if-branch and must be reachable. Got: {:?}",
7927 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
7928 );
7929 assert!(
7930 !unreachable.iter().any(|d| d.message.contains("rejected")),
7931 "rejected is assigned in the else-branch and must be reachable. Got: {:?}",
7932 unreachable.iter().map(|d| &d.message).collect::<Vec<_>>()
7933 );
7934 }
7935
7936 #[test]
7937 fn conditional_ensures_transitions_are_witnessed_no_deadlock() {
7938 let r = analyse_src(DECIDE_BRANCH_SPEC);
7942 assert!(
7943 !has_finding(&r, "deadlock"),
7944 "branch-witnessed exits from pending must clear the deadlock. Findings: {:?}",
7945 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
7946 );
7947 }
7948
7949 const FOR_IN_PROVIDES_SPEC: &str = r#"external entity Person { name: String }
7950
7951entity User {
7952 person: Person
7953 sessions: Session with user = this
7954}
7955
7956entity Session {
7957 user: User
7958 status: active | ended
7959 transitions status { active -> ended terminal: ended }
7960}
7961
7962rule LogOut {
7963 when: UserLogsOut(session)
7964 requires: session.status = active
7965 ensures: session.status = ended
7966}
7967
7968surface AccountManagement {
7969 facing person: Person
7970 context user: User where person = person
7971 exposes:
7972 for session in user.sessions:
7973 session.status
7974 provides:
7975 for session in user.sessions:
7976 UserLogsOut(session)
7977}
7978"#;
7979
7980 #[test]
7981 fn trigger_provided_in_for_block_is_reachable() {
7982 let ds = analyze_src(FOR_IN_PROVIDES_SPEC);
7985 assert!(
7986 !ds.iter().any(|d| d.code == Some("allium.rule.unreachableTrigger")
7987 && d.message.contains("UserLogsOut")),
7988 "UserLogsOut is provided inside a for-block and must be reachable. Got: {:?}",
7989 ds.iter()
7990 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
7991 .map(|d| &d.message)
7992 .collect::<Vec<_>>()
7993 );
7994 }
7995
7996 #[test]
7997 fn trigger_provided_in_for_block_no_unreachable_finding() {
7998 let r = analyse_src(FOR_IN_PROVIDES_SPEC);
8000 assert!(
8001 !has_finding(&r, "unreachable_trigger"),
8002 "for-block-provided trigger must not yield an unreachable_trigger finding. Findings: {:?}",
8003 r.findings.iter().map(|f| f["summary"].clone()).collect::<Vec<_>>()
8004 );
8005 }
8006
8007 #[test]
8010 fn unused_entity_reported() {
8011 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
8012 assert!(has_code(&ds, "allium.entity.unused"));
8013 }
8014
8015 #[test]
8016 fn external_ref_suppresses_unused_entity() {
8017 let src = "entity InputEvent {\n payload: String\n}\n";
8018 let input = format!("-- allium: 3\n{src}");
8019 let result = parse(&input);
8020 let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
8021 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8022 assert!(!has_code(&ds, "allium.entity.unused"));
8023 }
8024
8025 #[test]
8026 fn external_ref_suppresses_unused_definition() {
8027 let src = "value Snapshot {\n version: Integer\n}\n";
8028 let input = format!("-- allium: 3\n{src}");
8029 let result = parse(&input);
8030 let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
8031 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8032 assert!(!has_code(&ds, "allium.definition.unused"));
8033 }
8034
8035 #[test]
8036 fn unreferenced_entity_still_warns_without_external_ref() {
8037 let src = "entity InputEvent {\n payload: String\n}\n";
8038 let input = format!("-- allium: 3\n{src}");
8039 let result = parse(&input);
8040 let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
8041 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8042 assert!(has_code(&ds, "allium.entity.unused"));
8043 }
8044
8045 #[test]
8046 fn collect_qualified_refs_from_rule_clause() {
8047 let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
8048 let input = format!("-- allium: 3\n{src}");
8049 let result = parse(&input);
8050 let refs = collect_qualified_references(&result.module);
8051 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8052 }
8053
8054 #[test]
8055 fn collect_qualified_refs_from_field_type() {
8056 let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
8057 let input = format!("-- allium: 3\n{src}");
8058 let result = parse(&input);
8059 let refs = collect_qualified_references(&result.module);
8060 assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
8061 }
8062
8063 #[test]
8064 fn collect_qualified_refs_from_requires() {
8065 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";
8066 let input = format!("-- allium: 3\n{src}");
8067 let result = parse(&input);
8068 let refs = collect_qualified_references(&result.module);
8069 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
8070 }
8071
8072 #[test]
8073 fn collect_qualified_refs_from_for_block() {
8074 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";
8075 let input = format!("-- allium: 3\n{src}");
8076 let result = parse(&input);
8077 let refs = collect_qualified_references(&result.module);
8078 assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
8079 }
8080
8081 #[test]
8082 fn collect_qualified_refs_from_member_access() {
8083 let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
8084 let input = format!("-- allium: 3\n{src}");
8085 let result = parse(&input);
8086 let refs = collect_qualified_references(&result.module);
8087 assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
8088 }
8089
8090 #[test]
8091 fn collect_qualified_refs_multiple_from_same_module() {
8092 let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
8093 let input = format!("-- allium: 3\n{src}");
8094 let result = parse(&input);
8095 let refs = collect_qualified_references(&result.module);
8096 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8097 assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
8098 }
8099
8100 #[test]
8101 fn collect_qualified_refs_multiple_modules() {
8102 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";
8103 let input = format!("-- allium: 3\n{src}");
8104 let result = parse(&input);
8105 let refs = collect_qualified_references(&result.module);
8106 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
8107 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
8108 }
8109
8110 #[test]
8111 fn collect_qualified_refs_empty_when_none() {
8112 let src = "entity Order {\n total: Decimal\n}\n";
8113 let input = format!("-- allium: 3\n{src}");
8114 let result = parse(&input);
8115 let refs = collect_qualified_references(&result.module);
8116 assert!(refs.is_empty());
8117 }
8118
8119 #[test]
8120 fn collect_qualified_refs_from_ensures() {
8121 let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
8122 let input = format!("-- allium: 3\n{src}");
8123 let result = parse(&input);
8124 let refs = collect_qualified_references(&result.module);
8125 assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
8126 }
8127
8128 #[test]
8129 fn collect_qualified_refs_from_invariant() {
8130 let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
8131 let input = format!("-- allium: 3\n{src}");
8132 let result = parse(&input);
8133 let refs = collect_qualified_references(&result.module);
8134 assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
8135 }
8136
8137 #[test]
8138 fn collect_qualified_refs_from_deferred() {
8139 let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
8140 let input = format!("-- allium: 3\n{src}");
8141 let result = parse(&input);
8142 let refs = collect_qualified_references(&result.module);
8143 assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
8144 }
8145
8146 #[test]
8147 fn collect_qualified_refs_from_alias_dot_member() {
8148 let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
8149 let input = format!("-- allium: 3\n{src}");
8150 let result = parse(&input);
8151 let refs = collect_qualified_references(&result.module);
8152 assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
8153 }
8154
8155 #[test]
8156 fn collect_all_idents_includes_unqualified_entity_ref() {
8157 let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
8158 let input = format!("-- allium: 3\n{src}");
8159 let result = parse(&input);
8160 let idents = collect_all_referenced_idents(&result.module);
8161 assert!(idents.contains("InputPartition"));
8162 }
8163
8164 #[test]
8165 fn collect_declared_names_returns_entity_and_value_names() {
8166 let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
8167 let input = format!("-- allium: 3\n{src}");
8168 let result = parse(&input);
8169 let names = collect_declared_names(&result.module);
8170 assert!(names.contains("Order"));
8171 assert!(names.contains("Money"));
8172 assert!(names.contains("Status"));
8173 }
8174
8175 #[test]
8176 fn external_ref_only_suppresses_matching_name() {
8177 let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
8179 let input = format!("-- allium: 3\n{src}");
8180 let result = parse(&input);
8181 let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
8182 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8183 assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
8184 && d.message.contains("Used")));
8185 assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
8186 && d.message.contains("Orphan")));
8187 }
8188
8189 #[test]
8190 fn external_ref_suppresses_unused_external_entity() {
8191 let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
8192 let input = format!("-- allium: 3\n{src}");
8193 let result = parse(&input);
8194 let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
8195 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8196 assert!(!has_code(&ds, "allium.entity.unused"));
8197 }
8198
8199 #[test]
8200 fn external_ref_suppresses_unused_enum() {
8201 let src = "enum Priority {\n low\n medium\n high\n}\n";
8202 let input = format!("-- allium: 3\n{src}");
8203 let result = parse(&input);
8204 let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
8205 let ds = analyze_with_external_refs(&result.module, &input, &refs);
8206 assert!(!has_code(&ds, "allium.definition.unused"));
8207 }
8208
8209 #[test]
8210 fn empty_external_refs_same_as_plain_analyze() {
8211 let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
8212 let input = format!("-- allium: 3\n{src}");
8213 let result = parse(&input);
8214 let plain = analyze(&result.module, &input);
8215 let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
8216 assert_eq!(plain.len(), with_empty.len());
8217 for (a, b) in plain.iter().zip(with_empty.iter()) {
8218 assert_eq!(a.code, b.code);
8219 assert_eq!(a.message, b.message);
8220 }
8221 }
8222
8223 #[test]
8226 fn resolved_use_path_no_warning() {
8227 let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
8228 let input = format!("-- allium: 3\n{src}");
8229 let result = parse(&input);
8230 let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
8231 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8232 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8233 }
8234
8235 #[test]
8236 fn unresolved_use_path_warns() {
8237 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8238 let input = format!("-- allium: 3\n{src}");
8239 let result = parse(&input);
8240 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8242 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8243 assert!(has_code(&ds, "allium.use.unresolvedPath"));
8244 }
8245
8246 #[test]
8247 fn unresolved_use_path_skipped_in_single_file_mode() {
8248 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8250 let input = format!("-- allium: 3\n{src}");
8251 let result = parse(&input);
8252 let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
8253 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8254 }
8255
8256 #[test]
8257 fn unresolved_use_path_fires_with_empty_resolved_set() {
8258 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8261 let input = format!("-- allium: 3\n{src}");
8262 let result = parse(&input);
8263 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new(), &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8264 assert!(has_code(&ds, "allium.use.unresolvedPath"));
8265 }
8266
8267 #[test]
8268 fn unresolved_use_path_message_includes_path() {
8269 let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
8270 let input = format!("-- allium: 3\n{src}");
8271 let result = parse(&input);
8272 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8273 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8274 let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
8275 assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
8276 }
8277
8278 #[test]
8279 fn unresolved_use_path_suppressible() {
8280 let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
8281 let input = format!("-- allium: 3\n{src}");
8282 let result = parse(&input);
8283 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
8284 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8285 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
8286 }
8287
8288 #[test]
8289 fn multiple_use_paths_mixed_resolution() {
8290 let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
8291 let input = format!("-- allium: 3\n{src}");
8292 let result = parse(&input);
8293 let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
8294 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &HashMap::new(), &AmbiguousImports::default(), &ReverseContributions::default(), &HashMap::new());
8295 let unresolved: Vec<_> = ds.iter()
8296 .filter(|d| d.code == Some("allium.use.unresolvedPath"))
8297 .collect();
8298 assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
8299 assert!(unresolved[0].message.contains("lost.allium"));
8300 }
8301
8302 #[test]
8305 fn deferred_missing_location_hint() {
8306 let ds = analyze_src("deferred Foo.bar\n");
8307 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8308 }
8309
8310 #[test]
8311 fn deferred_with_quoted_path_hint_ok() {
8312 let ds = analyze_src("deferred Foo.bar \"detailed/foo.allium\"\n");
8313 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8314 }
8315
8316 #[test]
8317 fn deferred_with_see_comment_hint_ok() {
8318 let ds = analyze_src("deferred Foo.bar -- see: detailed/foo.allium\n");
8320 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8321 }
8322
8323 #[test]
8324 fn deferred_with_url_hint_ok() {
8325 let ds = analyze_src("deferred Foo.bar -- https://example.com/foo.allium\n");
8326 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8327 }
8328
8329 #[test]
8330 fn deferred_with_url_glued_to_path_warns() {
8331 assert!(has_code(
8335 &analyze_src("deferred Foohttps://x\n"),
8336 "allium.deferred.missingLocationHint"
8337 ));
8338 assert!(has_code(
8339 &analyze_src("deferred Foohttp://x\n"),
8340 "allium.deferred.missingLocationHint"
8341 ));
8342 }
8343
8344 #[test]
8345 fn deferred_with_non_hint_comment_warns() {
8346 let ds = analyze_src("deferred Foo.bar -- TODO write this\n");
8349 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8350 }
8351
8352 #[test]
8353 fn deferred_expression_path_with_quote_suppresses() {
8354 let ds = analyze_src("deferred Foo(\"x\")\n");
8359 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8360 let ds = analyze_src("deferred Foo = \"x\"\n");
8361 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8362 }
8363
8364 #[test]
8365 fn deferred_trailing_dot_warns_with_captured_name() {
8366 let ds = analyze_src("deferred Dangling.\n");
8371 let hints: Vec<&Diagnostic> = ds
8372 .iter()
8373 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8374 .collect();
8375 assert_eq!(hints.len(), 1);
8376 assert!(hints[0].message.contains("'Dangling.'"));
8377 }
8378
8379 #[test]
8380 fn deferred_lone_cr_is_a_line_boundary() {
8381 let ds = analyze_src("deferred Foo\rdeferred Bar\n");
8385 let hints: Vec<&Diagnostic> = ds
8386 .iter()
8387 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8388 .collect();
8389 assert_eq!(hints.len(), 2, "both CR-separated declarations warn");
8390 let ds = analyze_src("deferred Foo\r-- see: x.allium\n");
8392 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
8393 }
8394
8395 #[test]
8396 fn deferred_unmatchable_path_stays_silent() {
8397 let ds = analyze_src("deferred (Foo)\n");
8402 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
8403 }
8404
8405 #[test]
8406 fn deferred_qualified_path_warns_with_flat_name() {
8407 let ds = analyze_src("deferred billing/InvoiceWorkflow\n");
8410 let hints: Vec<&Diagnostic> = ds
8411 .iter()
8412 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8413 .collect();
8414 assert_eq!(hints.len(), 1);
8415 assert!(hints[0].message.contains("'billing'"));
8416 }
8417
8418 #[test]
8419 fn deferred_location_hint_is_per_line() {
8420 let ds = analyze_src(
8424 "deferred A.one -- see: a.allium\ndeferred B.two\ndeferred C.three \"c.allium\"\n",
8425 );
8426 let hints: Vec<&Diagnostic> = ds
8427 .iter()
8428 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
8429 .collect();
8430 assert_eq!(hints.len(), 1);
8431 assert!(hints[0].message.contains("B.two"));
8432 }
8433
8434 #[test]
8437 fn valid_trigger_ok() {
8438 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
8439 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8440 }
8441
8442 #[test]
8443 fn qualified_trigger_call_is_valid() {
8444 let ds = analyze_src(
8448 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
8449 );
8450 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8451 }
8452
8453 #[test]
8454 fn typed_trigger_param_reported_at_trigger() {
8455 let ds = analyze_src(
8460 "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",
8461 );
8462 let invalid: Vec<&Diagnostic> = ds
8463 .iter()
8464 .filter(|d| d.code == Some("allium.rule.invalidTrigger"))
8465 .collect();
8466 assert_eq!(invalid.len(), 1, "exactly one invalidTrigger diagnostic");
8467 assert!(invalid[0].message.contains("'account'"));
8468 assert!(invalid[0].message.contains("bare names"));
8469 assert!(
8471 !has_code(&ds, "allium.rule.undefinedBinding"),
8472 "typed trigger param must not also fire undefinedBinding on the body"
8473 );
8474 }
8475
8476 #[test]
8477 fn untyped_trigger_param_ok() {
8478 let ds = analyze_src(
8480 "entity Account { name: String }\nentity Greeting { label: String }\n\nrule UntypedParam {\n when: AccountSeen(account)\n ensures: Greeting.created(label: account.name)\n}\n",
8481 );
8482 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
8483 assert!(!has_code(&ds, "allium.rule.undefinedBinding"));
8484 }
8485
8486 #[test]
8489 fn homogeneous_list_literal_ok() {
8490 let ds = analyze_src("default E e = { items: [\"a\", \"b\", \"c\"] }");
8491 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8492 }
8493
8494 #[test]
8495 fn empty_list_literal_ok() {
8496 let ds = analyze_src("default E e = { items: [] }");
8497 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8498 }
8499
8500 #[test]
8501 fn heterogeneous_list_literal_flagged() {
8502 let ds = analyze_src("default E e = { items: [\"a\", 5] }");
8503 assert!(has_code(&ds, "allium.list.mixedElementTypes"));
8504 }
8505
8506 #[test]
8507 fn list_literal_of_identifiers_not_flagged() {
8508 let ds = analyze_src("default E e = { items: [foo, bar] }");
8511 assert!(!has_code(&ds, "allium.list.mixedElementTypes"));
8512 }
8513
8514 #[test]
8517 fn qualified_default_known_alias_ok() {
8518 let ds = analyze_src(
8519 "use \"./p.allium\" as gp\n\ndefault gp/Policy my_policy = { id: \"x\" }",
8520 );
8521 assert!(!has_code(&ds, "allium.reference.undefinedImportedAlias"));
8522 }
8523
8524 #[test]
8525 fn qualified_default_unknown_alias_flagged() {
8526 let ds = analyze_src("default zz/Policy my_policy = { id: \"x\" }");
8528 assert!(has_code(&ds, "allium.reference.undefinedImportedAlias"));
8529 }
8530
8531 #[test]
8534 fn default_unknown_field_flagged() {
8535 let ds = analyze_src(
8536 "entity Policy { id: String }\ndefault Policy p = { id: \"x\", naem: \"typo\" }",
8537 );
8538 assert!(has_code(&ds, "allium.default.unknownField"));
8539 }
8540
8541 #[test]
8542 fn default_known_fields_ok() {
8543 let ds = analyze_src(
8544 "entity Policy { id: String\n label: String }\ndefault Policy p = { id: \"x\", label: \"y\" }",
8545 );
8546 assert!(!has_code(&ds, "allium.default.unknownField"));
8547 }
8548
8549 #[test]
8550 fn default_nested_object_unknown_field_flagged() {
8551 let ds = analyze_src(
8553 "value Predicate { clause_order: List<String> }\nentity Policy { id: String\n predicate: Predicate }\ndefault Policy p = { id: \"x\", predicate: { bogus: 5 } }",
8554 );
8555 assert!(has_code(&ds, "allium.default.unknownField"));
8556 }
8557
8558 #[test]
8559 fn empty_list_in_list_field_ok() {
8560 let ds = analyze_src(
8561 "entity E { tags: List<String> }\ndefault E e = { tags: [] }",
8562 );
8563 assert!(!has_code(&ds, "allium.list.emptyListNoElementType"));
8564 }
8565
8566 #[test]
8567 fn empty_list_in_non_list_field_flagged() {
8568 let ds = analyze_src(
8569 "entity E { id: String }\ndefault E e = { id: [] }",
8570 );
8571 assert!(has_code(&ds, "allium.list.emptyListNoElementType"));
8572 }
8573
8574 #[test]
8575 fn qualified_default_fields_not_validated() {
8576 let ds = analyze_src(
8579 "use \"./p.allium\" as gp\n\ndefault gp/Policy p = { anything: 1, goes: 2 }",
8580 );
8581 assert!(!has_code(&ds, "allium.default.unknownField"));
8582 }
8583
8584 #[test]
8587 fn duplicate_let_binding() {
8588 let ds = analyze_src(
8589 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
8590 );
8591 assert!(has_code(&ds, "allium.let.duplicateBinding"));
8592 }
8593
8594 #[test]
8597 fn config_undefined_reference() {
8598 let ds = analyze_src(
8599 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
8600 );
8601 assert!(has_code(&ds, "allium.config.undefinedReference"));
8602 }
8603
8604 #[test]
8605 fn config_valid_reference_ok() {
8606 let ds = analyze_src(
8607 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
8608 );
8609 assert!(!has_code(&ds, "allium.config.undefinedReference"));
8610 }
8611
8612 fn module_of(src: &str) -> Module {
8615 parse(&format!("-- allium: 3\n{src}")).module
8616 }
8617
8618 #[test]
8619 fn reverse_contributions_credit_qualified_creation() {
8620 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8621 let importer = module_of(
8622 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
8623 );
8624 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8625 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("open")));
8626 assert!(rc.witnessed_transitions.is_empty());
8627 assert!(rc.provided_triggers.is_empty());
8628 }
8629
8630 #[test]
8631 fn reverse_contributions_credit_qualified_provides() {
8632 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8633 let importer = module_of(
8634 "use \"./t.allium\" as tickets\nsurface Intake {\n provides:\n tickets/OpenTicket()\n tickets/CloseTicket(ticket)\n}\n",
8635 );
8636 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8637 assert!(rc.provided_triggers.contains("OpenTicket"));
8638 assert!(rc.provided_triggers.contains("CloseTicket"));
8639 }
8640
8641 #[test]
8642 fn reverse_contributions_credit_witnessed_transition() {
8643 let imported = module_of(
8644 "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",
8645 );
8646 let importer = module_of(
8647 "use \"./t.allium\" as tickets\nrule Archive {\n when: tickets/ArchiveTicketRequested(ticket)\n requires: ticket.status = closed\n ensures: ticket.status = archived\n}\n",
8648 );
8649 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8650 assert!(rc
8651 .witnessed_transitions
8652 .get("Ticket")
8653 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
8654 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8656 }
8657
8658 #[test]
8659 fn reverse_contributions_credit_importer_owned_trigger_via_qualified_context() {
8660 let imported = module_of(
8664 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
8665 );
8666 let importer = module_of(
8667 "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",
8668 );
8669 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8670 assert!(
8671 rc.witnessed_transitions
8672 .get("Ticket")
8673 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))),
8674 "importer-owned trigger typed via qualified context must witness the transition. Got: {:?}",
8675 rc.witnessed_transitions
8676 );
8677 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8678 }
8679
8680 #[test]
8681 fn reverse_contributions_credit_becomes_transition_trigger_witness() {
8682 let imported = module_of(
8685 "entity Ticket {\n status: closed | archived\n transitions status {\n closed -> archived\n terminal: archived\n }\n}\n",
8686 );
8687 let importer = module_of(
8688 "use \"./t.allium\" as tickets\nrule Archive {\n when: t: tickets/Ticket.status becomes closed\n ensures: t.status = archived\n}\n",
8689 );
8690 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8691 assert!(rc
8692 .witnessed_transitions
8693 .get("Ticket")
8694 .is_some_and(|e| e.contains(&("closed".to_string(), "archived".to_string()))));
8695 assert!(rc.assigned_statuses.get("Ticket").is_some_and(|s| s.contains("archived")));
8696 }
8697
8698 #[test]
8699 fn reverse_contributions_require_the_matching_alias() {
8700 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8701 let importer = module_of(
8702 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: open)\n}\n",
8703 );
8704 let rc = collect_reverse_contributions(&importer, "other", &imported);
8706 assert!(rc.is_empty());
8707 }
8708
8709 #[test]
8710 fn reverse_contributions_credit_qualified_field_reference() {
8711 let imported =
8712 module_of("entity Ticket {\n status: open | closed\n due_at: Timestamp\n}\n");
8713 let importer = module_of(
8714 "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",
8715 );
8716 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8719 assert!(rc.referenced_fields.contains("due_at"));
8720 let other = collect_reverse_contributions(&importer, "other", &imported);
8721 assert!(!other.referenced_fields.contains("due_at"));
8722 }
8723
8724 #[test]
8725 fn reverse_contributions_filter_undeclared_status() {
8726 let imported = module_of("entity Ticket {\n status: open | closed\n}\n");
8727 let importer = module_of(
8729 "use \"./t.allium\" as tickets\nrule Create {\n when: Go()\n ensures: tickets/Ticket.created(status: pending)\n}\n",
8730 );
8731 let rc = collect_reverse_contributions(&importer, "tickets", &imported);
8732 assert!(rc.is_empty());
8733 }
8734}