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
49pub fn analyze_with_cross_module(
60 module: &Module,
61 source: &str,
62 external_refs: &HashSet<String>,
63 resolved_use_paths: &HashSet<String>,
64 imported_triggers: &HashMap<String, HashSet<String>>,
65 ambiguous_imports: &AmbiguousImports,
66) -> Vec<Diagnostic> {
67 run_checks(
68 Ctx::new(
69 module,
70 external_refs,
71 Some(resolved_use_paths),
72 Some(imported_triggers),
73 Some(ambiguous_imports),
74 ),
75 source,
76 )
77}
78
79fn run_checks(mut ctx: Ctx<'_>, source: &str) -> Vec<Diagnostic> {
80 ctx.check_related_surface_references();
81 ctx.check_discriminator_variants();
82 ctx.check_surface_binding_usage();
83 ctx.check_status_state_machine();
84 ctx.check_external_entity_source_hints();
85 ctx.check_type_references();
86 ctx.check_unreachable_triggers();
87 ctx.check_unused_fields();
88 ctx.check_unused_entities();
89 ctx.check_unused_definitions();
90 ctx.check_unresolved_use_paths();
91 ctx.check_ambiguous_imported_names();
92 ctx.check_deferred_location_hints(source);
93 ctx.check_rule_invalid_triggers();
94 ctx.check_rule_undefined_bindings();
95 ctx.check_duplicate_let_bindings();
96 ctx.check_config_undefined_references();
97
98 apply_suppressions(ctx.diagnostics, source)
99}
100
101pub fn analyse(module: &Module, source: &str) -> crate::diagnostic::AnalyseResult {
104 let empty = HashSet::new();
105 analyse_with_external_refs(module, source, &empty)
106}
107
108pub fn analyse_with_external_refs(
111 module: &Module,
112 source: &str,
113 external_refs: &HashSet<String>,
114) -> crate::diagnostic::AnalyseResult {
115 let diagnostics = analyze_with_external_refs(module, source, external_refs);
116 let findings = find_process_issues(module, None);
117 crate::diagnostic::AnalyseResult {
118 diagnostics,
119 findings,
120 }
121}
122
123pub fn analyse_with_cross_module(
126 module: &Module,
127 source: &str,
128 external_refs: &HashSet<String>,
129 resolved_use_paths: &HashSet<String>,
130 imported_triggers: &HashMap<String, HashSet<String>>,
131 ambiguous_imports: &AmbiguousImports,
132) -> crate::diagnostic::AnalyseResult {
133 let diagnostics = analyze_with_cross_module(
134 module,
135 source,
136 external_refs,
137 resolved_use_paths,
138 imported_triggers,
139 ambiguous_imports,
140 );
141 let findings = find_process_issues(module, Some(imported_triggers));
142 crate::diagnostic::AnalyseResult {
143 diagnostics,
144 findings,
145 }
146}
147
148struct EntityInfo<'a> {
150 status_values: HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
152 field_types: HashMap<&'a str, HashMap<&'a str, &'a str>>,
154 graph_edges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
156 terminals: HashMap<&'a str, HashSet<&'a str>>,
158}
159
160impl<'a> EntityInfo<'a> {
161 fn from_module(module: &'a Module) -> Self {
162 let mut status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = HashMap::new();
163 let mut field_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
164 let mut graph_edges: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
165 let mut terminals: HashMap<&str, HashSet<&str>> = HashMap::new();
166
167 let entities = module.declarations.iter().filter_map(|d| match d {
168 Decl::Block(b) if b.kind == BlockKind::Entity => Some(b),
169 _ => None,
170 });
171 for entity in entities {
172 let name = match &entity.name {
173 Some(n) => n.name.as_str(),
174 None => continue,
175 };
176 for item in &entity.items {
177 match &item.kind {
178 BlockItemKind::Assignment { name: f, value } if f.name == "status" => {
179 let mut idents = Vec::new();
180 collect_pipe_idents(value, &mut idents);
181 if idents.len() >= 2
182 && !idents.iter().any(|id| starts_uppercase(&id.name))
183 {
184 let set: HashSet<&str> =
185 idents.iter().map(|id| id.name.as_str()).collect();
186 status_values.insert(name, (set, idents));
187 }
188 }
189 BlockItemKind::Assignment { name: f, value } => {
190 if let Some(t) = extract_field_entity_type(value) {
191 field_types.entry(name).or_default().insert(f.name.as_str(), t);
192 }
193 }
194 BlockItemKind::TransitionsBlock(graph) => {
195 let edges: Vec<(&str, &str)> = graph
196 .edges
197 .iter()
198 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
199 .collect();
200 graph_edges.insert(name, edges);
201 let terms: HashSet<&str> =
202 graph.terminal.iter().map(|t| t.name.as_str()).collect();
203 if !terms.is_empty() {
204 terminals.insert(name, terms);
205 }
206 }
207 _ => {}
208 }
209 }
210 }
211
212 Self { status_values, field_types, graph_edges, terminals }
213 }
214
215 fn status_by_entity(&self) -> HashMap<&'a str, HashSet<&'a str>> {
217 self.status_values
218 .iter()
219 .map(|(k, (set, _))| (*k, set.clone()))
220 .collect()
221 }
222}
223
224fn find_process_issues(
226 module: &Module,
227 imported_triggers: Option<&HashMap<String, HashSet<String>>>,
228) -> Vec<crate::diagnostic::Finding> {
229 let empty = HashSet::new();
230 let mut ctx = Ctx::new(module, &empty, None, imported_triggers, None);
231 let info = EntityInfo::from_module(module);
232 ctx.collect_process_findings(&info);
233 ctx.collect_conflict_findings(&info);
234 ctx.collect_invariant_findings(&info);
235 std::mem::take(&mut ctx.findings)
236}
237
238fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
243 if diagnostics.is_empty() {
244 return diagnostics;
245 }
246 let sm = SourceMap::new(source);
247 let directives = collect_suppression_directives(source, &sm);
248 if directives.is_empty() {
249 return diagnostics;
250 }
251 diagnostics
252 .into_iter()
253 .filter(|d| {
254 let (line, _) = sm.line_col(d.span.start);
255 let line = line as i64;
256 let active = directives
257 .get(&(line as u32))
258 .or_else(|| directives.get(&((line - 1).max(0) as u32)));
259 match (active, d.code) {
260 (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
261 (Some(codes), None) => !codes.contains("all"),
262 _ => true,
263 }
264 })
265 .collect()
266}
267
268fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
269 let mut directives = HashMap::new();
270 let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
271 for m in pattern.find_iter(source) {
272 let text = m.as_str();
273 let (line, _) = sm.line_col(m.start());
274 if let Some(idx) = text.find("allium-ignore") {
276 let offset = m.start() + idx + "allium-ignore".len();
277 let source_after = &source[offset..m.end()];
278 let codes: HashSet<&'a str> = source_after
279 .split(',')
280 .map(|c| c.trim())
281 .filter(|c| !c.is_empty())
282 .collect();
283 directives.insert(line, codes);
284 }
285 }
286 directives
287}
288
289struct Ctx<'a> {
294 module: &'a Module,
295 external_refs: &'a HashSet<String>,
296 resolved_use_paths: Option<&'a HashSet<String>>,
300 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
305 ambiguous_imports: Option<&'a AmbiguousImports>,
309 diagnostics: Vec<Diagnostic>,
310 findings: Vec<crate::diagnostic::Finding>,
311}
312
313impl<'a> Ctx<'a> {
314 fn new(
315 module: &'a Module,
316 external_refs: &'a HashSet<String>,
317 resolved_use_paths: Option<&'a HashSet<String>>,
318 imported_triggers: Option<&'a HashMap<String, HashSet<String>>>,
319 ambiguous_imports: Option<&'a AmbiguousImports>,
320 ) -> Self {
321 Self {
322 module,
323 external_refs,
324 resolved_use_paths,
325 imported_triggers,
326 ambiguous_imports,
327 diagnostics: Vec::new(),
328 findings: Vec::new(),
329 }
330 }
331
332 fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
333 self.module.declarations.iter().filter_map(move |d| match d {
334 Decl::Block(b) if b.kind == kind => Some(b),
335 _ => None,
336 })
337 }
338
339 fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
340 self.module
341 .declarations
342 .iter()
343 .filter_map(|d| match d {
344 Decl::Variant(v) => Some(v),
345 _ => None,
346 })
347 }
348
349 fn has_use_imports(&self) -> bool {
350 self.module
351 .declarations
352 .iter()
353 .any(|d| matches!(d, Decl::Use(_)))
354 }
355
356 fn push(&mut self, d: Diagnostic) {
357 self.diagnostics.push(d);
358 }
359
360 fn push_finding(&mut self, finding: Finding) {
361 self.findings.push(finding);
362 }
363
364 fn declared_type_names(&self) -> HashSet<&'a str> {
367 let mut names = HashSet::new();
368 for d in &self.module.declarations {
369 match d {
370 Decl::Block(b) => {
371 if matches!(
372 b.kind,
373 BlockKind::Entity
374 | BlockKind::ExternalEntity
375 | BlockKind::Value
376 | BlockKind::Enum
377 | BlockKind::Actor
378 ) {
379 if let Some(n) = &b.name {
380 names.insert(n.name.as_str());
381 }
382 }
383 }
384 Decl::Variant(v) => {
385 names.insert(v.name.name.as_str());
386 }
387 _ => {}
388 }
389 }
390 for t in &[
392 "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
393 "List", "Set", "Map", "Any", "Void",
394 ] {
395 names.insert(t);
396 }
397 for d in &self.module.declarations {
399 if let Decl::Use(u) = d {
400 if let Some(alias) = &u.alias {
401 names.insert(alias.name.as_str());
402 }
403 }
404 }
405 names
406 }
407
408 fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
410 let mut names = HashSet::new();
411 for d in &self.module.declarations {
412 match d {
413 Decl::Block(b) => {
414 for item in &b.items {
415 collect_accessed_fields_from_item(&item.kind, &mut names);
416 }
417 }
418 Decl::Invariant(inv) => {
419 collect_accessed_fields_from_expr(&inv.body, &mut names);
420 }
421 _ => {}
422 }
423 }
424 names
425 }
426}
427
428impl Ctx<'_> {
433 fn check_related_surface_references(&mut self) {
434 let surface_names: HashSet<&str> = self
435 .blocks(BlockKind::Surface)
436 .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
437 .collect();
438
439 for surface in self.blocks(BlockKind::Surface) {
440 let surface_name = match &surface.name {
441 Some(n) => &n.name,
442 None => continue,
443 };
444
445 for item in &surface.items {
446 let BlockItemKind::Clause { keyword, value } = &item.kind else {
447 continue;
448 };
449 if keyword != "related" {
450 continue;
451 }
452
453 let refs = extract_related_surface_names(value);
454 for ident in refs {
455 if !surface_names.contains(ident.name.as_str()) {
456 self.push(
457 Diagnostic::error(
458 ident.span,
459 format!(
460 "Surface '{surface_name}' references unknown related surface '{}'.",
461 ident.name
462 ),
463 )
464 .with_code("allium.surface.relatedUndefined"),
465 );
466 }
467 }
468 }
469 }
470 }
471}
472
473fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
474 match expr {
475 Expr::Ident(id) => vec![id],
476 Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
477 Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
478 Expr::Block { items, .. } => items
479 .iter()
480 .flat_map(extract_related_surface_names)
481 .collect(),
482 _ => vec![],
483 }
484}
485
486fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
487 match expr {
488 Expr::Ident(id) => Some(id),
489 Expr::MemberAccess { object, .. } => extract_leading_ident(object),
490 _ => None,
491 }
492}
493
494impl Ctx<'_> {
499 fn check_discriminator_variants(&mut self) {
500 let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
501 for v in self.variants() {
502 let base_name = expr_as_ident(&v.base).or_else(|| {
503 if let Expr::JoinLookup { entity, .. } = &v.base {
505 expr_as_ident(entity)
506 } else {
507 None
508 }
509 });
510 if let Some(base_name) = base_name {
511 variants_by_base
512 .entry(base_name)
513 .or_default()
514 .insert(&v.name.name);
515 }
516 }
517
518 for entity in self.blocks(BlockKind::Entity) {
519 let entity_name = match &entity.name {
520 Some(n) => &n.name,
521 None => continue,
522 };
523
524 for item in &entity.items {
525 let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
526 continue;
527 };
528
529 let mut pipe_idents = Vec::new();
530 collect_pipe_idents(value, &mut pipe_idents);
531 if pipe_idents.len() < 2 {
532 continue;
533 }
534
535 let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
536 if !has_capitalised {
537 continue;
538 }
539
540 let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
541 if !all_capitalised {
542 self.push(
543 Diagnostic::error(
544 value.span(),
545 format!(
546 "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
547 field_name.name
548 ),
549 )
550 .with_code("allium.sum.invalidDiscriminator"),
551 );
552 continue;
553 }
554
555 let declared = variants_by_base
556 .get(entity_name.as_str())
557 .cloned()
558 .unwrap_or_default();
559
560 let missing: Vec<&&Ident> = pipe_idents
561 .iter()
562 .filter(|id| !declared.contains(id.name.as_str()))
563 .collect();
564
565 if missing.len() == pipe_idents.len() && declared.is_empty() {
566 self.push(
567 Diagnostic::error(
568 value.span(),
569 format!(
570 "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
571 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
572 declarations. Use lowercase values for a plain enum.",
573 field_name.name
574 ),
575 )
576 .with_code("allium.sum.v1InlineEnum"),
577 );
578 } else {
579 for id in missing {
580 self.push(
581 Diagnostic::error(
582 id.span,
583 format!(
584 "Entity '{entity_name}' discriminator references '{}' without matching \
585 'variant {} : {entity_name}'.",
586 id.name, id.name
587 ),
588 )
589 .with_code("allium.sum.discriminatorUnknownVariant"),
590 );
591 }
592 }
593 }
594 }
595 }
596}
597
598fn starts_uppercase(s: &str) -> bool {
599 s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
600}
601
602fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
603 match expr {
604 Expr::Ident(id) => out.push(id),
605 Expr::Pipe { left, right, .. } => {
606 collect_pipe_idents(left, out);
607 collect_pipe_idents(right, out);
608 }
609 _ => {}
610 }
611}
612
613fn expr_as_ident(expr: &Expr) -> Option<&str> {
614 match expr {
615 Expr::Ident(id) => Some(&id.name),
616 _ => None,
617 }
618}
619
620impl Ctx<'_> {
625 fn check_surface_binding_usage(&mut self) {
626 for surface in self.blocks(BlockKind::Surface) {
627 let surface_name = match &surface.name {
628 Some(n) => &n.name,
629 None => continue,
630 };
631
632 let has_provides = surface
634 .items
635 .iter()
636 .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
637
638 let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
640 let BlockItemKind::Clause { keyword, value } = &item.kind else {
641 continue;
642 };
643 if keyword != "facing" && keyword != "context" {
644 continue;
645 }
646 if let Expr::Binding { name, .. } = value {
647 bindings.push((&name.name, name.span, keyword == "facing"));
648 }
649 }
650
651 for (name, span, is_facing) in &bindings {
652 if *name == "_" {
653 continue;
654 }
655 if *is_facing && !has_provides {
657 continue;
658 }
659 let used = surface.items.iter().any(|item| {
660 let BlockItemKind::Clause { keyword, value } = &item.kind else {
661 return item_contains_ident(&item.kind, name);
662 };
663 if keyword == "facing" || keyword == "context" {
664 if let Expr::Binding {
665 name: binding_name, ..
666 } = value
667 {
668 if binding_name.name == *name {
669 return false;
670 }
671 }
672 }
673 expr_contains_ident(value, name)
674 });
675
676 if !used {
677 self.push(
678 Diagnostic::warning(
679 *span,
680 format!(
681 "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
682 ),
683 )
684 .with_code("allium.surface.unusedBinding"),
685 );
686 }
687 }
688 }
689 }
690}
691
692impl Ctx<'_> {
697 fn check_status_state_machine(&mut self) {
698 let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
699 let mut terminal_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
700 let mut has_transitions: HashSet<&str> = HashSet::new();
701 let mut declared_edges: HashMap<&str, HashSet<(&str, &str)>> = HashMap::new();
702 let mut field_entity_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
703 for entity in self.blocks(BlockKind::Entity) {
704 let entity_name = match &entity.name {
705 Some(n) => n.name.as_str(),
706 None => continue,
707 };
708 for item in &entity.items {
709 match &item.kind {
710 BlockItemKind::Assignment { name, value } if name.name == "status" => {
711 let mut idents = Vec::new();
712 collect_pipe_idents(value, &mut idents);
713 if idents.len() < 2 {
714 continue;
715 }
716 if idents.iter().any(|id| starts_uppercase(&id.name)) {
717 continue;
718 }
719 let set: HashSet<&str> =
720 idents.iter().map(|id| id.name.as_str()).collect();
721 status_by_entity.insert(entity_name, (idents, set));
722 }
723 BlockItemKind::Assignment { name, value } => {
724 if let Some(type_name) = extract_field_entity_type(value) {
726 field_entity_types
727 .entry(entity_name)
728 .or_default()
729 .insert(name.name.as_str(), type_name);
730 }
731 }
732 BlockItemKind::TransitionsBlock(graph) => {
733 has_transitions.insert(entity_name);
734 let terminals: HashSet<&str> =
735 graph.terminal.iter().map(|t| t.name.as_str()).collect();
736 if !terminals.is_empty() {
737 terminal_by_entity.insert(entity_name, terminals);
738 }
739 let edges: HashSet<(&str, &str)> = graph
740 .edges
741 .iter()
742 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
743 .collect();
744 declared_edges.insert(entity_name, edges);
745 }
746 _ => {}
747 }
748 }
749 }
750 for fields in field_entity_types.values_mut() {
752 fields.retain(|_, type_name| status_by_entity.contains_key(type_name));
753 }
754
755 if status_by_entity.is_empty() {
756 return;
757 }
758
759 let mut command_param_types: HashMap<&str, Vec<Option<&str>>> = HashMap::new();
764 for surface in self.blocks(BlockKind::Surface) {
765 for item in &surface.items {
766 let BlockItemKind::Clause { keyword, value } = &item.kind else {
767 continue;
768 };
769 if keyword == "provides" {
770 collect_command_param_types(value, &status_by_entity, &mut command_param_types);
771 }
772 }
773 }
774
775 let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
776 let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
777 HashMap::new();
778 let mut created_issues: Vec<Diagnostic> = Vec::new();
779
780 for rule in self.blocks(BlockKind::Rule) {
781 let mut binding_types = collect_rule_binding_types(rule, &status_by_entity);
782 augment_binding_types_from_commands(rule, &command_param_types, &mut binding_types);
783 let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
784
785 for item in &rule.items {
786 let BlockItemKind::Clause { keyword, value } = &item.kind else {
787 continue;
788 };
789 if keyword != "requires" {
790 continue;
791 }
792 visit_status_comparisons(
793 value,
794 &binding_types,
795 &status_by_entity,
796 &field_entity_types,
797 &mut |binding, status| {
798 requires_by_binding
799 .entry(binding)
800 .or_default()
801 .insert(status);
802 },
803 );
804 }
805
806 for item in &rule.items {
807 let BlockItemKind::Clause { keyword, value } = &item.kind else {
808 continue;
809 };
810 if keyword != "ensures" {
811 continue;
812 }
813 visit_status_assignments(
814 value,
815 &binding_types,
816 &status_by_entity,
817 &field_entity_types,
818 &mut |binding, target, entity| {
819 assigned_by_entity
820 .entry(entity)
821 .or_default()
822 .insert(target);
823
824 if let Some(sources) = requires_by_binding.get(binding) {
825 let entity_transitions =
826 transitions_by_entity.entry(entity).or_default();
827 for source in sources {
828 entity_transitions
829 .entry(source)
830 .or_default()
831 .insert(target);
832 }
833 }
834 },
835 );
836 visit_created_calls(
837 value,
838 &status_by_entity,
839 &has_transitions,
840 &mut |entity, status| {
841 assigned_by_entity
842 .entry(entity)
843 .or_default()
844 .insert(status);
845 },
846 &mut created_issues,
847 );
848 }
849 }
850
851 for (entity_name, (idents, values)) in &status_by_entity {
852 let assigned = assigned_by_entity.get(entity_name);
853 let transitions = transitions_by_entity.get(entity_name);
854
855 if let Some(assigned) = assigned {
856 if assigned.iter().any(|v| !values.contains(v)) {
857 continue;
858 }
859 }
860
861 let assigned_set = assigned.cloned().unwrap_or_default();
862 let transition_map = transitions.cloned().unwrap_or_default();
863
864 for id in idents {
865 if !assigned_set.contains(id.name.as_str()) {
866 self.push(
867 Diagnostic::warning(
868 id.span,
869 format!(
870 "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
871 id.name
872 ),
873 )
874 .with_code("allium.status.unreachableValue"),
875 );
876 }
877
878 let is_terminal = terminal_by_entity
879 .get(entity_name)
880 .map_or_else(
881 || is_likely_terminal(&id.name),
882 |terminals| terminals.contains(id.name.as_str()),
883 );
884 if is_terminal {
885 continue;
886 }
887 let exits = transition_map.get(id.name.as_str());
888 if exits.is_some_and(|e| !e.is_empty()) {
889 continue;
890 }
891 self.push(
892 Diagnostic::warning(
893 id.span,
894 format!(
895 "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
896 id.name
897 ),
898 )
899 .with_code("allium.status.noExit"),
900 );
901 }
902 }
903
904 for (entity_name, transition_map) in &transitions_by_entity {
906 if let Some(edges) = declared_edges.get(entity_name) {
907 if let Some((idents, _)) = status_by_entity.get(entity_name) {
908 for (from, targets) in transition_map {
909 for to in targets {
910 if from != to && !edges.contains(&(*from, *to)) {
911 let span = idents
913 .iter()
914 .find(|id| id.name == *from)
915 .map(|id| id.span)
916 .unwrap_or(idents[0].span);
917 self.push(
918 Diagnostic::warning(
919 span,
920 format!(
921 "Rule produces transition '{from}' → '{to}' on entity '{entity_name}', but this edge is not in the declared transition graph.",
922 ),
923 )
924 .with_code("allium.status.undeclaredTransition"),
925 );
926 }
927 }
928 }
929 }
930 }
931 }
932
933 for issue in created_issues {
934 self.push(issue);
935 }
936 }
937}
938
939impl Ctx<'_> {
944 fn collect_process_findings(&mut self, info: &EntityInfo<'_>) {
945 let status_values = &info.status_values;
946 let field_types = &info.field_types;
947 let graph_edges = &info.graph_edges;
948 let terminals = &info.terminals;
949
950 if status_values.is_empty() {
951 return;
952 }
953
954 let mut surface_triggers: HashSet<&str> = HashSet::new();
956 let mut surface_names: Vec<String> = Vec::new();
957 for surface in self.blocks(BlockKind::Surface) {
958 if let Some(n) = &surface.name {
959 surface_names.push(n.name.clone());
960 }
961 for item in &surface.items {
962 let BlockItemKind::Clause { keyword, value } = &item.kind else {
963 continue;
964 };
965 if keyword == "provides" {
966 collect_call_names(value, &mut surface_triggers);
967 }
968 }
969 }
970
971 let mut emitted_triggers: HashSet<&str> = HashSet::new();
973 for rule in self.blocks(BlockKind::Rule) {
974 for item in &rule.items {
975 collect_emitted_trigger_from_item(&item.kind, &mut emitted_triggers);
976 }
977 }
978
979 let mut assigned_fields: HashSet<String> = HashSet::new();
981
982 struct RuleData<'b> {
983 name: &'b str,
984 trigger_reachable: bool,
985 requires_fields: Vec<(String, String, String)>,
986 transitions: Vec<(String, String, String)>,
987 field_assignments: HashSet<String>,
988 entity_bindings: Vec<String>,
989 }
990 let mut rules: Vec<RuleData> = Vec::new();
991
992 for rule in self.blocks(BlockKind::Rule) {
993 let rule_name = match &rule.name {
994 Some(n) => n.name.as_str(),
995 None => continue,
996 };
997 let mut trigger_ref: Option<TriggerRef<'_>> = None;
998 let mut requires_statuses: HashMap<&str, HashSet<&str>> = HashMap::new();
999 let mut requires_fields: Vec<(String, String, String)> = Vec::new();
1000 let mut ensures_statuses: Vec<(&str, &str)> = Vec::new();
1001 let mut rule_assigned: HashSet<String> = HashSet::new();
1002
1003 for item in &rule.items {
1004 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1005 continue;
1006 };
1007 if keyword == "when" {
1008 let mut refs = extract_trigger_refs(value);
1009 if !refs.is_empty() {
1010 trigger_ref = Some(refs.remove(0));
1011 }
1012 }
1013 }
1014
1015 let trigger_reachable = trigger_ref.as_ref().map_or(true, |t| {
1019 self.trigger_reachability(t, &surface_triggers, &emitted_triggers)
1020 .unwrap_or(true)
1021 });
1022
1023 let binding_types = collect_rule_binding_types(rule, &status_values_for_binding(&status_values));
1024
1025 let entity_bindings: Vec<String> = binding_types
1027 .values()
1028 .map(|v| v.to_string())
1029 .collect::<HashSet<_>>()
1030 .into_iter()
1031 .collect();
1032
1033 for item in &rule.items {
1034 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1035 continue;
1036 };
1037 if keyword != "requires" {
1038 continue;
1039 }
1040 collect_requires_conditions(
1041 value,
1042 &binding_types,
1043 status_values,
1044 &mut |binding, field, val| {
1045 if field == "status" {
1046 requires_statuses
1047 .entry(binding)
1048 .or_default()
1049 .insert(val);
1050 } else {
1051 let entity = resolve_binding_entity_from_status(
1052 binding, None, &binding_types, &status_values,
1053 );
1054 if let Some(e) = entity {
1055 requires_fields.push((
1056 e.to_string(),
1057 field.to_string(),
1058 val.to_string(),
1059 ));
1060 }
1061 }
1062 },
1063 );
1064 }
1065
1066 for item in &rule.items {
1067 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1068 continue;
1069 };
1070 if keyword != "ensures" {
1071 continue;
1072 }
1073 collect_field_assignments(
1074 value,
1075 &binding_types,
1076 &status_values,
1077 &field_types,
1078 &mut |entity, field, value| {
1079 let key = format!("{entity}.{field}");
1080 assigned_fields.insert(key.clone());
1081 rule_assigned.insert(key);
1082 if field == "status" && value != "_variable_" {
1083 assigned_fields.insert(format!("{entity}.status.{value}"));
1084 }
1085 },
1086 );
1087 collect_ensures_status(
1088 value,
1089 &binding_types,
1090 &status_values,
1091 &field_types,
1092 &mut |binding, target| {
1093 ensures_statuses.push((binding, target));
1094 },
1095 );
1096 }
1097
1098 let mut transitions = Vec::new();
1099 for (binding, target) in &ensures_statuses {
1100 let entity = resolve_binding_entity_from_status(
1101 binding,
1102 Some(target),
1103 &binding_types,
1104 &status_values,
1105 );
1106 if let Some(e) = entity {
1107 if let Some(sources) = requires_statuses.get(binding) {
1108 for source in sources {
1109 transitions.push((
1110 e.to_string(),
1111 source.to_string(),
1112 target.to_string(),
1113 ));
1114 }
1115 }
1116 }
1117 }
1118
1119 rules.push(RuleData {
1120 name: rule_name,
1121 trigger_reachable,
1122 requires_fields,
1123 transitions,
1124 field_assignments: rule_assigned,
1125 entity_bindings,
1126 });
1127 }
1128
1129 let mut created_fields: HashSet<String> = HashSet::new();
1131 for rule in self.blocks(BlockKind::Rule) {
1132 for item in &rule.items {
1133 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1134 continue;
1135 };
1136 if keyword != "ensures" {
1137 continue;
1138 }
1139 collect_created_field_assignments(value, &status_values, &mut assigned_fields);
1140 collect_created_field_assignments(value, &status_values, &mut created_fields);
1141 }
1142 }
1143
1144 let mut surface_provided_fields: HashSet<String> = HashSet::new();
1146 for surface in self.blocks(BlockKind::Surface) {
1147 for item in &surface.items {
1148 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1149 continue;
1150 };
1151 if keyword == "provides" {
1152 collect_surface_provided_fields(value, &status_values, &mut surface_provided_fields);
1153 }
1154 }
1155 }
1156
1157 let build_searched = |entity: &str, field: &str| -> Vec<serde_json::Value> {
1159 let key = format!("{entity}.{field}");
1160 let mut searched = Vec::new();
1161
1162 let matching_rule: Option<&RuleData> = rules.iter().find(|r| {
1164 r.field_assignments.contains(&key)
1165 });
1166 if let Some(r) = matching_rule {
1167 if !r.trigger_reachable {
1168 searched.push(serde_json::json!({
1169 "kind": "rule_ensures",
1170 "found": r.name,
1171 "but": "trigger has no providing surface"
1172 }));
1173 } else {
1174 searched.push(serde_json::json!({
1175 "kind": "rule_ensures",
1176 "found": r.name
1177 }));
1178 }
1179 } else {
1180 searched.push(serde_json::json!({
1181 "kind": "rule_ensures",
1182 "found": false
1183 }));
1184 }
1185
1186 searched.push(serde_json::json!({
1188 "kind": "surface_provides",
1189 "found": surface_provided_fields.contains(&key)
1190 }));
1191
1192 searched.push(serde_json::json!({
1194 "kind": "created_calls",
1195 "found": created_fields.contains(&key)
1196 }));
1197
1198 searched
1199 };
1200
1201 for (entity, edges) in graph_edges {
1203 let _statuses = match status_values.get(entity) {
1204 Some(v) => v,
1205 None => continue,
1206 };
1207
1208 for (from, to) in edges {
1209 let witnesses: Vec<&RuleData> = rules
1210 .iter()
1211 .filter(|r| {
1212 r.transitions
1213 .iter()
1214 .any(|(e, f, t)| e == *entity && f == *from && t == *to)
1215 })
1216 .collect();
1217
1218 if witnesses.is_empty() {
1219 continue;
1220 }
1221
1222 let any_achievable = witnesses.iter().any(|r| {
1223 r.requires_fields.iter().all(|(e, f, _v)| {
1224 assigned_fields.contains(&format!("{e}.{f}"))
1225 })
1226 });
1227
1228 if !any_achievable {
1229 let witness_names: Vec<String> =
1230 witnesses.iter().map(|r| r.name.to_string()).collect();
1231 let unsatisfiable: Vec<serde_json::Value> = witnesses
1232 .iter()
1233 .flat_map(|r| {
1234 r.requires_fields.iter().filter(|(e, f, _)| {
1235 !assigned_fields.contains(&format!("{e}.{f}"))
1236 })
1237 })
1238 .map(|(e, f, v)| {
1239 serde_json::json!({
1240 "entity": e,
1241 "field": f,
1242 "value": v,
1243 "searched": build_searched(e, f),
1244 })
1245 })
1246 .collect();
1247
1248 self.push_finding(serde_json::json!({
1249 "type": "dead_transition",
1250 "summary": format!(
1251 "Transition '{from}' → '{to}' on entity '{entity}' is declared but unachievable"
1252 ),
1253 "edge": {"entity": entity, "from": from, "to": to},
1254 "witnessing_rules": witness_names,
1255 "unsatisfiable_requires": unsatisfiable,
1256 "affected_entities": [entity],
1257 }));
1258 }
1259 }
1260 }
1261
1262 for r in &rules {
1264 for (entity, field, value) in &r.requires_fields {
1265 let key = format!("{entity}.{field}");
1266 if !assigned_fields.contains(&key) {
1267 self.push_finding(serde_json::json!({
1268 "type": "missing_producer",
1269 "summary": format!("Nothing establishes {entity}.{field} = {value}"),
1270 "requires": {"rule": r.name, "field": field, "value": value},
1271 "searched": build_searched(entity, field),
1272 "affected_entities": [entity],
1273 }));
1274 }
1275 }
1276 }
1277
1278 for (entity, edges) in graph_edges {
1280 let entity_terminals = match terminals.get(entity) {
1281 Some(t) => t,
1282 None => continue,
1283 };
1284 let (statuses, _idents) = match status_values.get(entity) {
1285 Some(v) => v,
1286 None => continue,
1287 };
1288
1289 let achievable_edges: HashSet<(&str, &str)> = edges
1290 .iter()
1291 .filter(|(_from, to)| {
1292 let producers: Vec<&RuleData> = rules
1293 .iter()
1294 .filter(|r| {
1295 r.transitions
1296 .iter()
1297 .any(|(e, _f, t)| e == *entity && t == *to)
1298 })
1299 .collect();
1300 if producers.is_empty() {
1301 return assigned_fields.contains(&format!("{entity}.status.{to}"));
1302 }
1303 producers.iter().any(|r| {
1304 r.requires_fields.iter().all(|(e, f, _v)| {
1305 assigned_fields.contains(&format!("{e}.{f}"))
1306 })
1307 })
1308 })
1309 .copied()
1310 .collect();
1311
1312 for status in statuses {
1313 if entity_terminals.contains(status) {
1314 continue;
1315 }
1316 let mut visited = HashSet::new();
1317 let mut queue = vec![*status];
1318 let mut found_terminal = false;
1319 while let Some(current) = queue.pop() {
1320 if !visited.insert(current) {
1321 continue;
1322 }
1323 if entity_terminals.contains(current) {
1324 found_terminal = true;
1325 break;
1326 }
1327 for (from, to) in &achievable_edges {
1328 if *from == current {
1329 queue.push(to);
1330 }
1331 }
1332 }
1333 if !found_terminal {
1334 let has_inbound = achievable_edges
1335 .iter()
1336 .any(|(_, to)| *to == *status);
1337
1338 if has_inbound || statuses.len() <= 6 {
1339 let outbound: Vec<serde_json::Value> = edges
1341 .iter()
1342 .filter(|(f, _)| *f == *status)
1343 .map(|(f, t)| {
1344 let witness_rules: Vec<(&str, &[(String, String, String)])> =
1345 rules
1346 .iter()
1347 .filter(|r| {
1348 r.transitions.iter().any(|(e, _ef, et)| {
1349 e == *entity && et == *t
1350 })
1351 })
1352 .map(|r| {
1353 (r.name, r.requires_fields.as_slice())
1354 })
1355 .collect();
1356 let reason = edge_blocked_reason(
1357 &witness_rules, &assigned_fields,
1358 );
1359 serde_json::json!({
1360 "from": f,
1361 "to": t,
1362 "reason": reason,
1363 })
1364 })
1365 .collect();
1366
1367 let cycle = detect_cycle(*status, &achievable_edges);
1369
1370 self.push_finding(serde_json::json!({
1371 "type": "deadlock",
1372 "summary": format!(
1373 "Entity '{entity}' can reach state '{status}' but has no achievable path to any terminal state"
1374 ),
1375 "state": status,
1376 "outbound_edges": outbound,
1377 "cycle": cycle,
1378 "affected_entities": [entity],
1379 }));
1380 }
1381 }
1382 }
1383 }
1384
1385 let mut unreachable_by_trigger: HashMap<String, Vec<(&str, Vec<String>)>> = HashMap::new();
1387 for rule in self.blocks(BlockKind::Rule) {
1388 let rule_name = match &rule.name {
1389 Some(n) => n.name.as_str(),
1390 None => continue,
1391 };
1392 for item in &rule.items {
1393 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1394 continue;
1395 };
1396 if keyword != "when" {
1397 continue;
1398 }
1399 for tref in extract_trigger_refs(value) {
1400 if self.trigger_reachability(&tref, &surface_triggers, &emitted_triggers)
1401 == Some(false)
1402 {
1403 let rule_data = rules.iter().find(|r| r.name == rule_name);
1405 let bindings = rule_data
1406 .map(|r| r.entity_bindings.clone())
1407 .unwrap_or_default();
1408 unreachable_by_trigger
1409 .entry(tref.display())
1410 .or_default()
1411 .push((rule_name, bindings));
1412 }
1413 }
1414 }
1415 }
1416 for (trigger, rule_entries) in &unreachable_by_trigger {
1417 let listening_rules: Vec<&str> = rule_entries.iter().map(|(n, _)| *n).collect();
1418 let affected_entities: Vec<String> = rule_entries
1419 .iter()
1420 .flat_map(|(_, bindings)| bindings.iter().cloned())
1421 .collect::<HashSet<_>>()
1422 .into_iter()
1423 .collect();
1424 self.push_finding(serde_json::json!({
1425 "type": "unreachable_trigger",
1426 "summary": format!(
1427 "Trigger '{trigger}' is not provided by any surface"
1428 ),
1429 "trigger": trigger,
1430 "listening_rules": listening_rules,
1431 "surfaces_checked": surface_names,
1432 "affected_entities": affected_entities,
1433 }));
1434 }
1435 }
1436
1437 fn collect_conflict_findings(&mut self, info: &EntityInfo<'_>) {
1438 let status_by_entity = info.status_by_entity();
1439
1440 if status_by_entity.is_empty() {
1441 return;
1442 }
1443
1444 struct ConflictRule<'b> {
1445 name: &'b str,
1446 trigger_kind: ConflictTriggerKind<'b>,
1447 requires_statuses: HashMap<String, HashSet<String>>,
1448 ensures_statuses: HashMap<String, String>,
1449 }
1450
1451 let mut conflict_rules: Vec<ConflictRule> = Vec::new();
1452
1453 for rule in self.blocks(BlockKind::Rule) {
1454 let rule_name = match &rule.name {
1455 Some(n) => n.name.as_str(),
1456 None => continue,
1457 };
1458 let binding_types = collect_rule_binding_types(rule, &HashMap::new());
1461
1462 let mut trigger_kind = ConflictTriggerKind::Unknown;
1463 let mut requires_statuses: HashMap<String, HashSet<String>> = HashMap::new();
1464 let mut ensures_statuses: HashMap<String, String> = HashMap::new();
1465
1466 for item in &rule.items {
1467 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1468 continue;
1469 };
1470 match keyword.as_str() {
1471 "when" => {
1472 trigger_kind = classify_trigger(value);
1473 }
1474 "requires" => {
1475 collect_requires_statuses_for_conflict(
1476 value,
1477 &binding_types,
1478 &status_by_entity,
1479 &mut requires_statuses,
1480 );
1481 }
1482 "ensures" => {
1483 collect_ensures_statuses_for_conflict(
1484 value,
1485 &binding_types,
1486 &status_by_entity,
1487 &mut ensures_statuses,
1488 );
1489 }
1490 _ => {}
1491 }
1492 }
1493
1494 conflict_rules.push(ConflictRule {
1495 name: rule_name,
1496 trigger_kind,
1497 requires_statuses,
1498 ensures_statuses,
1499 });
1500 }
1501
1502 let mut reported: HashSet<(usize, usize)> = HashSet::new();
1504 for i in 0..conflict_rules.len() {
1505 for j in (i + 1)..conflict_rules.len() {
1506 let a = &conflict_rules[i];
1507 let b = &conflict_rules[j];
1508
1509 if matches!(
1510 (&a.trigger_kind, &b.trigger_kind),
1511 (ConflictTriggerKind::Call(_), ConflictTriggerKind::Call(_))
1512 ) {
1513 continue;
1514 }
1515
1516 let mut overlap_state: Option<(&str, &str)> = None;
1518 let mut compatible = false;
1519 for (entity, a_statuses) in &a.requires_statuses {
1520 if let Some(b_statuses) = b.requires_statuses.get(entity) {
1521 let intersection: Vec<&String> =
1522 a_statuses.intersection(b_statuses).collect();
1523 if !intersection.is_empty() {
1524 compatible = true;
1525 overlap_state = Some((entity.as_str(), intersection[0].as_str()));
1526 break;
1527 }
1528 }
1529 }
1530 if !compatible {
1531 continue;
1532 }
1533
1534 for (entity, a_target) in &a.ensures_statuses {
1535 if let Some(b_target) = b.ensures_statuses.get(entity) {
1536 if a_target != b_target && !reported.contains(&(i, j)) {
1537 reported.insert((i, j));
1538 let state = overlap_state
1539 .map(|(_, s)| s.to_string())
1540 .unwrap_or_default();
1541 let mut values = serde_json::Map::new();
1542 values.insert(a.name.to_string(), serde_json::json!(a_target));
1543 values.insert(b.name.to_string(), serde_json::json!(b_target));
1544
1545 self.push_finding(serde_json::json!({
1546 "type": "conflict",
1547 "summary": format!(
1548 "Rules '{}' and '{}' can both fire when entity '{entity}' is in state '{state}', setting status to conflicting values",
1549 a.name, b.name,
1550 ),
1551 "rule_a": a.name,
1552 "rule_b": b.name,
1553 "field": "status",
1554 "state": state,
1555 "values": values,
1556 "affected_entities": [entity],
1557 }));
1558 }
1559 }
1560 }
1561 }
1562 }
1563 }
1564
1565 fn collect_invariant_findings(&mut self, info: &EntityInfo<'_>) {
1566 let status_by_entity = info.status_by_entity();
1567 let field_types = &info.field_types;
1568
1569 struct RuleEffect<'b> {
1570 name: &'b str,
1571 status_sets: Vec<(String, String)>,
1572 field_sets: HashSet<String>,
1573 requires: Vec<(String, String, String)>,
1574 }
1575
1576 let binding_map: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
1577 .iter()
1578 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
1579 .collect();
1580 let binding_map_for_types: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = status_by_entity
1581 .iter()
1582 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1583 .collect();
1584 let mut rule_effects: Vec<RuleEffect> = Vec::new();
1585
1586 for rule in self.blocks(BlockKind::Rule) {
1587 let rule_name = match &rule.name {
1588 Some(n) => n.name.as_str(),
1589 None => continue,
1590 };
1591 let binding_types = collect_rule_binding_types(rule, &binding_map_for_types);
1592 let mut status_sets = Vec::new();
1593 let mut field_sets = HashSet::new();
1594 let mut requires = Vec::new();
1595
1596 for item in &rule.items {
1597 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1598 continue;
1599 };
1600 match keyword.as_str() {
1601 "ensures" => {
1602 collect_rule_effects(
1603 value,
1604 &binding_types,
1605 &status_by_entity,
1606 &field_types,
1607 &mut status_sets,
1608 &mut field_sets,
1609 );
1610 }
1611 "requires" => {
1612 collect_requires_conditions(
1613 value,
1614 &binding_types,
1615 &binding_map,
1616 &mut |binding, field, val| {
1617 let entity = resolve_binding_entity(
1618 binding,
1619 None,
1620 &binding_types,
1621 &binding_map_for_types,
1622 );
1623 if let Some(e) = entity {
1624 requires.push((
1625 e.to_string(),
1626 field.to_string(),
1627 val.to_string(),
1628 ));
1629 }
1630 },
1631 );
1632 }
1633 _ => {}
1634 }
1635 }
1636
1637 rule_effects.push(RuleEffect {
1638 name: rule_name,
1639 status_sets,
1640 field_sets,
1641 requires,
1642 });
1643 }
1644
1645 for decl in &self.module.declarations {
1647 let Decl::Invariant(inv) = decl else {
1648 continue;
1649 };
1650
1651 if let Some(pattern) = extract_uniqueness_invariant(&inv.body) {
1652 let key_entity_type: Option<&str> = status_by_entity
1653 .keys()
1654 .find_map(|entity_name| {
1655 field_types
1656 .get(entity_name)
1657 .and_then(|fields| fields.get(pattern.key_field).copied())
1658 });
1659
1660 for effect in &rule_effects {
1661 for (entity, target) in &effect.status_sets {
1662 if target == pattern.prohibited_status {
1663 let has_guard = key_entity_type.map_or(false, |ket| {
1664 effect.field_sets.iter().any(|f| {
1665 f.starts_with(&format!("{ket}."))
1666 }) || effect.requires.iter().any(|(e, _f, _v)| {
1667 e == ket
1668 })
1669 });
1670
1671 if !has_guard {
1672 let needed = format!(
1673 "Rule should set {}.status to prevent concurrent {} states",
1674 key_entity_type.unwrap_or("related entity"),
1675 pattern.prohibited_status,
1676 );
1677 self.push_finding(serde_json::json!({
1678 "type": "invariant_risk",
1679 "summary": format!(
1680 "Rule '{}' could violate invariant '{}'",
1681 effect.name, inv.name.name,
1682 ),
1683 "rule": effect.name,
1684 "invariant": inv.name.name,
1685 "mechanism": format!(
1686 "Sets {entity}.status to '{target}' without preventing concurrent instances"
1687 ),
1688 "guard_analysis": {
1689 "has_guard": false,
1690 "needed": needed,
1691 },
1692 "affected_entities": [entity],
1693 }));
1694 }
1695 }
1696 }
1697 }
1698 }
1699 }
1700 }
1701}
1702
1703fn edge_blocked_reason(
1708 witness_rules: &[(&str, &[(String, String, String)])],
1709 assigned_fields: &HashSet<String>,
1710) -> String {
1711 if witness_rules.is_empty() {
1712 return "no witnessing rule".to_string();
1713 }
1714
1715 for (name, requires_fields) in witness_rules {
1716 for (e, f, v) in *requires_fields {
1717 if !assigned_fields.contains(&format!("{e}.{f}")) {
1718 return format!(
1719 "rule {name} requires {e}.{f} = {v}, never established",
1720 );
1721 }
1722 }
1723 }
1724
1725 "no achievable witnessing rule".to_string()
1726}
1727
1728fn detect_cycle<'a>(
1731 start: &'a str,
1732 edges: &HashSet<(&'a str, &'a str)>,
1733) -> Option<Vec<&'a str>> {
1734 let mut stack: Vec<(&str, Vec<&str>)> = vec![(start, vec![start])];
1736 let mut visited: HashSet<&str> = HashSet::new();
1737
1738 while let Some((current, path)) = stack.pop() {
1739 if !visited.insert(current) {
1740 continue;
1741 }
1742 for (from, to) in edges {
1743 if *from != current {
1744 continue;
1745 }
1746 if let Some(pos) = path.iter().position(|s| *s == *to) {
1747 let mut cycle: Vec<&str> = path[pos..].to_vec();
1749 cycle.push(to);
1750 return Some(cycle);
1751 }
1752 let mut next_path = path.clone();
1753 next_path.push(to);
1754 visited.remove(to);
1756 stack.push((to, next_path));
1757 }
1758 }
1759 None
1760}
1761
1762fn collect_surface_provided_fields(
1764 expr: &Expr,
1765 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
1766 out: &mut HashSet<String>,
1767) {
1768 match expr {
1769 Expr::Call { function, args, .. } => {
1770 if let Expr::Ident(fn_name) = function.as_ref() {
1771 for arg in args {
1774 if let CallArg::Positional(Expr::Ident(binding)) = arg {
1775 if status_values.contains_key(binding.name.as_str()) {
1777 out.insert(format!("{}.status", binding.name));
1779 }
1780 }
1781 if let CallArg::Named(named) = arg {
1782 if let Expr::Ident(val) = &named.value {
1783 if status_values.contains_key(val.name.as_str()) {
1784 out.insert(format!("{}.{}", val.name, named.name.name));
1785 }
1786 }
1787 }
1788 }
1789 let _ = fn_name;
1791 }
1792 }
1793 Expr::Block { items, .. } => {
1794 for item in items {
1795 collect_surface_provided_fields(item, status_values, out);
1796 }
1797 }
1798 Expr::WhenGuard { action, .. } => {
1799 collect_surface_provided_fields(action, status_values, out);
1800 }
1801 Expr::Conditional { branches, else_body, .. } => {
1802 for b in branches {
1803 collect_surface_provided_fields(&b.body, status_values, out);
1804 }
1805 if let Some(body) = else_body {
1806 collect_surface_provided_fields(body, status_values, out);
1807 }
1808 }
1809 _ => {}
1810 }
1811}
1812
1813fn collect_rule_effects(
1815 expr: &Expr,
1816 binding_types: &HashMap<&str, &str>,
1817 status_by_entity: &HashMap<&str, HashSet<&str>>,
1818 field_types: &HashMap<&str, HashMap<&str, &str>>,
1819 status_sets: &mut Vec<(String, String)>,
1820 field_sets: &mut HashSet<String>,
1821) {
1822 match expr {
1823 Expr::Comparison {
1824 left,
1825 op: ComparisonOp::Eq,
1826 right,
1827 ..
1828 } => {
1829 if let Some(target) = expr_as_ident(right) {
1830 if let Some((binding, field)) = expr_as_member_access(left) {
1831 let entity = resolve_binding_entity(
1832 binding,
1833 if field == "status" { Some(target) } else { None },
1834 binding_types,
1835 &status_by_entity
1836 .iter()
1837 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1838 .collect(),
1839 );
1840 if let Some(e) = entity {
1841 if field == "status" {
1842 status_sets.push((e.to_string(), target.to_string()));
1843 }
1844 field_sets.insert(format!("{e}.{field}"));
1845 }
1846 }
1847 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
1849 let root_entity = resolve_binding_entity(
1850 root,
1851 None,
1852 binding_types,
1853 &status_by_entity
1854 .iter()
1855 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1856 .collect(),
1857 );
1858 if let Some(re) = root_entity {
1859 if let Some(nested) =
1860 field_types.get(re).and_then(|f| f.get(mid).copied())
1861 {
1862 if field == "status" {
1863 status_sets.push((nested.to_string(), target.to_string()));
1864 }
1865 field_sets.insert(format!("{nested}.{field}"));
1866 }
1867 }
1868 }
1869 }
1870 }
1871 Expr::Block { items, .. } => {
1872 for item in items {
1873 collect_rule_effects(
1874 item, binding_types, status_by_entity, field_types, status_sets, field_sets,
1875 );
1876 }
1877 }
1878 Expr::Conditional {
1879 branches,
1880 else_body,
1881 ..
1882 } => {
1883 for branch in branches {
1884 collect_rule_effects(
1885 &branch.body, binding_types, status_by_entity, field_types, status_sets,
1886 field_sets,
1887 );
1888 }
1889 if let Some(body) = else_body {
1890 collect_rule_effects(
1891 body, binding_types, status_by_entity, field_types, status_sets, field_sets,
1892 );
1893 }
1894 }
1895 _ => {}
1896 }
1897}
1898
1899struct UniquenessPattern<'a> {
1902 prohibited_status: &'a str,
1903 key_field: &'a str,
1904}
1905
1906fn extract_uniqueness_invariant<'a>(expr: &'a Expr) -> Option<UniquenessPattern<'a>> {
1908 let Expr::For { body, .. } = expr else {
1910 return None;
1911 };
1912 let Expr::For { body: inner_body, .. } = body.as_ref() else {
1913 return None;
1914 };
1915
1916 let Expr::LogicalOp {
1918 op: LogicalOp::Implies,
1919 left: premise,
1920 right: conclusion,
1921 ..
1922 } = inner_body.as_ref()
1923 else {
1924 return None;
1925 };
1926
1927 let Expr::Not { operand, .. } = conclusion.as_ref() else {
1929 return None;
1930 };
1931
1932 let prohibited = extract_prohibited_status(operand)?;
1934
1935 let key_field = extract_key_field(premise)?;
1937
1938 Some(UniquenessPattern {
1939 prohibited_status: prohibited,
1940 key_field,
1941 })
1942}
1943
1944fn extract_prohibited_status(expr: &Expr) -> Option<&str> {
1946 let Expr::LogicalOp {
1947 op: LogicalOp::And,
1948 left,
1949 right,
1950 ..
1951 } = expr
1952 else {
1953 return None;
1954 };
1955
1956 let l_status = extract_status_value(left)?;
1958 let r_status = extract_status_value(right)?;
1959
1960 if l_status == r_status {
1961 Some(l_status)
1962 } else {
1963 None
1964 }
1965}
1966
1967fn extract_status_value(expr: &Expr) -> Option<&str> {
1968 if let Expr::Comparison {
1969 left,
1970 op: ComparisonOp::Eq,
1971 right,
1972 ..
1973 } = expr
1974 {
1975 if let Some((_, "status")) = expr_as_member_access(left) {
1976 return expr_as_ident(right);
1977 }
1978 }
1979 None
1980}
1981
1982fn extract_key_field(expr: &Expr) -> Option<&str> {
1984 let Expr::LogicalOp {
1985 op: LogicalOp::And,
1986 left: _,
1987 right,
1988 ..
1989 } = expr
1990 else {
1991 return None;
1992 };
1993
1994 if let Expr::Comparison {
1996 left,
1997 op: ComparisonOp::Eq,
1998 right: _,
1999 ..
2000 } = right.as_ref()
2001 {
2002 if let Some((_, field)) = expr_as_member_access(left) {
2003 return Some(field);
2007 }
2008 }
2009 None
2010}
2011
2012#[derive(PartialEq)]
2013enum ConflictTriggerKind<'a> {
2014 Call(&'a str),
2015 Temporal,
2016 Unknown,
2017}
2018
2019fn classify_trigger(expr: &Expr) -> ConflictTriggerKind<'_> {
2020 match expr {
2021 Expr::Call { function, .. } => {
2022 if let Expr::Ident(id) = function.as_ref() {
2023 return ConflictTriggerKind::Call(&id.name);
2024 }
2025 ConflictTriggerKind::Unknown
2026 }
2027 Expr::Binding { value, .. } => classify_trigger(value),
2028 Expr::Comparison { .. }
2029 | Expr::Becomes { .. }
2030 | Expr::TransitionsTo { .. } => ConflictTriggerKind::Temporal,
2031 _ => ConflictTriggerKind::Unknown,
2032 }
2033}
2034
2035fn collect_requires_statuses_for_conflict(
2036 expr: &Expr,
2037 binding_types: &HashMap<&str, &str>,
2038 status_by_entity: &HashMap<&str, HashSet<&str>>,
2039 out: &mut HashMap<String, HashSet<String>>,
2040) {
2041 match expr {
2042 Expr::Comparison {
2043 left,
2044 op: ComparisonOp::Eq,
2045 right,
2046 ..
2047 } => {
2048 if let (Some((binding, "status")), Some(target)) =
2049 (expr_as_member_access(left), expr_as_ident(right))
2050 {
2051 let entity = resolve_binding_entity(
2052 binding,
2053 Some(target),
2054 binding_types,
2055 &status_by_entity
2056 .iter()
2057 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2058 .collect(),
2059 );
2060 if let Some(e) = entity {
2061 out.entry(e.to_string()).or_default().insert(target.to_string());
2062 }
2063 }
2064 }
2065 Expr::LogicalOp { left, right, .. } => {
2066 collect_requires_statuses_for_conflict(left, binding_types, status_by_entity, out);
2067 collect_requires_statuses_for_conflict(right, binding_types, status_by_entity, out);
2068 }
2069 Expr::Block { items, .. } => {
2070 for item in items {
2071 collect_requires_statuses_for_conflict(item, binding_types, status_by_entity, out);
2072 }
2073 }
2074 _ => {}
2075 }
2076}
2077
2078fn collect_ensures_statuses_for_conflict(
2079 expr: &Expr,
2080 binding_types: &HashMap<&str, &str>,
2081 status_by_entity: &HashMap<&str, HashSet<&str>>,
2082 out: &mut HashMap<String, String>,
2083) {
2084 match expr {
2085 Expr::Comparison {
2086 left,
2087 op: ComparisonOp::Eq,
2088 right,
2089 ..
2090 } => {
2091 if let (Some((binding, "status")), Some(target)) =
2092 (expr_as_member_access(left), expr_as_ident(right))
2093 {
2094 let entity = resolve_binding_entity(
2095 binding,
2096 Some(target),
2097 binding_types,
2098 &status_by_entity
2099 .iter()
2100 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
2101 .collect(),
2102 );
2103 if let Some(e) = entity {
2104 out.insert(e.to_string(), target.to_string());
2105 }
2106 }
2107 }
2108 Expr::Block { items, .. } => {
2109 for item in items {
2110 collect_ensures_statuses_for_conflict(item, binding_types, status_by_entity, out);
2111 }
2112 }
2113 Expr::Conditional {
2114 branches,
2115 else_body,
2116 ..
2117 } => {
2118 for branch in branches {
2119 collect_ensures_statuses_for_conflict(
2120 &branch.body, binding_types, status_by_entity, out,
2121 );
2122 }
2123 if let Some(body) = else_body {
2124 collect_ensures_statuses_for_conflict(body, binding_types, status_by_entity, out);
2125 }
2126 }
2127 _ => {}
2128 }
2129}
2130
2131fn status_values_for_binding<'a>(
2133 status_values: &'a HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
2134) -> HashMap<&'a str, (Vec<&'a Ident>, HashSet<&'a str>)> {
2135 status_values
2136 .iter()
2137 .map(|(k, (set, idents))| (*k, (idents.clone(), set.clone())))
2138 .collect()
2139}
2140
2141fn resolve_binding_entity_from_status<'a>(
2144 binding: &str,
2145 target: Option<&str>,
2146 binding_types: &HashMap<&'a str, &'a str>,
2147 status_values: &HashMap<&'a str, (HashSet<&'a str>, Vec<&Ident>)>,
2148) -> Option<&'a str> {
2149 binding_types
2150 .get(binding)
2151 .copied()
2152 .or_else(|| {
2153 status_values
2154 .keys()
2155 .find(|name| name.eq_ignore_ascii_case(binding))
2156 .copied()
2157 })
2158 .or_else(|| {
2159 let target = target?;
2160 let mut candidates = status_values
2161 .iter()
2162 .filter(|(_, (values, _))| values.contains(target));
2163 let first = candidates.next()?;
2164 if candidates.next().is_none() {
2165 Some(first.0)
2166 } else {
2167 None
2168 }
2169 })
2170}
2171
2172fn collect_requires_conditions<'a>(
2174 expr: &'a Expr,
2175 binding_types: &HashMap<&'a str, &'a str>,
2176 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2177 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2178) {
2179 match expr {
2180 Expr::Comparison {
2181 left,
2182 op: ComparisonOp::Eq,
2183 right,
2184 ..
2185 } => {
2186 if let Some(target) = expr_as_ident(right) {
2187 if let Some((binding, field)) = expr_as_member_access(left) {
2188 cb(binding, field, target);
2189 } else if let Some((root, _mid, field)) =
2190 expr_as_nested_member_access(left)
2191 {
2192 if field == "status" {
2193 cb(root, "status", target);
2194 }
2195 }
2196 }
2197 if let Expr::BoolLiteral { value: true, .. } = right.as_ref() {
2199 if let Some((binding, field)) = expr_as_member_access(left) {
2200 cb(binding, field, "true");
2201 }
2202 }
2203 }
2204 Expr::Comparison {
2205 op: ComparisonOp::GtEq,
2206 ..
2207 } => {
2208 }
2210 Expr::LogicalOp { left, right, .. } => {
2211 collect_requires_conditions(left, binding_types, status_values, cb);
2212 collect_requires_conditions(right, binding_types, status_values, cb);
2213 }
2214 Expr::Block { items, .. } => {
2215 for item in items {
2216 collect_requires_conditions(item, binding_types, status_values, cb);
2217 }
2218 }
2219 _ => {}
2220 }
2221}
2222
2223fn collect_field_assignments<'a>(
2225 expr: &'a Expr,
2226 binding_types: &HashMap<&'a str, &'a str>,
2227 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2228 field_types: &HashMap<&str, HashMap<&str, &str>>,
2229 cb: &mut impl FnMut(&str, &str, &str),
2230) {
2231 match expr {
2232 Expr::Comparison {
2233 left,
2234 op: ComparisonOp::Eq,
2235 right,
2236 ..
2237 } => {
2238 if let Some((binding, field)) = expr_as_member_access(left) {
2239 let entity = resolve_binding_entity_from_status(
2240 binding, None, binding_types, status_values,
2241 );
2242 if let Some(entity) = entity {
2243 let val = expr_as_ident(right).unwrap_or("_variable_");
2244 cb(entity, field, val);
2245 }
2246 }
2247 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2249 let root_entity = resolve_binding_entity_from_status(
2250 root, None, binding_types, status_values,
2251 );
2252 if let Some(root_entity) = root_entity {
2253 if let Some(nested) =
2254 field_types.get(root_entity).and_then(|f| f.get(mid).copied())
2255 {
2256 let val = expr_as_ident(right).unwrap_or("_variable_");
2257 cb(nested, field, val);
2258 }
2259 }
2260 }
2261 }
2262 Expr::Block { items, .. } => {
2263 for item in items {
2264 collect_field_assignments(item, binding_types, status_values, field_types, cb);
2265 }
2266 }
2267 Expr::Conditional {
2268 branches,
2269 else_body,
2270 ..
2271 } => {
2272 for branch in branches {
2273 collect_field_assignments(
2274 &branch.body, binding_types, status_values, field_types, cb,
2275 );
2276 }
2277 if let Some(body) = else_body {
2278 collect_field_assignments(body, binding_types, status_values, field_types, cb);
2279 }
2280 }
2281 _ => {}
2282 }
2283}
2284
2285fn collect_ensures_status<'a>(
2287 expr: &'a Expr,
2288 binding_types: &HashMap<&'a str, &'a str>,
2289 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2290 field_types: &HashMap<&str, HashMap<&str, &str>>,
2291 cb: &mut impl FnMut(&'a str, &'a str),
2292) {
2293 match expr {
2294 Expr::Comparison {
2295 left,
2296 op: ComparisonOp::Eq,
2297 right,
2298 ..
2299 } => {
2300 if let Some(target) = expr_as_ident(right) {
2301 if let Some((binding, "status")) = expr_as_member_access(left) {
2304 cb(binding, target);
2305 }
2306 }
2307 }
2308 Expr::Block { items, .. } => {
2309 for item in items {
2310 collect_ensures_status(item, binding_types, status_values, field_types, cb);
2311 }
2312 }
2313 Expr::Conditional {
2314 branches,
2315 else_body,
2316 ..
2317 } => {
2318 for branch in branches {
2319 collect_ensures_status(
2320 &branch.body, binding_types, status_values, field_types, cb,
2321 );
2322 }
2323 if let Some(body) = else_body {
2324 collect_ensures_status(body, binding_types, status_values, field_types, cb);
2325 }
2326 }
2327 _ => {}
2328 }
2329}
2330
2331fn collect_created_field_assignments<'a>(
2333 expr: &'a Expr,
2334 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2335 assigned: &mut HashSet<String>,
2336) {
2337 match expr {
2338 Expr::Call { function, args, .. } => {
2339 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2340 if field.name == "created" {
2341 if let Expr::Ident(entity_id) = object.as_ref() {
2342 let entity = entity_id.name.as_str();
2343 if status_values.contains_key(entity) {
2344 for arg in args {
2345 if let CallArg::Named(named) = arg {
2346 assigned.insert(format!(
2347 "{entity}.{}", named.name.name
2348 ));
2349 if named.name.name == "status" {
2351 if let Expr::Ident(val) = &named.value {
2352 assigned.insert(format!(
2353 "{entity}.status.{}", val.name
2354 ));
2355 }
2356 }
2357 }
2358 }
2359 }
2360 }
2361 }
2362 }
2363 }
2364 Expr::Block { items, .. } => {
2365 for item in items {
2366 collect_created_field_assignments(item, status_values, assigned);
2367 }
2368 }
2369 Expr::Conditional {
2370 branches,
2371 else_body,
2372 ..
2373 } => {
2374 for branch in branches {
2375 collect_created_field_assignments(&branch.body, status_values, assigned);
2376 }
2377 if let Some(body) = else_body {
2378 collect_created_field_assignments(body, status_values, assigned);
2379 }
2380 }
2381 _ => {}
2382 }
2383}
2384
2385fn collect_rule_binding_types<'a>(
2386 rule: &'a BlockDecl,
2387 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2388) -> HashMap<&'a str, &'a str> {
2389 let mut types = HashMap::new();
2390 for item in &rule.items {
2391 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2392 continue;
2393 };
2394 if keyword != "when" {
2395 continue;
2396 }
2397 collect_binding_types_from_expr(value, status_by_entity, &mut types);
2398 }
2399 types
2400}
2401
2402fn collect_binding_types_from_expr<'a>(
2403 expr: &'a Expr,
2404 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2405 out: &mut HashMap<&'a str, &'a str>,
2406) {
2407 match expr {
2408 Expr::Binding { name, value, .. } => {
2409 if let Some(entity_name) = extract_entity_from_trigger(value) {
2410 if status_by_entity.contains_key(entity_name) {
2411 out.insert(&name.name, entity_name);
2412 }
2413 }
2414 }
2415 Expr::Call { function, args, .. } => {
2416 if let Expr::Ident(fn_name) = function.as_ref() {
2417 for arg in args {
2418 if let CallArg::Positional(Expr::Ident(binding)) = arg {
2419 if status_by_entity.contains_key(fn_name.name.as_str()) {
2420 out.insert(&binding.name, &fn_name.name);
2421 }
2422 }
2423 }
2424 }
2425 }
2426 Expr::LogicalOp { left, right, .. } => {
2427 collect_binding_types_from_expr(left, status_by_entity, out);
2428 collect_binding_types_from_expr(right, status_by_entity, out);
2429 }
2430 _ => {}
2431 }
2432}
2433
2434fn collect_command_param_types<'a, V>(
2438 expr: &'a Expr,
2439 status_by_entity: &HashMap<&str, V>,
2440 out: &mut HashMap<&'a str, Vec<Option<&'a str>>>,
2441) {
2442 match expr {
2443 Expr::Call { function, args, .. } => {
2444 if let Expr::Ident(fn_name) = function.as_ref() {
2445 let params: Vec<Option<&str>> = args
2446 .iter()
2447 .map(|arg| match arg {
2448 CallArg::Named(named) => match &named.value {
2449 Expr::Ident(val)
2450 if status_by_entity.contains_key(val.name.as_str()) =>
2451 {
2452 Some(val.name.as_str())
2453 }
2454 _ => None,
2455 },
2456 CallArg::Positional(_) => None,
2457 })
2458 .collect();
2459 if params.iter().any(Option::is_some) {
2460 out.insert(&fn_name.name, params);
2461 }
2462 }
2463 }
2464 Expr::WhenGuard { action, .. } => {
2465 collect_command_param_types(action, status_by_entity, out);
2466 }
2467 Expr::Block { items, .. } => {
2468 for item in items {
2469 collect_command_param_types(item, status_by_entity, out);
2470 }
2471 }
2472 Expr::Conditional {
2473 branches,
2474 else_body,
2475 ..
2476 } => {
2477 for branch in branches {
2478 collect_command_param_types(&branch.body, status_by_entity, out);
2479 }
2480 if let Some(body) = else_body {
2481 collect_command_param_types(body, status_by_entity, out);
2482 }
2483 }
2484 _ => {}
2485 }
2486}
2487
2488fn augment_binding_types_from_commands<'a>(
2492 rule: &'a BlockDecl,
2493 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2494 out: &mut HashMap<&'a str, &'a str>,
2495) {
2496 for item in &rule.items {
2497 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2498 continue;
2499 };
2500 if keyword != "when" {
2501 continue;
2502 }
2503 augment_binding_types_from_call(value, command_param_types, out);
2504 }
2505}
2506
2507fn augment_binding_types_from_call<'a>(
2508 expr: &'a Expr,
2509 command_param_types: &HashMap<&str, Vec<Option<&'a str>>>,
2510 out: &mut HashMap<&'a str, &'a str>,
2511) {
2512 match expr {
2513 Expr::Call { function, args, .. } => {
2514 if let Expr::Ident(fn_name) = function.as_ref() {
2515 if let Some(params) = command_param_types.get(fn_name.name.as_str()) {
2516 for (arg, param_type) in args.iter().zip(params) {
2517 if let (CallArg::Positional(Expr::Ident(binding)), Some(entity)) =
2518 (arg, param_type)
2519 {
2520 out.entry(&binding.name).or_insert(entity);
2521 }
2522 }
2523 }
2524 }
2525 }
2526 Expr::LogicalOp { left, right, .. } => {
2527 augment_binding_types_from_call(left, command_param_types, out);
2528 augment_binding_types_from_call(right, command_param_types, out);
2529 }
2530 _ => {}
2531 }
2532}
2533
2534fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
2535 match expr {
2536 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
2537 extract_entity_from_member(subject)
2538 }
2539 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2540 _ => None,
2541 }
2542}
2543
2544fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
2545 match expr {
2546 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2547 _ => None,
2548 }
2549}
2550
2551fn visit_status_assignments<'a>(
2552 expr: &'a Expr,
2553 binding_types: &HashMap<&'a str, &'a str>,
2554 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2555 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2556 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2557) {
2558 match expr {
2559 Expr::Comparison {
2560 left,
2561 op: ComparisonOp::Eq,
2562 right,
2563 ..
2564 } => {
2565 if let Some(target) = expr_as_ident(right) {
2566 if let Some((binding, "status")) = expr_as_member_access(left) {
2568 let entity = resolve_binding_entity(
2569 binding,
2570 Some(target),
2571 binding_types,
2572 status_by_entity,
2573 );
2574 if let Some(entity) = entity {
2575 cb(binding, target, entity);
2576 }
2577 }
2578 else if let Some((root, field, "status")) =
2582 expr_as_nested_member_access(left)
2583 {
2584 let root_entity = resolve_binding_entity(
2585 root, None, binding_types, status_by_entity,
2586 );
2587 if let Some(root_entity) = root_entity {
2588 if let Some(nested_entity) = field_entity_types
2589 .get(root_entity)
2590 .and_then(|fields| fields.get(field).copied())
2591 {
2592 cb("_nested_", target, nested_entity);
2595 }
2596 }
2597 }
2598 }
2599 }
2600 Expr::Block { items, .. } => {
2601 for item in items {
2602 visit_status_assignments(
2603 item,
2604 binding_types,
2605 status_by_entity,
2606 field_entity_types,
2607 cb,
2608 );
2609 }
2610 }
2611 Expr::Conditional {
2612 branches,
2613 else_body,
2614 ..
2615 } => {
2616 for branch in branches {
2617 visit_status_assignments(
2618 &branch.body,
2619 binding_types,
2620 status_by_entity,
2621 field_entity_types,
2622 cb,
2623 );
2624 }
2625 if let Some(body) = else_body {
2626 visit_status_assignments(
2627 body,
2628 binding_types,
2629 status_by_entity,
2630 field_entity_types,
2631 cb,
2632 );
2633 }
2634 }
2635 _ => {}
2636 }
2637}
2638
2639fn visit_created_calls<'a>(
2643 expr: &'a Expr,
2644 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2645 has_transitions: &HashSet<&'a str>,
2646 on_status: &mut impl FnMut(&'a str, &'a str),
2647 issues: &mut Vec<Diagnostic>,
2648) {
2649 match expr {
2650 Expr::Call {
2651 function, args, span, ..
2652 } => {
2653 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2654 if field.name == "created" {
2655 if let Expr::Ident(entity_ident) = object.as_ref() {
2656 let entity_name = entity_ident.name.as_str();
2657 if let Some((_, values)) = status_by_entity.get(entity_name) {
2658 let status_arg = args.iter().find_map(|arg| {
2659 if let CallArg::Named(named) = arg {
2660 if named.name.name == "status" {
2661 return Some(named);
2662 }
2663 }
2664 None
2665 });
2666
2667 match status_arg {
2668 Some(named) => {
2669 if let Expr::Ident(status_ident) = &named.value {
2670 let status = status_ident.name.as_str();
2671 if values.contains(status) {
2672 on_status(entity_name, status);
2673 } else {
2674 issues.push(
2675 Diagnostic::error(
2676 named.value.span(),
2677 format!(
2678 ".created() on entity '{entity_name}' sets status to '{status}', which is not a declared status value.",
2679 ),
2680 )
2681 .with_code("allium.created.invalidStatus"),
2682 );
2683 }
2684 }
2685 }
2686 None => {
2687 if has_transitions.contains(entity_name) {
2688 issues.push(
2689 Diagnostic::warning(
2690 *span,
2691 format!(
2692 ".created() on entity '{entity_name}' omits the status field, but the entity has a transition graph. The initial state is unspecified.",
2693 ),
2694 )
2695 .with_code("allium.created.missingStatus"),
2696 );
2697 }
2698 }
2699 }
2700 }
2701 }
2702 }
2703 }
2704 }
2705 Expr::Block { items, .. } => {
2706 for item in items {
2707 visit_created_calls(item, status_by_entity, has_transitions, on_status, issues);
2708 }
2709 }
2710 Expr::Conditional {
2711 branches,
2712 else_body,
2713 ..
2714 } => {
2715 for branch in branches {
2716 visit_created_calls(
2717 &branch.body,
2718 status_by_entity,
2719 has_transitions,
2720 on_status,
2721 issues,
2722 );
2723 }
2724 if let Some(body) = else_body {
2725 visit_created_calls(body, status_by_entity, has_transitions, on_status, issues);
2726 }
2727 }
2728 _ => {}
2729 }
2730}
2731
2732fn visit_status_comparisons<'a>(
2733 expr: &'a Expr,
2734 binding_types: &HashMap<&'a str, &'a str>,
2735 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2736 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2737 cb: &mut impl FnMut(&'a str, &'a str),
2738) {
2739 match expr {
2740 Expr::Comparison {
2741 left,
2742 op: ComparisonOp::Eq,
2743 right,
2744 ..
2745 } => {
2746 if let Some(target) = expr_as_ident(right) {
2747 if let Some((binding, "status")) = expr_as_member_access(left) {
2749 let known = resolve_binding_entity(
2750 binding,
2751 Some(target),
2752 binding_types,
2753 status_by_entity,
2754 )
2755 .is_some();
2756 if known {
2757 cb(binding, target);
2758 }
2759 }
2760 }
2765 }
2766 Expr::Comparison {
2767 left,
2768 op: ComparisonOp::NotEq,
2769 right,
2770 ..
2771 } => {
2772 if let Some(target) = expr_as_ident(right) {
2775 if let Some((binding, "status")) = expr_as_member_access(left) {
2776 if let Some(entity) = resolve_binding_entity(
2777 binding,
2778 Some(target),
2779 binding_types,
2780 status_by_entity,
2781 ) {
2782 if let Some((_, values)) = status_by_entity.get(entity) {
2783 if values.contains(target) {
2784 for value in values.iter().filter(|v| **v != target) {
2785 cb(binding, value);
2786 }
2787 }
2788 }
2789 }
2790 }
2791 }
2792 }
2793 Expr::LogicalOp { left, right, .. } => {
2794 visit_status_comparisons(left, binding_types, status_by_entity, field_entity_types, cb);
2795 visit_status_comparisons(right, binding_types, status_by_entity, field_entity_types, cb);
2796 }
2797 Expr::Block { items, .. } => {
2798 for item in items {
2799 visit_status_comparisons(item, binding_types, status_by_entity, field_entity_types, cb);
2800 }
2801 }
2802 _ => {}
2803 }
2804}
2805
2806fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
2807 match expr {
2808 Expr::MemberAccess { object, field, .. } => {
2809 expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
2810 }
2811 _ => None,
2812 }
2813}
2814
2815fn expr_as_nested_member_access(expr: &Expr) -> Option<(&str, &str, &str)> {
2817 if let Expr::MemberAccess {
2818 object, field: last, ..
2819 } = expr
2820 {
2821 if let Expr::MemberAccess {
2822 object: root_obj,
2823 field: mid,
2824 ..
2825 } = object.as_ref()
2826 {
2827 if let Expr::Ident(root) = root_obj.as_ref() {
2828 return Some((&root.name, &mid.name, &last.name));
2829 }
2830 }
2831 }
2832 None
2833}
2834
2835fn resolve_binding_entity<'a>(
2840 binding: &str,
2841 target: Option<&str>,
2842 binding_types: &HashMap<&'a str, &'a str>,
2843 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2844) -> Option<&'a str> {
2845 binding_types
2846 .get(binding)
2847 .copied()
2848 .or_else(|| {
2849 status_by_entity
2850 .keys()
2851 .find(|name| name.eq_ignore_ascii_case(binding))
2852 .copied()
2853 })
2854 .or_else(|| {
2855 let target = target?;
2857 let mut candidates = status_by_entity
2858 .iter()
2859 .filter(|(_, (_, values))| values.contains(target));
2860 let first = candidates.next()?;
2861 if candidates.next().is_none() {
2862 Some(first.0)
2863 } else {
2864 None
2865 }
2866 })
2867}
2868
2869fn extract_field_entity_type(expr: &Expr) -> Option<&str> {
2872 match expr {
2873 Expr::Ident(id) if starts_uppercase(&id.name) => Some(&id.name),
2874 Expr::JoinLookup { entity, .. } => {
2875 if let Expr::Ident(id) = entity.as_ref() {
2876 if starts_uppercase(&id.name) {
2877 return Some(&id.name);
2878 }
2879 }
2880 None
2881 }
2882 _ => None,
2883 }
2884}
2885
2886fn is_likely_terminal(status: &str) -> bool {
2887 matches!(
2888 status,
2889 "completed"
2890 | "cancelled"
2891 | "canceled"
2892 | "expired"
2893 | "closed"
2894 | "deleted"
2895 | "archived"
2896 | "failed"
2897 | "rejected"
2898 | "done"
2899 )
2900}
2901
2902impl Ctx<'_> {
2907 fn check_external_entity_source_hints(&mut self) {
2908 if self.has_use_imports() {
2909 return;
2910 }
2911
2912 let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
2913
2914 for entity in self.blocks(BlockKind::ExternalEntity) {
2915 let name = match &entity.name {
2916 Some(n) => n,
2917 None => continue,
2918 };
2919
2920 let referenced_in_rules = rule_blocks
2921 .iter()
2922 .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
2923
2924 let msg = format!(
2925 "External entity '{}' has no obvious governing specification import in this module.",
2926 name.name
2927 );
2928 if referenced_in_rules {
2929 self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
2930 } else {
2931 self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
2932 }
2933 }
2934 }
2935}
2936
2937impl Ctx<'_> {
2942 fn check_unresolved_use_paths(&mut self) {
2946 let Some(resolved) = self.resolved_use_paths else {
2947 return;
2948 };
2949 for d in &self.module.declarations {
2950 let Decl::Use(u) = d else { continue };
2951 let path_text = u.path.text();
2952 if !resolved.contains(&path_text) {
2953 self.push(
2954 Diagnostic::warning(
2955 u.path.span,
2956 format!(
2957 "Use path \"{path_text}\" does not resolve to a file in the current check set.",
2958 ),
2959 )
2960 .with_code("allium.use.unresolvedPath"),
2961 );
2962 }
2963 }
2964 }
2965}
2966
2967impl Ctx<'_> {
2972 fn check_ambiguous_imported_names(&mut self) {
2979 let Some(ambiguous) = self.ambiguous_imports else {
2980 return;
2981 };
2982 if ambiguous.names.is_empty() {
2983 return;
2984 }
2985 let mut local = self.declared_type_names();
2990 for b in self.blocks(BlockKind::Contract) {
2991 if let Some(n) = &b.name {
2992 local.insert(n.name.as_str());
2993 }
2994 }
2995
2996 let mut flagged: HashSet<&str> = HashSet::new();
2997 let mut findings = Vec::new();
2998 for id in collect_referenced_ident_nodes(self.module) {
2999 if id.qualified || local.contains(id.name) || flagged.contains(id.name) {
3000 continue;
3001 }
3002 let Some(aliases) = ambiguous.names.get(id.name) else {
3003 continue;
3004 };
3005 flagged.insert(id.name);
3007 findings.push(
3008 Diagnostic::warning(
3009 id.span,
3010 format!(
3011 "Unqualified reference '{}' is ambiguous: it is declared in imported modules {}. Use a qualified name (e.g. '{}/{}').",
3012 id.name,
3013 format_alias_list(aliases),
3014 aliases[0],
3015 id.name,
3016 ),
3017 )
3018 .with_code("allium.use.ambiguousReference"),
3019 );
3020 }
3021 self.diagnostics.extend(findings);
3022 }
3023}
3024
3025fn format_alias_list(aliases: &[String]) -> String {
3027 let quoted: Vec<String> = aliases.iter().map(|a| format!("'{a}'")).collect();
3028 match quoted.split_last() {
3029 Some((last, rest)) if !rest.is_empty() => {
3030 format!("{} and {last}", rest.join(", "))
3031 }
3032 _ => quoted.join(", "),
3033 }
3034}
3035
3036impl Ctx<'_> {
3041 fn check_type_references(&mut self) {
3042 let known = self.declared_type_names();
3043
3044 for d in &self.module.declarations {
3045 let block = match d {
3046 Decl::Block(b)
3047 if matches!(
3048 b.kind,
3049 BlockKind::Entity
3050 | BlockKind::ExternalEntity
3051 | BlockKind::Value
3052 ) =>
3053 {
3054 b
3055 }
3056 Decl::Variant(v) => {
3057 for item in &v.items {
3059 self.check_type_ref_in_item(item, &known);
3060 }
3061 continue;
3062 }
3063 _ => continue,
3064 };
3065
3066 for item in &block.items {
3067 self.check_type_ref_in_item(item, &known);
3068 }
3069 }
3070
3071 for rule in self.blocks(BlockKind::Rule) {
3073 for item in &rule.items {
3074 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3075 continue;
3076 };
3077 if keyword == "when" || keyword == "ensures" || keyword == "requires" {
3078 self.check_type_refs_in_rule_expr(value, &known);
3079 }
3080 }
3081 }
3082 }
3083
3084 fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
3085 match &item.kind {
3086 BlockItemKind::Assignment { value, .. }
3087 | BlockItemKind::FieldWithWhen { value, .. } => {
3088 self.check_type_refs_in_value(value, known);
3089 }
3090 _ => {}
3091 }
3092 }
3093
3094 fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
3095 match expr {
3096 Expr::Ident(id) if starts_uppercase(&id.name) => {
3097 if !known.contains(id.name.as_str()) {
3098 self.push(
3099 Diagnostic::error(
3100 id.span,
3101 format!(
3102 "Type reference '{}' is not declared locally or imported.",
3103 id.name
3104 ),
3105 )
3106 .with_code("allium.type.undefinedReference"),
3107 );
3108 }
3109 }
3110 Expr::GenericType { name, args, .. } => {
3111 self.check_type_refs_in_value(name, known);
3112 for arg in args {
3113 self.check_type_refs_in_value(arg, known);
3114 }
3115 }
3116 Expr::Pipe { left, right, .. } => {
3117 self.check_type_refs_in_value(left, known);
3118 self.check_type_refs_in_value(right, known);
3119 }
3120 Expr::TypeOptional { inner, .. } => {
3121 self.check_type_refs_in_value(inner, known);
3122 }
3123 _ => {}
3124 }
3125 }
3126
3127 fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
3128 match expr {
3129 Expr::Binding { value, .. } => {
3131 self.check_type_refs_in_rule_expr(value, known);
3132 }
3133 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
3134 if let Expr::MemberAccess { object, .. } = subject.as_ref() {
3135 if let Expr::Ident(id) = object.as_ref() {
3136 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3137 self.push(
3138 Diagnostic::error(
3139 id.span,
3140 format!(
3141 "Type reference '{}' is not declared locally or imported.",
3142 id.name
3143 ),
3144 )
3145 .with_code("allium.rule.undefinedTypeReference"),
3146 );
3147 }
3148 }
3149 }
3150 }
3151 Expr::Call { function, .. } => {
3153 if let Expr::MemberAccess { object, .. } = function.as_ref() {
3154 if let Expr::Ident(id) = object.as_ref() {
3155 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3156 self.push(
3157 Diagnostic::error(
3158 id.span,
3159 format!(
3160 "Type reference '{}' is not declared locally or imported.",
3161 id.name
3162 ),
3163 )
3164 .with_code("allium.rule.undefinedTypeReference"),
3165 );
3166 }
3167 }
3168 }
3169 }
3170 Expr::MemberAccess { object, .. } => {
3172 if let Expr::Ident(id) = object.as_ref() {
3173 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
3174 self.push(
3175 Diagnostic::error(
3176 id.span,
3177 format!(
3178 "Type reference '{}' is not declared locally or imported.",
3179 id.name
3180 ),
3181 )
3182 .with_code("allium.rule.undefinedTypeReference"),
3183 );
3184 }
3185 }
3186 }
3187 Expr::Block { items, .. } => {
3188 for item in items {
3189 self.check_type_refs_in_rule_expr(item, known);
3190 }
3191 }
3192 Expr::LogicalOp { left, right, .. } => {
3193 self.check_type_refs_in_rule_expr(left, known);
3194 self.check_type_refs_in_rule_expr(right, known);
3195 }
3196 _ => {}
3197 }
3198 }
3199}
3200
3201impl Ctx<'_> {
3206 fn check_unreachable_triggers(&mut self) {
3207 let mut provided: HashSet<&str> = HashSet::new();
3209 for surface in self.blocks(BlockKind::Surface) {
3210 for item in &surface.items {
3211 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3212 continue;
3213 };
3214 if keyword != "provides" {
3215 continue;
3216 }
3217 collect_call_names(value, &mut provided);
3218 }
3219 }
3220
3221 let mut emitted: HashSet<&str> = HashSet::new();
3225 for rule in self.blocks(BlockKind::Rule) {
3226 for item in &rule.items {
3227 collect_emitted_trigger_from_item(&item.kind, &mut emitted);
3228 }
3229 }
3230
3231 for rule in self.blocks(BlockKind::Rule) {
3232 let rule_name = match &rule.name {
3233 Some(n) => &n.name,
3234 None => continue,
3235 };
3236 for item in &rule.items {
3237 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3238 continue;
3239 };
3240 if keyword != "when" {
3241 continue;
3242 }
3243 for tref in extract_trigger_refs(value) {
3244 if tref.qualifier.is_none()
3248 && !provided.contains(tref.name)
3249 && !emitted.contains(tref.name)
3250 {
3251 if let Some(aliases) = self
3252 .ambiguous_imports
3253 .and_then(|a| a.triggers.get(tref.name))
3254 {
3255 let message = format!(
3256 "Rule '{rule_name}' listens for trigger '{}', which is provided or emitted by imported modules {}. Use a qualified name (e.g. '{}/{}').",
3257 tref.name,
3258 format_alias_list(aliases),
3259 aliases[0],
3260 tref.name,
3261 );
3262 self.push(
3263 Diagnostic::warning(tref.span, message)
3264 .with_code("allium.use.ambiguousReference"),
3265 );
3266 }
3267 }
3268 if self.trigger_reachability(&tref, &provided, &emitted) != Some(false) {
3269 continue;
3270 }
3271 let message = match tref.qualifier {
3272 None => format!(
3273 "Rule '{rule_name}' listens for trigger '{}' but no local surface provides or rule emits it.",
3274 tref.name,
3275 ),
3276 Some(q) => format!(
3277 "Rule '{rule_name}' listens for trigger '{q}/{}' but imported module '{q}' does not provide or emit it.",
3278 tref.name,
3279 ),
3280 };
3281 self.push(
3282 Diagnostic::info(tref.span, message)
3283 .with_code("allium.rule.unreachableTrigger"),
3284 );
3285 }
3286 }
3287 }
3288 }
3289
3290 fn trigger_reachability(
3297 &self,
3298 tref: &TriggerRef<'_>,
3299 provided: &HashSet<&str>,
3300 emitted: &HashSet<&str>,
3301 ) -> Option<bool> {
3302 match tref.qualifier {
3303 None => {
3304 if provided.contains(tref.name) || emitted.contains(tref.name) {
3305 return Some(true);
3306 }
3307 if let Some(imports) = self.imported_triggers {
3308 if imports.values().any(|set| set.contains(tref.name)) {
3309 return Some(true);
3310 }
3311 }
3312 Some(false)
3313 }
3314 Some(q) => self
3315 .imported_triggers?
3316 .get(q)
3317 .map(|set| set.contains(tref.name)),
3318 }
3319 }
3320}
3321
3322fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3325 match kind {
3326 BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
3327 collect_leading_ensures_call(value, out);
3328 }
3329 BlockItemKind::ForBlock { items, .. } => {
3330 for item in items {
3331 collect_emitted_trigger_from_item(&item.kind, out);
3332 }
3333 }
3334 BlockItemKind::IfBlock { branches, else_items, .. } => {
3335 for b in branches {
3336 for item in &b.items {
3337 collect_emitted_trigger_from_item(&item.kind, out);
3338 }
3339 }
3340 if let Some(items) = else_items {
3341 for item in items {
3342 collect_emitted_trigger_from_item(&item.kind, out);
3343 }
3344 }
3345 }
3346 _ => {}
3347 }
3348}
3349
3350fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3357 match expr {
3358 Expr::Call { function, .. } => {
3359 if let Expr::Ident(id) = function.as_ref() {
3360 if starts_uppercase(&id.name) {
3361 out.insert(&id.name);
3362 }
3363 }
3364 }
3365 Expr::Block { items, .. } => {
3366 if let Some(first) = items.first() {
3367 collect_leading_ensures_call(first, out);
3368 }
3369 }
3370 Expr::Conditional {
3371 branches,
3372 else_body,
3373 ..
3374 } => {
3375 for b in branches {
3376 collect_leading_ensures_call(&b.body, out);
3377 }
3378 if let Some(body) = else_body {
3379 collect_leading_ensures_call(body, out);
3380 }
3381 }
3382 Expr::For { body, .. } => {
3383 collect_leading_ensures_call(body, out);
3384 }
3385 _ => {}
3386 }
3387}
3388
3389fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3390 match expr {
3391 Expr::Call { function, .. } => {
3392 if let Expr::Ident(id) = function.as_ref() {
3393 if starts_uppercase(&id.name) {
3394 out.insert(&id.name);
3395 }
3396 }
3397 }
3398 Expr::Block { items, .. } => {
3399 for item in items {
3400 collect_call_names(item, out);
3401 }
3402 }
3403 Expr::WhenGuard { action, .. } => {
3404 collect_call_names(action, out);
3405 }
3406 Expr::Conditional { branches, else_body, .. } => {
3407 for b in branches {
3408 collect_call_names(&b.body, out);
3409 }
3410 if let Some(body) = else_body {
3411 collect_call_names(body, out);
3412 }
3413 }
3414 _ => {}
3415 }
3416}
3417
3418struct TriggerRef<'a> {
3421 qualifier: Option<&'a str>,
3422 name: &'a str,
3423 span: Span,
3424}
3425
3426impl TriggerRef<'_> {
3427 fn display(&self) -> String {
3429 match self.qualifier {
3430 Some(q) => format!("{q}/{}", self.name),
3431 None => self.name.to_string(),
3432 }
3433 }
3434}
3435
3436fn extract_trigger_refs(expr: &Expr) -> Vec<TriggerRef<'_>> {
3437 match expr {
3438 Expr::Call { function, .. } => match function.as_ref() {
3439 Expr::Ident(id) if starts_uppercase(&id.name) => vec![TriggerRef {
3440 qualifier: None,
3441 name: &id.name,
3442 span: id.span,
3443 }],
3444 Expr::QualifiedName(q) if starts_uppercase(&q.name) => vec![TriggerRef {
3445 qualifier: q.qualifier.as_deref(),
3446 name: &q.name,
3447 span: q.span,
3448 }],
3449 _ => vec![],
3450 },
3451 Expr::Binding { .. } => {
3452 vec![]
3454 }
3455 Expr::LogicalOp { left, right, .. } => {
3456 let mut out = extract_trigger_refs(left);
3457 out.extend(extract_trigger_refs(right));
3458 out
3459 }
3460 _ => vec![],
3461 }
3462}
3463
3464impl Ctx<'_> {
3469 fn check_unused_fields(&mut self) {
3470 let accessed = self.collect_all_accessed_field_names();
3471
3472 for d in &self.module.declarations {
3473 let block = match d {
3474 Decl::Block(b)
3475 if matches!(
3476 b.kind,
3477 BlockKind::Entity | BlockKind::ExternalEntity
3478 ) =>
3479 {
3480 b
3481 }
3482 Decl::Variant(v) => {
3483 let entity_name = &v.name.name;
3484 for item in &v.items {
3485 if let BlockItemKind::Assignment { name, .. }
3486 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3487 {
3488 if !accessed.contains(name.name.as_str()) {
3489 self.push(
3490 Diagnostic::info(
3491 name.span,
3492 format!(
3493 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3494 name.name
3495 ),
3496 )
3497 .with_code("allium.field.unused"),
3498 );
3499 }
3500 }
3501 }
3502 continue;
3503 }
3504 _ => continue,
3505 };
3506
3507 let entity_name = match &block.name {
3508 Some(n) => &n.name,
3509 None => continue,
3510 };
3511
3512 for item in &block.items {
3513 if let BlockItemKind::Assignment { name, .. }
3514 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3515 {
3516 if !accessed.contains(name.name.as_str()) {
3517 self.push(
3518 Diagnostic::info(
3519 name.span,
3520 format!(
3521 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3522 name.name
3523 ),
3524 )
3525 .with_code("allium.field.unused"),
3526 );
3527 }
3528 }
3529 }
3530 }
3531 }
3532}
3533
3534fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3535 match kind {
3536 BlockItemKind::Clause { value, .. }
3537 | BlockItemKind::Assignment { value, .. }
3538 | BlockItemKind::ParamAssignment { value, .. }
3539 | BlockItemKind::Let { value, .. }
3540 | BlockItemKind::PathAssignment { value, .. }
3541 | BlockItemKind::InvariantBlock { body: value, .. }
3542 | BlockItemKind::FieldWithWhen { value, .. } => {
3543 collect_accessed_fields_from_expr(value, out);
3544 }
3545 BlockItemKind::ForBlock {
3546 collection,
3547 filter,
3548 items,
3549 ..
3550 } => {
3551 collect_accessed_fields_from_expr(collection, out);
3552 if let Some(f) = filter {
3553 collect_accessed_fields_from_expr(f, out);
3554 }
3555 for item in items {
3556 collect_accessed_fields_from_item(&item.kind, out);
3557 }
3558 }
3559 BlockItemKind::IfBlock {
3560 branches,
3561 else_items,
3562 } => {
3563 for b in branches {
3564 collect_accessed_fields_from_expr(&b.condition, out);
3565 for item in &b.items {
3566 collect_accessed_fields_from_item(&item.kind, out);
3567 }
3568 }
3569 if let Some(items) = else_items {
3570 for item in items {
3571 collect_accessed_fields_from_item(&item.kind, out);
3572 }
3573 }
3574 }
3575 _ => {}
3576 }
3577}
3578
3579fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3580 match expr {
3581 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
3582 out.insert(&field.name);
3583 collect_accessed_fields_from_expr(object, out);
3584 }
3585 Expr::Call { function, args, .. } => {
3586 collect_accessed_fields_from_expr(function, out);
3587 for a in args {
3588 match a {
3589 CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
3590 CallArg::Named(n) => collect_accessed_fields_from_expr(&n.value, out),
3591 }
3592 }
3593 }
3594 Expr::BinaryOp { left, right, .. }
3595 | Expr::Comparison { left, right, .. }
3596 | Expr::LogicalOp { left, right, .. }
3597 | Expr::Pipe { left, right, .. }
3598 | Expr::NullCoalesce { left, right, .. } => {
3599 collect_accessed_fields_from_expr(left, out);
3600 collect_accessed_fields_from_expr(right, out);
3601 }
3602 Expr::Not { operand, .. }
3603 | Expr::Exists { operand, .. }
3604 | Expr::NotExists { operand, .. }
3605 | Expr::TypeOptional { inner: operand, .. } => {
3606 collect_accessed_fields_from_expr(operand, out);
3607 }
3608 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3609 collect_accessed_fields_from_expr(element, out);
3610 collect_accessed_fields_from_expr(collection, out);
3611 }
3612 Expr::Where { source, condition, .. }
3613 | Expr::With {
3614 source,
3615 predicate: condition,
3616 ..
3617 } => {
3618 collect_accessed_fields_from_expr(source, out);
3619 collect_accessed_fields_from_expr(condition, out);
3620 }
3621 Expr::WhenGuard { action, condition, .. } => {
3622 collect_accessed_fields_from_expr(action, out);
3623 collect_accessed_fields_from_expr(condition, out);
3624 }
3625 Expr::Block { items, .. } => {
3626 for item in items {
3627 collect_accessed_fields_from_expr(item, out);
3628 }
3629 }
3630 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3631 collect_accessed_fields_from_expr(value, out);
3632 }
3633 Expr::Conditional { branches, else_body, .. } => {
3634 for b in branches {
3635 collect_accessed_fields_from_expr(&b.condition, out);
3636 collect_accessed_fields_from_expr(&b.body, out);
3637 }
3638 if let Some(body) = else_body {
3639 collect_accessed_fields_from_expr(body, out);
3640 }
3641 }
3642 Expr::For { collection, filter, body, .. } => {
3643 collect_accessed_fields_from_expr(collection, out);
3644 if let Some(f) = filter {
3645 collect_accessed_fields_from_expr(f, out);
3646 }
3647 collect_accessed_fields_from_expr(body, out);
3648 }
3649 Expr::Lambda { body, .. } => {
3650 collect_accessed_fields_from_expr(body, out);
3651 }
3652 Expr::JoinLookup { entity, fields, .. } => {
3653 collect_accessed_fields_from_expr(entity, out);
3654 for f in fields {
3655 out.insert(&f.field.name);
3656 if let Some(v) = &f.value {
3657 collect_accessed_fields_from_expr(v, out);
3658 }
3659 }
3660 }
3661 Expr::TransitionsTo { subject, new_state, .. }
3662 | Expr::Becomes { subject, new_state, .. } => {
3663 collect_accessed_fields_from_expr(subject, out);
3664 collect_accessed_fields_from_expr(new_state, out);
3665 }
3666 Expr::SetLiteral { elements, .. } => {
3667 for e in elements {
3668 collect_accessed_fields_from_expr(e, out);
3669 }
3670 }
3671 Expr::ObjectLiteral { fields, .. } => {
3672 for f in fields {
3673 collect_accessed_fields_from_expr(&f.value, out);
3674 }
3675 }
3676 Expr::GenericType { name, args, .. } => {
3677 collect_accessed_fields_from_expr(name, out);
3678 for a in args {
3679 collect_accessed_fields_from_expr(a, out);
3680 }
3681 }
3682 Expr::ProjectionMap { source, .. } => {
3683 collect_accessed_fields_from_expr(source, out);
3684 }
3685 _ => {}
3686 }
3687}
3688
3689impl Ctx<'_> {
3694 fn check_unused_entities(&mut self) {
3695 let mut all_idents = self.collect_all_referenced_idents();
3696 for name in self.external_refs {
3698 all_idents.insert(name.as_str());
3699 }
3700 for v in self.variants() {
3702 let base = expr_as_ident(&v.base).or_else(|| {
3703 if let Expr::JoinLookup { entity, .. } = &v.base {
3704 expr_as_ident(entity)
3705 } else {
3706 None
3707 }
3708 });
3709 if let Some(name) = base {
3710 all_idents.insert(name);
3711 }
3712 }
3713 let mut findings = Vec::new();
3714
3715 for d in &self.module.declarations {
3716 let block = match d {
3717 Decl::Block(b)
3718 if matches!(
3719 b.kind,
3720 BlockKind::Entity | BlockKind::ExternalEntity
3721 ) =>
3722 {
3723 b
3724 }
3725 _ => continue,
3726 };
3727 let name = match &block.name {
3728 Some(n) => n,
3729 None => continue,
3730 };
3731 if !all_idents.contains(name.name.as_str()) {
3732 findings.push(
3733 Diagnostic::warning(
3734 name.span,
3735 format!(
3736 "Entity '{}' is declared but not referenced elsewhere in this specification.",
3737 name.name
3738 ),
3739 )
3740 .with_code("allium.entity.unused"),
3741 );
3742 }
3743 }
3744 self.diagnostics.extend(findings);
3745 }
3746
3747 fn check_unused_definitions(&mut self) {
3748 let mut all_idents = self.collect_all_referenced_idents();
3749 for name in self.external_refs {
3751 all_idents.insert(name.as_str());
3752 }
3753 let mut findings = Vec::new();
3754
3755 for d in &self.module.declarations {
3756 match d {
3757 Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
3758 let name = match &b.name {
3759 Some(n) => n,
3760 None => continue,
3761 };
3762 if !all_idents.contains(name.name.as_str()) {
3763 findings.push(
3764 Diagnostic::warning(
3765 name.span,
3766 format!(
3767 "Value '{}' is declared but not referenced elsewhere.",
3768 name.name
3769 ),
3770 )
3771 .with_code("allium.definition.unused"),
3772 );
3773 }
3774 }
3775 _ => {}
3776 }
3777 }
3778 self.diagnostics.extend(findings);
3779 }
3780
3781 fn collect_all_referenced_idents(&self) -> HashSet<&str> {
3784 collect_referenced_ident_nodes(self.module)
3785 .into_iter()
3786 .map(|id| id.name)
3787 .collect()
3788 }
3789}
3790
3791fn collect_uppercase_idents_from_item<'a>(
3792 kind: &'a BlockItemKind,
3793 out: &mut Vec<ReferencedIdent<'a>>,
3794) {
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, .. } => {
3803 collect_uppercase_idents_from_expr(value, out);
3804 }
3805 BlockItemKind::ForBlock {
3806 collection,
3807 filter,
3808 items,
3809 ..
3810 } => {
3811 collect_uppercase_idents_from_expr(collection, out);
3812 if let Some(f) = filter {
3813 collect_uppercase_idents_from_expr(f, out);
3814 }
3815 for item in items {
3816 collect_uppercase_idents_from_item(&item.kind, out);
3817 }
3818 }
3819 BlockItemKind::IfBlock {
3820 branches,
3821 else_items,
3822 } => {
3823 for b in branches {
3824 collect_uppercase_idents_from_expr(&b.condition, out);
3825 for item in &b.items {
3826 collect_uppercase_idents_from_item(&item.kind, out);
3827 }
3828 }
3829 if let Some(items) = else_items {
3830 for item in items {
3831 collect_uppercase_idents_from_item(&item.kind, out);
3832 }
3833 }
3834 }
3835 BlockItemKind::ContractsClause { entries } => {
3836 for e in entries {
3837 if e.qualifier.is_none() {
3841 out.push(ReferencedIdent::unqualified(&e.name));
3842 }
3843 }
3844 }
3845 _ => {}
3846 }
3847}
3848
3849fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut Vec<ReferencedIdent<'a>>) {
3850 match expr {
3851 Expr::Ident(id) if starts_uppercase(&id.name) => {
3852 out.push(ReferencedIdent::unqualified(id));
3853 }
3854 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
3855 collect_uppercase_idents_from_expr(object, out);
3856 }
3857 Expr::Call { function, args, .. } => {
3858 collect_uppercase_idents_from_expr(function, out);
3859 for a in args {
3860 match a {
3861 CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
3862 CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
3863 }
3864 }
3865 }
3866 Expr::JoinLookup { entity, fields, .. } => {
3867 collect_uppercase_idents_from_expr(entity, out);
3868 for f in fields {
3869 if let Some(v) = &f.value {
3870 collect_uppercase_idents_from_expr(v, out);
3871 }
3872 }
3873 }
3874 Expr::BinaryOp { left, right, .. }
3875 | Expr::Comparison { left, right, .. }
3876 | Expr::LogicalOp { left, right, .. }
3877 | Expr::Pipe { left, right, .. }
3878 | Expr::NullCoalesce { left, right, .. } => {
3879 collect_uppercase_idents_from_expr(left, out);
3880 collect_uppercase_idents_from_expr(right, out);
3881 }
3882 Expr::Not { operand, .. }
3883 | Expr::Exists { operand, .. }
3884 | Expr::NotExists { operand, .. }
3885 | Expr::TypeOptional { inner: operand, .. } => {
3886 collect_uppercase_idents_from_expr(operand, out);
3887 }
3888 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3889 collect_uppercase_idents_from_expr(element, out);
3890 collect_uppercase_idents_from_expr(collection, out);
3891 }
3892 Expr::Where { source, condition, .. }
3893 | Expr::With {
3894 source,
3895 predicate: condition,
3896 ..
3897 } => {
3898 collect_uppercase_idents_from_expr(source, out);
3899 collect_uppercase_idents_from_expr(condition, out);
3900 }
3901 Expr::WhenGuard { action, condition, .. } => {
3902 collect_uppercase_idents_from_expr(action, out);
3903 collect_uppercase_idents_from_expr(condition, out);
3904 }
3905 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3906 collect_uppercase_idents_from_expr(value, out);
3907 }
3908 Expr::Block { items, .. } => {
3909 for item in items {
3910 collect_uppercase_idents_from_expr(item, out);
3911 }
3912 }
3913 Expr::Conditional { branches, else_body, .. } => {
3914 for b in branches {
3915 collect_uppercase_idents_from_expr(&b.condition, out);
3916 collect_uppercase_idents_from_expr(&b.body, out);
3917 }
3918 if let Some(body) = else_body {
3919 collect_uppercase_idents_from_expr(body, out);
3920 }
3921 }
3922 Expr::For { collection, filter, body, .. } => {
3923 collect_uppercase_idents_from_expr(collection, out);
3924 if let Some(f) = filter {
3925 collect_uppercase_idents_from_expr(f, out);
3926 }
3927 collect_uppercase_idents_from_expr(body, out);
3928 }
3929 Expr::Lambda { body, .. } => {
3930 collect_uppercase_idents_from_expr(body, out);
3931 }
3932 Expr::TransitionsTo { subject, new_state, .. }
3933 | Expr::Becomes { subject, new_state, .. } => {
3934 collect_uppercase_idents_from_expr(subject, out);
3935 collect_uppercase_idents_from_expr(new_state, out);
3936 }
3937 Expr::GenericType { name, args, .. } => {
3938 collect_uppercase_idents_from_expr(name, out);
3939 for a in args {
3940 collect_uppercase_idents_from_expr(a, out);
3941 }
3942 }
3943 Expr::SetLiteral { elements, .. } => {
3944 for e in elements {
3945 collect_uppercase_idents_from_expr(e, out);
3946 }
3947 }
3948 Expr::ObjectLiteral { fields, .. } => {
3949 for f in fields {
3950 collect_uppercase_idents_from_expr(&f.value, out);
3951 }
3952 }
3953 Expr::ProjectionMap { source, .. } => {
3954 collect_uppercase_idents_from_expr(source, out);
3955 }
3956 Expr::QualifiedName(q) => {
3957 out.push(ReferencedIdent {
3958 name: &q.name,
3959 span: q.span,
3960 qualified: q.qualifier.is_some(),
3961 });
3962 }
3963 _ => {}
3964 }
3965}
3966
3967pub fn collect_qualified_references(module: &Module) -> Vec<(String, String)> {
3977 let mut refs = Vec::new();
3978 for d in &module.declarations {
3979 match d {
3980 Decl::Block(b) => {
3981 for item in &b.items {
3982 collect_qrefs_from_item(&item.kind, &mut refs);
3983 }
3984 }
3985 Decl::Variant(v) => {
3986 collect_qrefs_from_expr(&v.base, &mut refs);
3987 for item in &v.items {
3988 collect_qrefs_from_item(&item.kind, &mut refs);
3989 }
3990 }
3991 Decl::Invariant(inv) => {
3992 collect_qrefs_from_expr(&inv.body, &mut refs);
3993 }
3994 Decl::Default(def) => {
3995 collect_qrefs_from_expr(&def.value, &mut refs);
3996 }
3997 Decl::Deferred(def) => {
3998 collect_qrefs_from_expr(&def.path, &mut refs);
3999 }
4000 _ => {}
4002 }
4003 }
4004 refs
4005}
4006
4007pub fn collect_all_referenced_idents(module: &Module) -> HashSet<String> {
4013 collect_referenced_ident_nodes(module)
4014 .into_iter()
4015 .map(|id| id.name.to_string())
4016 .collect()
4017}
4018
4019struct ReferencedIdent<'a> {
4022 name: &'a str,
4023 span: Span,
4024 qualified: bool,
4028}
4029
4030impl<'a> ReferencedIdent<'a> {
4031 fn unqualified(id: &'a Ident) -> Self {
4032 Self {
4033 name: &id.name,
4034 span: id.span,
4035 qualified: false,
4036 }
4037 }
4038}
4039
4040fn collect_referenced_ident_nodes(module: &Module) -> Vec<ReferencedIdent<'_>> {
4045 let mut idents: Vec<ReferencedIdent<'_>> = Vec::new();
4046 for d in &module.declarations {
4047 match d {
4048 Decl::Block(b) => {
4049 for item in &b.items {
4050 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4051 }
4052 }
4053 Decl::Variant(v) => {
4054 if let Expr::Ident(id) = &v.base {
4055 idents.push(ReferencedIdent::unqualified(id));
4056 }
4057 for item in &v.items {
4058 collect_uppercase_idents_from_item(&item.kind, &mut idents);
4059 }
4060 }
4061 Decl::Invariant(inv) => {
4062 collect_uppercase_idents_from_expr(&inv.body, &mut idents);
4063 }
4064 Decl::Default(def) => {
4065 if let Some(tn) = &def.type_name {
4066 idents.push(ReferencedIdent::unqualified(tn));
4067 }
4068 collect_uppercase_idents_from_expr(&def.value, &mut idents);
4069 }
4070 _ => {}
4071 }
4072 }
4073 idents
4074}
4075
4076pub fn collect_declared_names(module: &Module) -> HashSet<String> {
4083 let mut names = HashSet::new();
4084 for d in &module.declarations {
4085 match d {
4086 Decl::Block(b) => {
4087 if matches!(
4088 b.kind,
4089 BlockKind::Entity
4090 | BlockKind::ExternalEntity
4091 | BlockKind::Value
4092 | BlockKind::Enum
4093 | BlockKind::Actor
4094 | BlockKind::Contract
4095 ) {
4096 if let Some(n) = &b.name {
4097 names.insert(n.name.clone());
4098 }
4099 }
4100 }
4101 Decl::Variant(v) => {
4102 names.insert(v.name.name.clone());
4103 }
4104 _ => {}
4105 }
4106 }
4107 names
4108}
4109
4110pub fn collect_trigger_outputs(module: &Module) -> HashSet<String> {
4117 let mut names: HashSet<&str> = HashSet::new();
4118 for d in &module.declarations {
4119 let Decl::Block(b) = d else { continue };
4120 match b.kind {
4121 BlockKind::Surface => {
4122 for item in &b.items {
4123 if let BlockItemKind::Clause { keyword, value } = &item.kind {
4124 if keyword == "provides" {
4125 collect_call_names(value, &mut names);
4126 }
4127 }
4128 }
4129 }
4130 BlockKind::Rule => {
4131 for item in &b.items {
4132 collect_emitted_trigger_from_item(&item.kind, &mut names);
4133 }
4134 }
4135 _ => {}
4136 }
4137 }
4138 names.into_iter().map(str::to_string).collect()
4139}
4140
4141fn collect_qrefs_from_item(kind: &BlockItemKind, out: &mut Vec<(String, String)>) {
4142 match kind {
4143 BlockItemKind::Clause { value, .. }
4144 | BlockItemKind::Assignment { value, .. }
4145 | BlockItemKind::ParamAssignment { value, .. }
4146 | BlockItemKind::Let { value, .. }
4147 | BlockItemKind::PathAssignment { value, .. }
4148 | BlockItemKind::InvariantBlock { body: value, .. }
4149 | BlockItemKind::FieldWithWhen { value, .. } => {
4150 collect_qrefs_from_expr(value, out);
4151 }
4152 BlockItemKind::ForBlock {
4153 collection,
4154 filter,
4155 items,
4156 ..
4157 } => {
4158 collect_qrefs_from_expr(collection, out);
4159 if let Some(f) = filter {
4160 collect_qrefs_from_expr(f, out);
4161 }
4162 for item in items {
4163 collect_qrefs_from_item(&item.kind, out);
4164 }
4165 }
4166 BlockItemKind::IfBlock {
4167 branches,
4168 else_items,
4169 } => {
4170 for b in branches {
4171 collect_qrefs_from_expr(&b.condition, out);
4172 for item in &b.items {
4173 collect_qrefs_from_item(&item.kind, out);
4174 }
4175 }
4176 if let Some(items) = else_items {
4177 for item in items {
4178 collect_qrefs_from_item(&item.kind, out);
4179 }
4180 }
4181 }
4182 BlockItemKind::ContractsClause { entries } => {
4183 for e in entries {
4184 if let Some(ref qualifier) = e.qualifier {
4185 out.push((qualifier.clone(), e.name.name.clone()));
4186 }
4187 }
4188 }
4189 _ => {}
4192 }
4193}
4194
4195fn collect_qrefs_from_expr(expr: &Expr, out: &mut Vec<(String, String)>) {
4196 match expr {
4197 Expr::QualifiedName(q) => {
4198 if let Some(ref qualifier) = q.qualifier {
4199 out.push((qualifier.clone(), q.name.clone()));
4200 }
4201 }
4202 Expr::MemberAccess { object, field, .. }
4203 | Expr::OptionalAccess { object, field, .. } => {
4204 if let Expr::Ident(id) = object.as_ref() {
4206 if starts_uppercase(&field.name) {
4207 out.push((id.name.clone(), field.name.clone()));
4208 }
4209 }
4210 collect_qrefs_from_expr(object, out);
4211 }
4212 Expr::Call { function, args, .. } => {
4213 collect_qrefs_from_expr(function, out);
4214 for a in args {
4215 match a {
4216 CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
4217 CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
4218 }
4219 }
4220 }
4221 Expr::JoinLookup { entity, fields, .. } => {
4222 collect_qrefs_from_expr(entity, out);
4223 for f in fields {
4224 if let Some(v) = &f.value {
4225 collect_qrefs_from_expr(v, out);
4226 }
4227 }
4228 }
4229 Expr::BinaryOp { left, right, .. }
4230 | Expr::Comparison { left, right, .. }
4231 | Expr::LogicalOp { left, right, .. }
4232 | Expr::Pipe { left, right, .. }
4233 | Expr::NullCoalesce { left, right, .. } => {
4234 collect_qrefs_from_expr(left, out);
4235 collect_qrefs_from_expr(right, out);
4236 }
4237 Expr::Not { operand, .. }
4238 | Expr::Exists { operand, .. }
4239 | Expr::NotExists { operand, .. }
4240 | Expr::TypeOptional { inner: operand, .. } => {
4241 collect_qrefs_from_expr(operand, out);
4242 }
4243 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4244 collect_qrefs_from_expr(element, out);
4245 collect_qrefs_from_expr(collection, out);
4246 }
4247 Expr::Where { source, condition, .. }
4248 | Expr::With {
4249 source,
4250 predicate: condition,
4251 ..
4252 } => {
4253 collect_qrefs_from_expr(source, out);
4254 collect_qrefs_from_expr(condition, out);
4255 }
4256 Expr::WhenGuard { action, condition, .. } => {
4257 collect_qrefs_from_expr(action, out);
4258 collect_qrefs_from_expr(condition, out);
4259 }
4260 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
4261 collect_qrefs_from_expr(value, out);
4262 }
4263 Expr::Block { items, .. } => {
4264 for item in items {
4265 collect_qrefs_from_expr(item, out);
4266 }
4267 }
4268 Expr::Conditional {
4269 branches,
4270 else_body,
4271 ..
4272 } => {
4273 for b in branches {
4274 collect_qrefs_from_expr(&b.condition, out);
4275 collect_qrefs_from_expr(&b.body, out);
4276 }
4277 if let Some(body) = else_body {
4278 collect_qrefs_from_expr(body, out);
4279 }
4280 }
4281 Expr::For {
4282 collection,
4283 filter,
4284 body,
4285 ..
4286 } => {
4287 collect_qrefs_from_expr(collection, out);
4288 if let Some(f) = filter {
4289 collect_qrefs_from_expr(f, out);
4290 }
4291 collect_qrefs_from_expr(body, out);
4292 }
4293 Expr::Lambda { body, .. } => {
4294 collect_qrefs_from_expr(body, out);
4295 }
4296 Expr::TransitionsTo {
4297 subject, new_state, ..
4298 }
4299 | Expr::Becomes {
4300 subject, new_state, ..
4301 } => {
4302 collect_qrefs_from_expr(subject, out);
4303 collect_qrefs_from_expr(new_state, out);
4304 }
4305 Expr::GenericType { name, args, .. } => {
4306 collect_qrefs_from_expr(name, out);
4307 for a in args {
4308 collect_qrefs_from_expr(a, out);
4309 }
4310 }
4311 Expr::SetLiteral { elements, .. } => {
4312 for e in elements {
4313 collect_qrefs_from_expr(e, out);
4314 }
4315 }
4316 Expr::ObjectLiteral { fields, .. } => {
4317 for f in fields {
4318 collect_qrefs_from_expr(&f.value, out);
4319 }
4320 }
4321 Expr::ProjectionMap { source, .. } => {
4322 collect_qrefs_from_expr(source, out);
4323 }
4324 _ => {}
4325 }
4326}
4327
4328impl Ctx<'_> {
4333 fn check_deferred_location_hints(&mut self, source: &str) {
4334 for d in &self.module.declarations {
4335 let Decl::Deferred(def) = d else {
4336 continue;
4337 };
4338 const LINE_TERMINATORS: [char; 4] = ['\n', '\r', '\u{2028}', '\u{2029}'];
4359 let bytes = source.as_bytes();
4360 let kw_start = def.span.start;
4361 let line_start = source[..kw_start]
4362 .rfind(LINE_TERMINATORS)
4363 .map_or(0, |i| {
4364 i + source[i..].chars().next().map_or(1, char::len_utf8)
4365 });
4366 let mut name_start = kw_start + "deferred".len();
4367 while bytes.get(name_start).is_some_and(u8::is_ascii_whitespace) {
4368 name_start += 1;
4369 }
4370 let starts_name =
4371 |b: &u8| b.is_ascii_alphabetic() || *b == b'_';
4372 let continues_name =
4373 |b: &u8| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'.';
4374 if !bytes[line_start..kw_start]
4375 .iter()
4376 .all(|b| b.is_ascii_whitespace())
4377 || name_start == kw_start + "deferred".len()
4378 || !bytes.get(name_start).is_some_and(starts_name)
4379 {
4380 continue;
4384 }
4385 let mut name_end = name_start + 1;
4386 while bytes.get(name_end).is_some_and(continues_name) {
4387 name_end += 1;
4388 }
4389 let line_end = source[name_end..]
4390 .find(LINE_TERMINATORS)
4391 .map_or(source.len(), |i| name_end + i);
4392 let suffix = &source[name_end..line_end];
4393 if suffix.contains('"')
4394 || suffix.contains("http://")
4395 || suffix.contains("https://")
4396 || suffix.contains("-- see:")
4397 {
4398 continue;
4399 }
4400 self.push(
4401 Diagnostic::warning(
4402 def.span,
4403 format!(
4404 "Deferred specification '{}' should include a location hint.",
4405 &source[name_start..name_end],
4406 ),
4407 )
4408 .with_code("allium.deferred.missingLocationHint"),
4409 );
4410 }
4411 }
4412}
4413
4414impl Ctx<'_> {
4419 fn check_rule_invalid_triggers(&mut self) {
4420 for rule in self.blocks(BlockKind::Rule) {
4421 let rule_name = match &rule.name {
4422 Some(n) => &n.name,
4423 None => continue,
4424 };
4425
4426 for item in &rule.items {
4427 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4428 continue;
4429 };
4430 if keyword != "when" {
4431 continue;
4432 }
4433 if !is_valid_trigger(value) {
4434 self.push(
4435 Diagnostic::error(
4436 item.span,
4437 format!(
4438 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
4439 ),
4440 )
4441 .with_code("allium.rule.invalidTrigger"),
4442 );
4443 }
4444 }
4445 }
4446 }
4447}
4448
4449fn is_valid_trigger(expr: &Expr) -> bool {
4450 match expr {
4451 Expr::Call { function, .. } => {
4457 matches!(
4458 function.as_ref(),
4459 Expr::Ident(_) | Expr::MemberAccess { .. } | Expr::QualifiedName(_)
4460 )
4461 }
4462 Expr::Binding { value, .. } => {
4464 matches!(
4465 value.as_ref(),
4466 Expr::Becomes { .. }
4467 | Expr::TransitionsTo { .. }
4468 | Expr::MemberAccess { .. }
4469 | Expr::Comparison { .. }
4470 )
4471 }
4472 Expr::LogicalOp {
4474 op: LogicalOp::Or,
4475 left,
4476 right,
4477 ..
4478 } => is_valid_trigger(left) && is_valid_trigger(right),
4479 Expr::Comparison { left, .. } => {
4481 matches!(left.as_ref(), Expr::MemberAccess { .. })
4482 }
4483 _ => false,
4484 }
4485}
4486
4487impl Ctx<'_> {
4492 fn check_rule_undefined_bindings(&mut self) {
4493 let mut given_bindings: HashSet<&str> = HashSet::new();
4495 for given in self.blocks(BlockKind::Given) {
4496 for item in &given.items {
4497 if let BlockItemKind::Assignment { name, .. } = &item.kind {
4498 given_bindings.insert(&name.name);
4499 }
4500 }
4501 }
4502
4503 let mut default_names: HashSet<&str> = HashSet::new();
4505 for d in &self.module.declarations {
4506 if let Decl::Default(def) = d {
4507 default_names.insert(&def.name.name);
4508 }
4509 }
4510
4511 for rule in self.blocks(BlockKind::Rule) {
4512 let rule_name = match &rule.name {
4513 Some(n) => &n.name,
4514 None => continue,
4515 };
4516
4517 let mut bound: HashSet<&str> = HashSet::new();
4518 bound.extend(&given_bindings);
4519 bound.extend(&default_names);
4520
4521 for item in &rule.items {
4523 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4524 continue;
4525 };
4526 if keyword != "when" {
4527 continue;
4528 }
4529 collect_bound_names(value, &mut bound);
4530 }
4531
4532 for item in &rule.items {
4534 if let BlockItemKind::Let { name, .. } = &item.kind {
4535 bound.insert(&name.name);
4536 }
4537 }
4538
4539 for item in &rule.items {
4541 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4542 continue;
4543 };
4544 if keyword != "requires" && keyword != "ensures" {
4545 continue;
4546 }
4547 check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
4548 }
4549
4550 for item in &rule.items {
4552 match &item.kind {
4553 BlockItemKind::ForBlock {
4554 binding,
4555 items,
4556 ..
4557 } => {
4558 let mut inner_bound = bound.clone();
4559 match binding {
4560 ForBinding::Single(id) => { inner_bound.insert(&id.name); }
4561 ForBinding::Destructured(ids, _) => {
4562 for id in ids {
4563 inner_bound.insert(&id.name);
4564 }
4565 }
4566 }
4567 for sub_item in items {
4568 if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
4569 if keyword == "ensures" || keyword == "requires" {
4570 check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
4571 }
4572 }
4573 }
4574 }
4575 _ => {}
4576 }
4577 }
4578
4579 for item in &rule.items {
4583 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
4584 if keyword != "when" { continue }
4585 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
4586 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
4587 continue;
4588 }
4589 let mut found = false;
4591 for check_item in &rule.items {
4592 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
4593 if kw != "requires" && kw != "ensures" { continue }
4594 if expr_contains_ident(v, &binding_name.name) {
4595 self.push(
4596 Diagnostic::error(
4597 check_item.span,
4598 format!(
4599 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
4600 binding_name.name
4601 ),
4602 )
4603 .with_code("allium.rule.undefinedBinding"),
4604 );
4605 found = true;
4606 break;
4607 }
4608 }
4609 if found { break; }
4610 }
4611 }
4612 }
4613}
4614
4615fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
4616 match expr {
4617 Expr::Binding { name, .. } => {
4618 out.insert(&name.name);
4619 }
4620 Expr::Call { args, .. } => {
4621 for arg in args {
4622 if let CallArg::Positional(Expr::Ident(id)) = arg {
4623 out.insert(&id.name);
4624 }
4625 }
4626 }
4627 Expr::LogicalOp { left, right, .. } => {
4628 collect_bound_names(left, out);
4629 collect_bound_names(right, out);
4630 }
4631 _ => {}
4632 }
4633}
4634
4635fn check_unbound_roots(
4636 expr: &Expr,
4637 bound: &HashSet<&str>,
4638 rule_name: &str,
4639 diagnostics: &mut Vec<Diagnostic>,
4640) {
4641 match expr {
4642 Expr::MemberAccess { object, .. } => {
4643 if let Expr::Ident(id) = object.as_ref() {
4644 if !starts_uppercase(&id.name)
4645 && !bound.contains(id.name.as_str())
4646 && !is_builtin_name(&id.name)
4647 {
4648 diagnostics.push(
4649 Diagnostic::error(
4650 id.span,
4651 format!(
4652 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
4653 id.name
4654 ),
4655 )
4656 .with_code("allium.rule.undefinedBinding"),
4657 );
4658 }
4659 }
4660 }
4661 Expr::Comparison { left, right, .. } => {
4662 check_unbound_roots(left, bound, rule_name, diagnostics);
4663 check_unbound_roots(right, bound, rule_name, diagnostics);
4664 }
4665 Expr::LogicalOp { left, right, .. } => {
4666 check_unbound_roots(left, bound, rule_name, diagnostics);
4667 check_unbound_roots(right, bound, rule_name, diagnostics);
4668 }
4669 Expr::Block { items, .. } => {
4670 let mut block_bound = bound.clone();
4671 for item in items {
4672 if let Expr::LetExpr { name, value, .. } = item {
4673 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
4674 block_bound.insert(name.name.as_str());
4675 } else {
4676 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
4677 }
4678 }
4679 }
4680 Expr::For { binding, collection, body, .. } => {
4681 check_unbound_roots(collection, bound, rule_name, diagnostics);
4682 let mut inner = bound.clone();
4684 match binding {
4685 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
4686 ForBinding::Destructured(ids, _) => {
4687 for id in ids {
4688 inner.insert(id.name.as_str());
4689 }
4690 }
4691 }
4692 check_unbound_roots(body, &inner, rule_name, diagnostics);
4693 }
4694 Expr::BinaryOp { left, right, .. } => {
4695 check_unbound_roots(left, bound, rule_name, diagnostics);
4696 check_unbound_roots(right, bound, rule_name, diagnostics);
4697 }
4698 Expr::Call { function, args, .. } => {
4699 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
4701 check_unbound_roots(function, bound, rule_name, diagnostics);
4702 }
4703 let mut call_bound = bound.clone();
4705 for a in args {
4706 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
4707 if let Expr::Ident(id) = param.as_ref() {
4708 call_bound.insert(id.name.as_str());
4709 }
4710 }
4711 }
4712 for a in args {
4713 match a {
4714 CallArg::Positional(Expr::Lambda { body, .. }) => {
4715 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
4716 }
4717 CallArg::Positional(e) => {
4718 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
4719 }
4720 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
4721 }
4722 }
4723 }
4724 Expr::Not { operand, .. }
4725 | Expr::Exists { operand, .. }
4726 | Expr::NotExists { operand, .. } => {
4727 check_unbound_roots(operand, bound, rule_name, diagnostics);
4728 }
4729 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4730 check_unbound_roots(element, bound, rule_name, diagnostics);
4731 check_unbound_roots(collection, bound, rule_name, diagnostics);
4732 }
4733 Expr::Conditional { branches, else_body, .. } => {
4734 for b in branches {
4735 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
4736 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
4737 }
4738 if let Some(body) = else_body {
4739 check_unbound_roots(body, bound, rule_name, diagnostics);
4740 }
4741 }
4742 _ => {}
4743 }
4744}
4745
4746fn is_builtin_name(name: &str) -> bool {
4747 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
4748}
4749
4750impl Ctx<'_> {
4755 fn check_duplicate_let_bindings(&mut self) {
4756 for rule in self.blocks(BlockKind::Rule) {
4757 let mut seen: HashMap<&str, Span> = HashMap::new();
4758 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
4759 }
4760 }
4761
4762 fn check_duplicate_lets_in_items<'b>(
4763 &mut self,
4764 items: &'b [BlockItem],
4765 seen: &mut HashMap<&'b str, Span>,
4766 ) {
4767 for item in items {
4768 match &item.kind {
4769 BlockItemKind::Let { name, .. } => {
4770 if seen.contains_key(name.name.as_str()) {
4771 self.push(
4772 Diagnostic::error(
4773 name.span,
4774 format!("Duplicate let binding '{}' in this rule.", name.name),
4775 )
4776 .with_code("allium.let.duplicateBinding"),
4777 );
4778 } else {
4779 seen.insert(&name.name, name.span);
4780 }
4781 }
4782 BlockItemKind::ForBlock { items, .. } => {
4783 self.check_duplicate_lets_in_items(items, seen);
4784 }
4785 BlockItemKind::IfBlock {
4786 branches,
4787 else_items,
4788 } => {
4789 for b in branches {
4790 self.check_duplicate_lets_in_items(&b.items, seen);
4791 }
4792 if let Some(items) = else_items {
4793 self.check_duplicate_lets_in_items(items, seen);
4794 }
4795 }
4796 BlockItemKind::Clause { value, .. } => {
4797 self.check_duplicate_lets_in_expr(value, seen);
4798 }
4799 _ => {}
4800 }
4801 }
4802 }
4803
4804 fn check_duplicate_lets_in_expr<'b>(
4805 &mut self,
4806 expr: &'b Expr,
4807 seen: &mut HashMap<&'b str, Span>,
4808 ) {
4809 match expr {
4810 Expr::LetExpr { name, value, .. } => {
4811 if seen.contains_key(name.name.as_str()) {
4812 self.push(
4813 Diagnostic::error(
4814 name.span,
4815 format!("Duplicate let binding '{}' in this rule.", name.name),
4816 )
4817 .with_code("allium.let.duplicateBinding"),
4818 );
4819 } else {
4820 seen.insert(&name.name, name.span);
4821 }
4822 self.check_duplicate_lets_in_expr(value, seen);
4823 }
4824 Expr::Block { items, .. } => {
4825 for item in items {
4826 self.check_duplicate_lets_in_expr(item, seen);
4827 }
4828 }
4829 Expr::For { body, .. } => {
4830 self.check_duplicate_lets_in_expr(body, seen);
4831 }
4832 Expr::Conditional { branches, else_body, .. } => {
4833 for b in branches {
4834 self.check_duplicate_lets_in_expr(&b.body, seen);
4835 }
4836 if let Some(body) = else_body {
4837 self.check_duplicate_lets_in_expr(body, seen);
4838 }
4839 }
4840 _ => {}
4841 }
4842 }
4843}
4844
4845impl Ctx<'_> {
4850 fn check_config_undefined_references(&mut self) {
4851 let mut config_params: HashSet<&str> = HashSet::new();
4852 for config in self.blocks(BlockKind::Config) {
4853 for item in &config.items {
4854 if let BlockItemKind::Assignment { name, .. } = &item.kind {
4855 config_params.insert(&name.name);
4856 }
4857 }
4858 }
4859
4860 for d in &self.module.declarations {
4862 match d {
4863 Decl::Block(b) => {
4864 if b.kind == BlockKind::Config {
4865 continue;
4866 }
4867 for item in &b.items {
4868 self.check_config_refs_in_item(&item.kind, &config_params);
4869 }
4870 }
4871 Decl::Invariant(inv) => {
4872 self.check_config_refs_in_expr(&inv.body, &config_params);
4873 }
4874 _ => {}
4875 }
4876 }
4877 }
4878
4879 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
4880 match kind {
4881 BlockItemKind::Clause { value, .. }
4882 | BlockItemKind::Assignment { value, .. }
4883 | BlockItemKind::ParamAssignment { value, .. }
4884 | BlockItemKind::Let { value, .. }
4885 | BlockItemKind::FieldWithWhen { value, .. } => {
4886 self.check_config_refs_in_expr(value, params);
4887 }
4888 BlockItemKind::ForBlock { collection, filter, items, .. } => {
4889 self.check_config_refs_in_expr(collection, params);
4890 if let Some(f) = filter {
4891 self.check_config_refs_in_expr(f, params);
4892 }
4893 for item in items {
4894 self.check_config_refs_in_item(&item.kind, params);
4895 }
4896 }
4897 BlockItemKind::IfBlock { branches, else_items } => {
4898 for b in branches {
4899 self.check_config_refs_in_expr(&b.condition, params);
4900 for item in &b.items {
4901 self.check_config_refs_in_item(&item.kind, params);
4902 }
4903 }
4904 if let Some(items) = else_items {
4905 for item in items {
4906 self.check_config_refs_in_item(&item.kind, params);
4907 }
4908 }
4909 }
4910 _ => {}
4911 }
4912 }
4913
4914 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
4915 match expr {
4916 Expr::MemberAccess { object, field, .. } => {
4917 if let Expr::Ident(id) = object.as_ref() {
4918 if id.name == "config" && !params.contains(field.name.as_str()) {
4919 self.push(
4920 Diagnostic::warning(
4921 field.span,
4922 format!(
4923 "Config reference 'config.{}' is not declared in any config block.",
4924 field.name
4925 ),
4926 )
4927 .with_code("allium.config.undefinedReference"),
4928 );
4929 return;
4930 }
4931 }
4932 self.check_config_refs_in_expr(object, params);
4933 }
4934 Expr::Call { function, args, .. } => {
4935 self.check_config_refs_in_expr(function, params);
4936 for a in args {
4937 match a {
4938 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
4939 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
4940 }
4941 }
4942 }
4943 Expr::BinaryOp { left, right, .. }
4944 | Expr::Comparison { left, right, .. }
4945 | Expr::LogicalOp { left, right, .. }
4946 | Expr::Pipe { left, right, .. }
4947 | Expr::NullCoalesce { left, right, .. } => {
4948 self.check_config_refs_in_expr(left, params);
4949 self.check_config_refs_in_expr(right, params);
4950 }
4951 Expr::Not { operand, .. }
4952 | Expr::Exists { operand, .. }
4953 | Expr::NotExists { operand, .. } => {
4954 self.check_config_refs_in_expr(operand, params);
4955 }
4956 Expr::Block { items, .. } => {
4957 for item in items {
4958 self.check_config_refs_in_expr(item, params);
4959 }
4960 }
4961 Expr::Conditional { branches, else_body, .. } => {
4962 for b in branches {
4963 self.check_config_refs_in_expr(&b.condition, params);
4964 self.check_config_refs_in_expr(&b.body, params);
4965 }
4966 if let Some(body) = else_body {
4967 self.check_config_refs_in_expr(body, params);
4968 }
4969 }
4970 Expr::For { collection, filter, body, .. } => {
4971 self.check_config_refs_in_expr(collection, params);
4972 if let Some(f) = filter {
4973 self.check_config_refs_in_expr(f, params);
4974 }
4975 self.check_config_refs_in_expr(body, params);
4976 }
4977 Expr::LetExpr { value, .. } => {
4978 self.check_config_refs_in_expr(value, params);
4979 }
4980 Expr::Lambda { body, .. } => {
4981 self.check_config_refs_in_expr(body, params);
4982 }
4983 _ => {}
4984 }
4985 }
4986}
4987
4988fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
4993 match kind {
4994 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
4995 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
4996 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
4997 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
4998 BlockItemKind::ForBlock {
4999 collection,
5000 filter,
5001 items,
5002 ..
5003 } => {
5004 expr_contains_ident(collection, name)
5005 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
5006 || items.iter().any(|i| item_contains_ident(&i.kind, name))
5007 }
5008 BlockItemKind::IfBlock {
5009 branches,
5010 else_items,
5011 } => {
5012 branches.iter().any(|b| {
5013 expr_contains_ident(&b.condition, name)
5014 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
5015 }) || else_items
5016 .as_ref()
5017 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
5018 }
5019 BlockItemKind::PathAssignment { path, value } => {
5020 expr_contains_ident(path, name) || expr_contains_ident(value, name)
5021 }
5022 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
5023 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
5024 BlockItemKind::ContractsClause { .. }
5025 | BlockItemKind::EnumVariant { .. }
5026 | BlockItemKind::OpenQuestion { .. }
5027 | BlockItemKind::Annotation(_)
5028 | BlockItemKind::TransitionsBlock(_) => false,
5029 }
5030}
5031
5032fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
5033 match expr {
5034 Expr::Ident(id) => id.name == name,
5035 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
5036 expr_contains_ident(object, name)
5037 }
5038 Expr::Call { function, args, .. } => {
5039 expr_contains_ident(function, name)
5040 || args.iter().any(|a| match a {
5041 CallArg::Positional(e) => expr_contains_ident(e, name),
5042 CallArg::Named(n) => expr_contains_ident(&n.value, name),
5043 })
5044 }
5045 Expr::JoinLookup { entity, fields, .. } => {
5046 expr_contains_ident(entity, name)
5047 || fields
5048 .iter()
5049 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
5050 }
5051 Expr::BinaryOp { left, right, .. }
5052 | Expr::Comparison { left, right, .. }
5053 | Expr::LogicalOp { left, right, .. }
5054 | Expr::Pipe { left, right, .. }
5055 | Expr::NullCoalesce { left, right, .. } => {
5056 expr_contains_ident(left, name) || expr_contains_ident(right, name)
5057 }
5058 Expr::Not { operand, .. }
5059 | Expr::Exists { operand, .. }
5060 | Expr::NotExists { operand, .. }
5061 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
5062 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
5063 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
5064 }
5065 Expr::Where {
5066 source, condition, ..
5067 }
5068 | Expr::With {
5069 source,
5070 predicate: condition,
5071 ..
5072 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
5073 Expr::WhenGuard {
5074 action, condition, ..
5075 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
5076 Expr::Lambda { param, body, .. } => {
5077 expr_contains_ident(param, name) || expr_contains_ident(body, name)
5078 }
5079 Expr::Binding { name: n, value, .. } => {
5080 n.name == name || expr_contains_ident(value, name)
5081 }
5082 Expr::SetLiteral { elements, .. } => {
5083 elements.iter().any(|e| expr_contains_ident(e, name))
5084 }
5085 Expr::ObjectLiteral { fields, .. } => {
5086 fields.iter().any(|f| expr_contains_ident(&f.value, name))
5087 }
5088 Expr::GenericType { name: n, args, .. } => {
5089 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
5090 }
5091 Expr::Conditional {
5092 branches,
5093 else_body,
5094 ..
5095 } => {
5096 branches.iter().any(|b| {
5097 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
5098 }) || else_body
5099 .as_ref()
5100 .is_some_and(|e| expr_contains_ident(e, name))
5101 }
5102 Expr::For {
5103 collection,
5104 filter,
5105 body,
5106 ..
5107 } => {
5108 expr_contains_ident(collection, name)
5109 || filter
5110 .as_ref()
5111 .is_some_and(|f| expr_contains_ident(f, name))
5112 || expr_contains_ident(body, name)
5113 }
5114 Expr::TransitionsTo {
5115 subject, new_state, ..
5116 }
5117 | Expr::Becomes {
5118 subject, new_state, ..
5119 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
5120 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
5121 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
5122 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
5123 Expr::QualifiedName(_)
5124 | Expr::StringLiteral(_)
5125 | Expr::BacktickLiteral { .. }
5126 | Expr::NumberLiteral { .. }
5127 | Expr::BoolLiteral { .. }
5128 | Expr::Null { .. }
5129 | Expr::Now { .. }
5130 | Expr::This { .. }
5131 | Expr::Within { .. }
5132 | Expr::DurationLiteral { .. } => false,
5133 }
5134}
5135
5136#[cfg(test)]
5141mod tests {
5142 use super::*;
5143 use crate::diagnostic::Severity;
5144 use crate::parser::parse;
5145
5146 fn analyze_src(src: &str) -> Vec<Diagnostic> {
5147 let input = if src.starts_with("-- allium:") {
5148 src.to_string()
5149 } else {
5150 format!("-- allium: 3\n{src}")
5151 };
5152 let result = parse(&input);
5153 analyze(&result.module, &input)
5154 }
5155
5156 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
5157 diagnostics.iter().any(|d| d.code == Some(code))
5158 }
5159
5160 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
5161 diagnostics.iter().filter(|d| d.code == Some(code)).count()
5162 }
5163
5164 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
5165 let input = if src.starts_with("-- allium:") {
5166 src.to_string()
5167 } else {
5168 format!("-- allium: 3\n{src}")
5169 };
5170 let result = parse(&input);
5171 analyse(&result.module, &input)
5172 }
5173
5174 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
5175 result.findings.iter().any(|f| f["type"] == finding_type)
5176 }
5177
5178 #[test]
5181 fn suppression_on_previous_line() {
5182 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
5183 assert!(!has_code(&ds, "allium.field.unused"));
5184 }
5185
5186 #[test]
5187 fn suppression_all() {
5188 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
5189 assert!(!has_code(&ds, "allium.field.unused"));
5190 }
5191
5192 #[test]
5195 fn related_clause_with_binding_and_guard() {
5196 let ds = analyze_src(
5197 "surface QuoteVersions {\n facing user: User\n}\n\n\
5198 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
5199 );
5200 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
5201 }
5202
5203 #[test]
5204 fn related_clause_reports_unknown_surface() {
5205 let ds = analyze_src(
5206 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
5207 );
5208 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
5209 }
5210
5211 #[test]
5214 fn v1_capitalised_inline_enum() {
5215 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
5216 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
5217 }
5218
5219 #[test]
5222 fn discard_binding_no_warning() {
5223 let ds = analyze_src(
5224 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
5225 );
5226 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
5227 }
5228
5229 #[test]
5232 fn variable_status_assignment_suppresses_unreachable() {
5233 let ds = analyze_src(
5234 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
5235 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
5236 ensures: update.status = new_status\n}\n",
5237 );
5238 assert!(!has_code(&ds, "allium.status.unreachableValue"));
5239 assert!(!has_code(&ds, "allium.status.noExit"));
5240 }
5241
5242 #[test]
5243 fn surface_param_types_disambiguate_shared_status_values() {
5244 let ds = analyze_src(
5247 "entity Account {\n status: active | suspended\n}\n\n\
5248 entity Subscription {\n status: active | expired | cancelled\n}\n\n\
5249 surface AccountAdmin {\n facing admin: Admin\n provides:\n \
5250 SuspendAccount(admin, account: Account)\n when account.status = active\n \
5251 ReinstateAccount(admin, account: Account)\n when account.status = suspended\n}\n\n\
5252 surface SubscriptionAdmin {\n facing admin: Admin\n provides:\n \
5253 CancelSubscription(admin, sub: Subscription)\n when sub.status = active\n \
5254 RenewSubscription(admin, sub: Subscription)\n when sub.status != active\n \
5255 ExpireSubscription(admin, sub: Subscription)\n when sub.status = active\n}\n\n\
5256 rule AccountSuspended {\n when: SuspendAccount(admin, account)\n \
5257 requires: account.status = active\n ensures: account.status = suspended\n}\n\n\
5258 rule AccountReinstated {\n when: ReinstateAccount(admin, account)\n \
5259 requires: account.status = suspended\n ensures: account.status = active\n}\n\n\
5260 rule SubscriptionCancelled {\n when: CancelSubscription(admin, sub)\n \
5261 requires: sub.status = active\n ensures: sub.status = cancelled\n}\n\n\
5262 rule SubscriptionExpired {\n when: ExpireSubscription(admin, sub)\n \
5263 requires: sub.status = active\n ensures: sub.status = expired\n}\n\n\
5264 rule SubscriptionRenewed {\n when: RenewSubscription(admin, sub)\n \
5265 requires: sub.status != active\n ensures: sub.status = active\n}\n",
5266 );
5267 assert!(!has_code(&ds, "allium.status.unreachableValue"));
5268 assert!(!has_code(&ds, "allium.status.noExit"));
5269 }
5270
5271 #[test]
5272 fn negated_requires_counts_as_exit_for_complement_values() {
5273 let ds = analyze_src(
5276 "entity Order {\n status: draft | submitted | approved | rejected\n}\n\n\
5277 rule OrderSubmitted {\n when: SubmitOrder(clerk, order)\n \
5278 requires: order.status = draft\n ensures: order.status = submitted\n}\n\n\
5279 rule OrderApproved {\n when: ApproveOrder(clerk, order)\n \
5280 requires: order.status = submitted\n ensures: order.status = approved\n}\n\n\
5281 rule OrderRejected {\n when: RejectOrder(clerk, order)\n \
5282 requires: order.status = submitted\n ensures: order.status = rejected\n}\n\n\
5283 rule OrderReactivated {\n when: ReactivateOrder(clerk, order)\n \
5284 requires: order.status != draft\n ensures: order.status = draft\n}\n",
5285 );
5286 assert!(!has_code(&ds, "allium.status.unreachableValue"));
5287 assert!(!has_code(&ds, "allium.status.noExit"));
5288 }
5289
5290 #[test]
5293 fn created_with_status_suppresses_unreachable() {
5294 let ds = analyze_src(
5295 "entity Order {\n status: pending | confirmed\n customer: String\n \
5296 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
5297 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
5298 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
5299 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
5300 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
5301 );
5302 assert!(!has_code(&ds, "allium.status.unreachableValue"));
5303 }
5304
5305 #[test]
5306 fn created_omitting_status_warns() {
5307 let ds = analyze_src(
5308 "entity Order {\n status: pending | confirmed\n customer: String\n \
5309 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
5310 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
5311 Order.created(\n customer: customer\n )\n}\n",
5312 );
5313 assert!(has_code(&ds, "allium.created.missingStatus"));
5314 }
5315
5316 #[test]
5317 fn created_multiple_initial_statuses() {
5318 let ds = analyze_src(
5319 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
5320 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
5321 terminal: reviewed\n }\n}\n\n\
5322 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
5323 Proposal.created(status: draft, author: author)\n}\n\n\
5324 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
5325 Proposal.created(status: submitted, author: author)\n}\n\n\
5326 rule Review {\n when: ReviewerReviews(proposal)\n \
5327 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
5328 );
5329 assert!(!has_code(&ds, "allium.status.unreachableValue"));
5331 }
5332
5333 #[test]
5334 fn created_invalid_status_errors() {
5335 let ds = analyze_src(
5336 "entity Task {\n status: open | in_progress | done\n title: String\n \
5337 transitions status {\n open -> in_progress\n in_progress -> done\n \
5338 terminal: done\n }\n}\n\n\
5339 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
5340 Task.created(status: archived, title: title)\n}\n",
5341 );
5342 assert!(has_code(&ds, "allium.created.invalidStatus"));
5343 }
5344
5345 #[test]
5346 fn created_without_transitions_no_missing_status_warning() {
5347 let ds = analyze_src(
5349 "entity Note {\n status: draft | published\n content: String\n}\n\n\
5350 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
5351 Note.created(content: content)\n}\n",
5352 );
5353 assert!(!has_code(&ds, "allium.created.missingStatus"));
5354 }
5355
5356 #[test]
5359 fn terminal_declared_suppresses_no_exit() {
5360 let ds = analyze_src(
5361 "entity Subscription {\n status: active | paused | completed | cancelled\n \
5362 transitions status {\n active -> paused\n paused -> active\n \
5363 active -> completed\n active -> cancelled\n paused -> cancelled\n \
5364 terminal: completed, cancelled\n }\n}\n\n\
5365 rule Activate {\n when: UserActivates(user, subscription)\n \
5366 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
5367 rule Pause {\n when: UserPauses(user, subscription)\n \
5368 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
5369 rule Complete {\n when: PeriodEnds(subscription)\n \
5370 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
5371 rule Cancel {\n when: UserCancels(user, subscription)\n \
5372 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
5373 );
5374 assert!(!has_code(&ds, "allium.status.noExit"));
5375 }
5376
5377 #[test]
5378 fn non_terminal_no_exit_still_warns() {
5379 let ds = analyze_src(
5380 "entity Ticket {\n status: open | stuck | resolved\n \
5381 transitions status {\n open -> stuck\n open -> resolved\n \
5382 terminal: resolved\n }\n}\n\n\
5383 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
5384 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
5385 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
5386 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
5387 );
5388 assert!(has_code(&ds, "allium.status.noExit"));
5390 }
5391
5392 #[test]
5395 fn cross_entity_trigger_param_recognised() {
5396 let ds = analyze_src(
5397 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
5398 transitions status {\n scheduled -> confirmed\n \
5399 confirmed -> completed\n terminal: completed\n }\n}\n\n\
5400 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
5401 InterviewSlot.created(status: scheduled)\n}\n\n\
5402 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
5403 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
5404 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
5405 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
5406 );
5407 assert!(!ds.iter().any(|d| {
5409 d.code == Some("allium.status.unreachableValue")
5410 && d.message.contains("InterviewSlot")
5411 }));
5412 assert!(!ds.iter().any(|d| {
5413 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
5414 }));
5415 }
5416
5417 #[test]
5418 fn cross_entity_undeclared_transition() {
5419 let ds = analyze_src(
5420 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
5421 transitions status {\n scheduled -> confirmed\n \
5422 confirmed -> completed\n terminal: completed\n }\n}\n\n\
5423 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
5424 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
5425 );
5426 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
5427 }
5428
5429 #[test]
5430 fn nested_entity_status_recognised() {
5431 let ds = analyze_src(
5432 "entity Order {\n status: placed | paid\n payment: Payment\n \
5433 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
5434 entity Payment {\n status: pending | captured | failed\n \
5435 transitions status {\n pending -> captured\n pending -> failed\n \
5436 terminal: captured, failed\n }\n}\n\n\
5437 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
5438 requires: order.payment.status = pending\n \
5439 ensures: order.payment.status = captured\n}\n",
5440 );
5441 assert!(!ds.iter().any(|d| {
5443 (d.code == Some("allium.status.unreachableValue")
5444 || d.code == Some("allium.status.noExit"))
5445 && d.message.contains("'captured'")
5446 }));
5447 }
5448
5449 #[test]
5452 fn dead_transition_missing_producer() {
5453 let r = analyse_src(
5454 "entity App {\n status: submitted | screening | approved | rejected\n \
5455 verified: Boolean\n \
5456 transitions status {\n submitted -> screening\n screening -> approved\n \
5457 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
5458 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
5459 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
5460 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
5461 requires:\n app.status = screening\n app.verified = true\n \
5462 ensures: app.status = approved\n}\n\n\
5463 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
5464 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
5465 );
5466 assert!(has_finding(&r, "dead_transition"));
5467 assert!(has_finding(&r, "missing_producer"));
5468 }
5469
5470 #[test]
5471 fn satisfied_requires_no_dead_transition() {
5472 let r = analyse_src(
5473 "entity App {\n status: submitted | screening | approved | rejected\n \
5474 verified: Boolean\n \
5475 transitions status {\n submitted -> screening\n screening -> approved\n \
5476 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
5477 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
5478 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
5479 rule Verify {\n when: SystemVerifies(app, result)\n \
5480 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
5481 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
5482 requires:\n app.status = screening\n app.verified = true\n \
5483 ensures: app.status = approved\n}\n\n\
5484 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
5485 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
5486 );
5487 assert!(!has_finding(&r, "dead_transition"));
5488 assert!(!has_finding(&r, "missing_producer"));
5489 }
5490
5491 #[test]
5492 fn deadlock_detected() {
5493 let r = analyse_src(
5494 "entity Doc {\n status: submitted | review | approved | rejected\n \
5495 reviewer_assigned: Boolean\n \
5496 transitions status {\n submitted -> review\n review -> approved\n \
5497 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
5498 rule Submit {\n when: AuthorSubmits(author, doc)\n \
5499 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
5500 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
5501 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
5502 ensures: doc.status = approved\n}\n\n\
5503 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
5504 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
5505 ensures: doc.status = rejected\n}\n",
5506 );
5507 assert!(has_finding(&r, "deadlock"));
5508 }
5509
5510 #[test]
5511 fn no_deadlock_when_paths_open() {
5512 let r = analyse_src(
5513 "entity Invoice {\n status: draft | sent | paid | void\n \
5514 transitions status {\n draft -> sent\n draft -> void\n \
5515 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
5516 rule Send {\n when: AccountantSends(accountant, invoice)\n \
5517 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
5518 rule Pay {\n when: PaymentReceived(invoice)\n \
5519 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
5520 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
5521 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
5522 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
5523 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
5524 );
5525 assert!(!has_finding(&r, "deadlock"));
5526 }
5527
5528 #[test]
5531 fn conflict_temporal_vs_external() {
5532 let r = analyse_src(
5533 "entity Membership {\n status: active | expired | extended\n \
5534 expires_at: Timestamp\n \
5535 transitions status {\n active -> expired\n active -> extended\n \
5536 terminal: expired, extended\n }\n}\n\n\
5537 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
5538 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
5539 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
5540 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
5541 );
5542 assert!(has_finding(&r, "conflict"));
5543 }
5544
5545 #[test]
5546 fn no_conflict_actor_choice() {
5547 let r = analyse_src(
5548 "entity LeaveRequest {\n status: pending | approved | denied\n \
5549 transitions status {\n pending -> approved\n pending -> denied\n \
5550 terminal: approved, denied\n }\n}\n\n\
5551 rule Approve {\n when: ManagerApproves(manager, request)\n \
5552 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
5553 rule Deny {\n when: ManagerDenies(manager, request)\n \
5554 requires: request.status = pending\n ensures: request.status = denied\n}\n",
5555 );
5556 assert!(!has_finding(&r, "conflict"));
5557 }
5558
5559 #[test]
5562 fn invariant_violation_detected() {
5563 let r = analyse_src(
5564 "entity JobRole {\n status: open | filled\n \
5565 candidacies: Candidacy with role = this\n \
5566 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
5567 entity Candidacy {\n status: active | hired | rejected\n \
5568 role: JobRole\n \
5569 transitions status {\n active -> hired\n active -> rejected\n \
5570 terminal: hired, rejected\n }\n}\n\n\
5571 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
5572 requires: candidacy.status = active\n \
5573 ensures: candidacy.status = hired\n}\n\n\
5574 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
5575 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
5576 );
5577 assert!(has_finding(&r, "invariant_risk"));
5578 }
5579
5580 #[test]
5581 fn invariant_guarded_no_violation() {
5582 let r = analyse_src(
5583 "entity JobRole {\n status: open | filled\n \
5584 candidacies: Candidacy with role = this\n \
5585 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
5586 entity Candidacy {\n status: active | hired | rejected\n \
5587 role: JobRole\n \
5588 transitions status {\n active -> hired\n active -> rejected\n \
5589 terminal: hired, rejected\n }\n}\n\n\
5590 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
5591 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
5592 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
5593 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
5594 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
5595 );
5596 assert!(!has_finding(&r, "invariant_risk"));
5597 }
5598
5599 #[test]
5602 fn external_entity_referenced_in_rules_info() {
5603 let ds = analyze_src(
5604 "external entity Client {\n id: String\n}\n\n\
5605 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
5606 );
5607 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
5608 assert!(hint.is_some());
5609 assert_eq!(hint.unwrap().severity, Severity::Info);
5610 }
5611
5612 #[test]
5615 fn undefined_type_reference() {
5616 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
5617 assert!(has_code(&ds, "allium.type.undefinedReference"));
5618 }
5619
5620 #[test]
5621 fn known_type_reference_ok() {
5622 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
5623 assert!(!has_code(&ds, "allium.type.undefinedReference"));
5624 }
5625
5626 #[test]
5629 fn unreachable_trigger_reported() {
5630 let ds = analyze_src(
5631 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
5632 );
5633 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
5634 }
5635
5636 fn analyze_with_imports(
5639 src: &str,
5640 imports: &[(&str, &[&str])],
5641 ) -> Vec<Diagnostic> {
5642 let input = format!("-- allium: 3\n{src}");
5643 let result = parse(&input);
5644 let imported: HashMap<String, HashSet<String>> = imports
5645 .iter()
5646 .map(|(alias, triggers)| {
5647 (
5648 alias.to_string(),
5649 triggers.iter().map(|t| t.to_string()).collect(),
5650 )
5651 })
5652 .collect();
5653 analyze_with_cross_module(
5654 &result.module,
5655 &input,
5656 &HashSet::new(),
5657 &HashSet::new(),
5658 &imported,
5659 &AmbiguousImports::default(),
5660 )
5661 }
5662
5663 fn analyze_with_ambiguous(
5669 src: &str,
5670 names: &[(&str, &[&str])],
5671 triggers: &[(&str, &[&str])],
5672 ) -> Vec<Diagnostic> {
5673 let input = format!("-- allium: 3\n{src}");
5674 let result = parse(&input);
5675 let to_map = |entries: &[(&str, &[&str])]| -> HashMap<String, Vec<String>> {
5676 entries
5677 .iter()
5678 .map(|(name, aliases)| {
5679 (
5680 name.to_string(),
5681 aliases.iter().map(|a| a.to_string()).collect(),
5682 )
5683 })
5684 .collect()
5685 };
5686 let ambiguous = AmbiguousImports {
5687 names: to_map(names),
5688 triggers: to_map(triggers),
5689 };
5690 let mut imported: HashMap<String, HashSet<String>> = HashMap::new();
5691 for (trigger, aliases) in triggers {
5692 for alias in *aliases {
5693 imported
5694 .entry(alias.to_string())
5695 .or_default()
5696 .insert(trigger.to_string());
5697 }
5698 }
5699 analyze_with_cross_module(
5700 &result.module,
5701 &input,
5702 &HashSet::new(),
5703 &HashSet::new(),
5704 &imported,
5705 &ambiguous,
5706 )
5707 }
5708
5709 #[test]
5710 fn qualified_trigger_suppressed_in_single_file_mode() {
5711 let ds = analyze_src(
5714 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5715 );
5716 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
5717 }
5718
5719 #[test]
5720 fn qualified_trigger_reachable_via_imported_module() {
5721 let ds = analyze_with_imports(
5722 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5723 &[("emitter", &["Pinged"])],
5724 );
5725 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
5726 }
5727
5728 #[test]
5729 fn qualified_trigger_unreachable_when_imported_module_lacks_it() {
5730 let ds = analyze_with_imports(
5731 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5732 &[("emitter", &["SomethingElse"])],
5733 );
5734 let flagged: Vec<&Diagnostic> = ds
5735 .iter()
5736 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
5737 .collect();
5738 assert_eq!(flagged.len(), 1);
5739 assert!(flagged[0].message.contains("'emitter/Pinged'"));
5740 assert!(flagged[0].message.contains("imported module 'emitter'"));
5741 }
5742
5743 #[test]
5744 fn qualified_trigger_suppressed_for_alias_outside_check_set() {
5745 let ds = analyze_with_imports(
5748 "use \"github.com/allium-specs/oauth/abc\" as oauth\n\nrule Audit {\n when: oauth/SessionCreated(session)\n ensures: Logged(session: session)\n}\n",
5749 &[],
5750 );
5751 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
5752 }
5753
5754 #[test]
5755 fn unqualified_trigger_reachable_via_imported_module() {
5756 let ds = analyze_with_imports(
5757 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5758 &[("emitter", &["Pinged"])],
5759 );
5760 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
5761 }
5762
5763 #[test]
5764 fn unqualified_trigger_still_flagged_when_no_import_emits_it() {
5765 let ds = analyze_with_imports(
5766 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5767 &[("emitter", &["SomethingElse"])],
5768 );
5769 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
5770 }
5771
5772 #[test]
5775 fn ambiguous_trigger_subscription_warns() {
5776 let ds = analyze_with_ambiguous(
5777 "use \"./a.allium\" as a\nuse \"./b.allium\" as b\n\nrule HandlePing {\n when: Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
5778 &[],
5779 &[("Pinged", &["a", "b"])],
5780 );
5781 let diag = ds
5782 .iter()
5783 .find(|d| d.code == Some("allium.use.ambiguousReference"))
5784 .expect("ambiguous trigger subscription should warn");
5785 assert!(diag.message.contains("'a' and 'b'"), "message: {}", diag.message);
5786 assert!(diag.message.contains("a/Pinged"), "message: {}", diag.message);
5787 assert!(!has_code(&ds, "allium.rule.unreachableTrigger"));
5789 }
5790
5791 #[test]
5792 fn ambiguous_trigger_not_flagged_when_emitted_locally() {
5793 let ds = analyze_with_ambiguous(
5795 "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",
5796 &[],
5797 &[("Pinged", &["a", "b"])],
5798 );
5799 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
5800 }
5801
5802 #[test]
5803 fn qualified_trigger_subscription_not_flagged_as_ambiguous() {
5804 let ds = analyze_with_ambiguous(
5805 "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",
5806 &[],
5807 &[("Pinged", &["a", "b"])],
5808 );
5809 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
5810 }
5811
5812 #[test]
5813 fn ambiguous_name_reference_warns() {
5814 let ds = analyze_with_ambiguous(
5815 "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",
5816 &[("Invoice", &["billing", "orders"])],
5817 &[],
5818 );
5819 let diag = ds
5820 .iter()
5821 .find(|d| d.code == Some("allium.use.ambiguousReference"))
5822 .expect("ambiguous unqualified name should warn");
5823 assert!(
5824 diag.message.contains("'billing' and 'orders'"),
5825 "message: {}",
5826 diag.message
5827 );
5828 assert!(diag.message.contains("billing/Invoice"), "message: {}", diag.message);
5829 }
5830
5831 #[test]
5832 fn ambiguous_name_shadowed_by_local_declaration() {
5833 let ds = analyze_with_ambiguous(
5834 "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",
5835 &[("Invoice", &["billing", "orders"])],
5836 &[],
5837 );
5838 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
5839 }
5840
5841 #[test]
5842 fn ambiguous_name_flagged_once_per_name() {
5843 let ds = analyze_with_ambiguous(
5844 "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",
5845 &[("Invoice", &["billing", "orders"])],
5846 &[],
5847 );
5848 let count = ds
5849 .iter()
5850 .filter(|d| d.code == Some("allium.use.ambiguousReference"))
5851 .count();
5852 assert_eq!(count, 1, "expected a single warning per ambiguous name");
5853 }
5854
5855 #[test]
5856 fn no_ambiguity_warnings_in_single_file_mode() {
5857 let ds = analyze_src(
5858 "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",
5859 );
5860 assert!(!has_code(&ds, "allium.use.ambiguousReference"));
5861 }
5862
5863 #[test]
5864 fn conditional_ensures_emission_registers() {
5865 let ds = analyze_src(
5868 "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",
5869 );
5870 let unreachable: Vec<&Diagnostic> = ds
5871 .iter()
5872 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
5873 .collect();
5874 assert_eq!(unreachable.len(), 1);
5877 assert!(unreachable[0].message.contains("'AdvertReceived'"));
5878 }
5879
5880 #[test]
5881 fn for_body_ensures_emission_registers() {
5882 let ds = analyze_src(
5883 "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",
5884 );
5885 let unreachable: Vec<&Diagnostic> = ds
5886 .iter()
5887 .filter(|d| d.code == Some("allium.rule.unreachableTrigger"))
5888 .collect();
5889 assert_eq!(unreachable.len(), 1);
5890 assert!(unreachable[0].message.contains("'Broadcast'"));
5891 }
5892
5893 #[test]
5894 fn collect_trigger_outputs_includes_provides_ensures_and_branches() {
5895 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";
5896 let result = parse(input);
5897 let outputs = collect_trigger_outputs(&result.module);
5898 assert!(outputs.contains("Submit"));
5899 assert!(outputs.contains("Accepted"));
5900 assert!(outputs.contains("Rejected"));
5901 }
5902
5903 #[test]
5906 fn unused_field_reported() {
5907 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");
5908 assert!(has_code(&ds, "allium.field.unused"));
5909 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
5911 assert!(unused.iter().any(|d| d.message.contains("A.y")));
5912 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
5913 }
5914
5915 #[test]
5918 fn unused_entity_reported() {
5919 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
5920 assert!(has_code(&ds, "allium.entity.unused"));
5921 }
5922
5923 #[test]
5924 fn external_ref_suppresses_unused_entity() {
5925 let src = "entity InputEvent {\n payload: String\n}\n";
5926 let input = format!("-- allium: 3\n{src}");
5927 let result = parse(&input);
5928 let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
5929 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5930 assert!(!has_code(&ds, "allium.entity.unused"));
5931 }
5932
5933 #[test]
5934 fn external_ref_suppresses_unused_definition() {
5935 let src = "value Snapshot {\n version: Integer\n}\n";
5936 let input = format!("-- allium: 3\n{src}");
5937 let result = parse(&input);
5938 let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
5939 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5940 assert!(!has_code(&ds, "allium.definition.unused"));
5941 }
5942
5943 #[test]
5944 fn unreferenced_entity_still_warns_without_external_ref() {
5945 let src = "entity InputEvent {\n payload: String\n}\n";
5946 let input = format!("-- allium: 3\n{src}");
5947 let result = parse(&input);
5948 let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
5949 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5950 assert!(has_code(&ds, "allium.entity.unused"));
5951 }
5952
5953 #[test]
5954 fn collect_qualified_refs_from_rule_clause() {
5955 let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
5956 let input = format!("-- allium: 3\n{src}");
5957 let result = parse(&input);
5958 let refs = collect_qualified_references(&result.module);
5959 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
5960 }
5961
5962 #[test]
5963 fn collect_qualified_refs_from_field_type() {
5964 let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
5965 let input = format!("-- allium: 3\n{src}");
5966 let result = parse(&input);
5967 let refs = collect_qualified_references(&result.module);
5968 assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
5969 }
5970
5971 #[test]
5972 fn collect_qualified_refs_from_requires() {
5973 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";
5974 let input = format!("-- allium: 3\n{src}");
5975 let result = parse(&input);
5976 let refs = collect_qualified_references(&result.module);
5977 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
5978 }
5979
5980 #[test]
5981 fn collect_qualified_refs_from_for_block() {
5982 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";
5983 let input = format!("-- allium: 3\n{src}");
5984 let result = parse(&input);
5985 let refs = collect_qualified_references(&result.module);
5986 assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
5987 }
5988
5989 #[test]
5990 fn collect_qualified_refs_from_member_access() {
5991 let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
5992 let input = format!("-- allium: 3\n{src}");
5993 let result = parse(&input);
5994 let refs = collect_qualified_references(&result.module);
5995 assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
5996 }
5997
5998 #[test]
5999 fn collect_qualified_refs_multiple_from_same_module() {
6000 let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
6001 let input = format!("-- allium: 3\n{src}");
6002 let result = parse(&input);
6003 let refs = collect_qualified_references(&result.module);
6004 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
6005 assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
6006 }
6007
6008 #[test]
6009 fn collect_qualified_refs_multiple_modules() {
6010 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";
6011 let input = format!("-- allium: 3\n{src}");
6012 let result = parse(&input);
6013 let refs = collect_qualified_references(&result.module);
6014 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
6015 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
6016 }
6017
6018 #[test]
6019 fn collect_qualified_refs_empty_when_none() {
6020 let src = "entity Order {\n total: Decimal\n}\n";
6021 let input = format!("-- allium: 3\n{src}");
6022 let result = parse(&input);
6023 let refs = collect_qualified_references(&result.module);
6024 assert!(refs.is_empty());
6025 }
6026
6027 #[test]
6028 fn collect_qualified_refs_from_ensures() {
6029 let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
6030 let input = format!("-- allium: 3\n{src}");
6031 let result = parse(&input);
6032 let refs = collect_qualified_references(&result.module);
6033 assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
6034 }
6035
6036 #[test]
6037 fn collect_qualified_refs_from_invariant() {
6038 let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
6039 let input = format!("-- allium: 3\n{src}");
6040 let result = parse(&input);
6041 let refs = collect_qualified_references(&result.module);
6042 assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
6043 }
6044
6045 #[test]
6046 fn collect_qualified_refs_from_deferred() {
6047 let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
6048 let input = format!("-- allium: 3\n{src}");
6049 let result = parse(&input);
6050 let refs = collect_qualified_references(&result.module);
6051 assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
6052 }
6053
6054 #[test]
6055 fn collect_qualified_refs_from_alias_dot_member() {
6056 let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
6057 let input = format!("-- allium: 3\n{src}");
6058 let result = parse(&input);
6059 let refs = collect_qualified_references(&result.module);
6060 assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
6061 }
6062
6063 #[test]
6064 fn collect_all_idents_includes_unqualified_entity_ref() {
6065 let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
6066 let input = format!("-- allium: 3\n{src}");
6067 let result = parse(&input);
6068 let idents = collect_all_referenced_idents(&result.module);
6069 assert!(idents.contains("InputPartition"));
6070 }
6071
6072 #[test]
6073 fn collect_declared_names_returns_entity_and_value_names() {
6074 let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
6075 let input = format!("-- allium: 3\n{src}");
6076 let result = parse(&input);
6077 let names = collect_declared_names(&result.module);
6078 assert!(names.contains("Order"));
6079 assert!(names.contains("Money"));
6080 assert!(names.contains("Status"));
6081 }
6082
6083 #[test]
6084 fn external_ref_only_suppresses_matching_name() {
6085 let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
6087 let input = format!("-- allium: 3\n{src}");
6088 let result = parse(&input);
6089 let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
6090 let ds = analyze_with_external_refs(&result.module, &input, &refs);
6091 assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
6092 && d.message.contains("Used")));
6093 assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
6094 && d.message.contains("Orphan")));
6095 }
6096
6097 #[test]
6098 fn external_ref_suppresses_unused_external_entity() {
6099 let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
6100 let input = format!("-- allium: 3\n{src}");
6101 let result = parse(&input);
6102 let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
6103 let ds = analyze_with_external_refs(&result.module, &input, &refs);
6104 assert!(!has_code(&ds, "allium.entity.unused"));
6105 }
6106
6107 #[test]
6108 fn external_ref_suppresses_unused_enum() {
6109 let src = "enum Priority {\n low\n medium\n high\n}\n";
6110 let input = format!("-- allium: 3\n{src}");
6111 let result = parse(&input);
6112 let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
6113 let ds = analyze_with_external_refs(&result.module, &input, &refs);
6114 assert!(!has_code(&ds, "allium.definition.unused"));
6115 }
6116
6117 #[test]
6118 fn empty_external_refs_same_as_plain_analyze() {
6119 let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
6120 let input = format!("-- allium: 3\n{src}");
6121 let result = parse(&input);
6122 let plain = analyze(&result.module, &input);
6123 let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
6124 assert_eq!(plain.len(), with_empty.len());
6125 for (a, b) in plain.iter().zip(with_empty.iter()) {
6126 assert_eq!(a.code, b.code);
6127 assert_eq!(a.message, b.message);
6128 }
6129 }
6130
6131 #[test]
6134 fn resolved_use_path_no_warning() {
6135 let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
6136 let input = format!("-- allium: 3\n{src}");
6137 let result = parse(&input);
6138 let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
6139 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &AmbiguousImports::default());
6140 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
6141 }
6142
6143 #[test]
6144 fn unresolved_use_path_warns() {
6145 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
6146 let input = format!("-- allium: 3\n{src}");
6147 let result = parse(&input);
6148 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
6150 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &AmbiguousImports::default());
6151 assert!(has_code(&ds, "allium.use.unresolvedPath"));
6152 }
6153
6154 #[test]
6155 fn unresolved_use_path_skipped_in_single_file_mode() {
6156 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
6158 let input = format!("-- allium: 3\n{src}");
6159 let result = parse(&input);
6160 let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
6161 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
6162 }
6163
6164 #[test]
6165 fn unresolved_use_path_fires_with_empty_resolved_set() {
6166 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
6169 let input = format!("-- allium: 3\n{src}");
6170 let result = parse(&input);
6171 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new(), &HashMap::new(), &AmbiguousImports::default());
6172 assert!(has_code(&ds, "allium.use.unresolvedPath"));
6173 }
6174
6175 #[test]
6176 fn unresolved_use_path_message_includes_path() {
6177 let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
6178 let input = format!("-- allium: 3\n{src}");
6179 let result = parse(&input);
6180 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
6181 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &AmbiguousImports::default());
6182 let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
6183 assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
6184 }
6185
6186 #[test]
6187 fn unresolved_use_path_suppressible() {
6188 let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
6189 let input = format!("-- allium: 3\n{src}");
6190 let result = parse(&input);
6191 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
6192 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &AmbiguousImports::default());
6193 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
6194 }
6195
6196 #[test]
6197 fn multiple_use_paths_mixed_resolution() {
6198 let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
6199 let input = format!("-- allium: 3\n{src}");
6200 let result = parse(&input);
6201 let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
6202 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved, &HashMap::new(), &AmbiguousImports::default());
6203 let unresolved: Vec<_> = ds.iter()
6204 .filter(|d| d.code == Some("allium.use.unresolvedPath"))
6205 .collect();
6206 assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
6207 assert!(unresolved[0].message.contains("lost.allium"));
6208 }
6209
6210 #[test]
6213 fn deferred_missing_location_hint() {
6214 let ds = analyze_src("deferred Foo.bar\n");
6215 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
6216 }
6217
6218 #[test]
6219 fn deferred_with_quoted_path_hint_ok() {
6220 let ds = analyze_src("deferred Foo.bar \"detailed/foo.allium\"\n");
6221 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6222 }
6223
6224 #[test]
6225 fn deferred_with_see_comment_hint_ok() {
6226 let ds = analyze_src("deferred Foo.bar -- see: detailed/foo.allium\n");
6228 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6229 }
6230
6231 #[test]
6232 fn deferred_with_url_hint_ok() {
6233 let ds = analyze_src("deferred Foo.bar -- https://example.com/foo.allium\n");
6234 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6235 }
6236
6237 #[test]
6238 fn deferred_with_url_glued_to_path_warns() {
6239 assert!(has_code(
6243 &analyze_src("deferred Foohttps://x\n"),
6244 "allium.deferred.missingLocationHint"
6245 ));
6246 assert!(has_code(
6247 &analyze_src("deferred Foohttp://x\n"),
6248 "allium.deferred.missingLocationHint"
6249 ));
6250 }
6251
6252 #[test]
6253 fn deferred_with_non_hint_comment_warns() {
6254 let ds = analyze_src("deferred Foo.bar -- TODO write this\n");
6257 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
6258 }
6259
6260 #[test]
6261 fn deferred_expression_path_with_quote_suppresses() {
6262 let ds = analyze_src("deferred Foo(\"x\")\n");
6267 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6268 let ds = analyze_src("deferred Foo = \"x\"\n");
6269 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6270 }
6271
6272 #[test]
6273 fn deferred_trailing_dot_warns_with_captured_name() {
6274 let ds = analyze_src("deferred Dangling.\n");
6279 let hints: Vec<&Diagnostic> = ds
6280 .iter()
6281 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
6282 .collect();
6283 assert_eq!(hints.len(), 1);
6284 assert!(hints[0].message.contains("'Dangling.'"));
6285 }
6286
6287 #[test]
6288 fn deferred_lone_cr_is_a_line_boundary() {
6289 let ds = analyze_src("deferred Foo\rdeferred Bar\n");
6293 let hints: Vec<&Diagnostic> = ds
6294 .iter()
6295 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
6296 .collect();
6297 assert_eq!(hints.len(), 2, "both CR-separated declarations warn");
6298 let ds = analyze_src("deferred Foo\r-- see: x.allium\n");
6300 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
6301 }
6302
6303 #[test]
6304 fn deferred_unmatchable_path_stays_silent() {
6305 let ds = analyze_src("deferred (Foo)\n");
6310 assert!(!has_code(&ds, "allium.deferred.missingLocationHint"));
6311 }
6312
6313 #[test]
6314 fn deferred_qualified_path_warns_with_flat_name() {
6315 let ds = analyze_src("deferred billing/InvoiceWorkflow\n");
6318 let hints: Vec<&Diagnostic> = ds
6319 .iter()
6320 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
6321 .collect();
6322 assert_eq!(hints.len(), 1);
6323 assert!(hints[0].message.contains("'billing'"));
6324 }
6325
6326 #[test]
6327 fn deferred_location_hint_is_per_line() {
6328 let ds = analyze_src(
6332 "deferred A.one -- see: a.allium\ndeferred B.two\ndeferred C.three \"c.allium\"\n",
6333 );
6334 let hints: Vec<&Diagnostic> = ds
6335 .iter()
6336 .filter(|d| d.code == Some("allium.deferred.missingLocationHint"))
6337 .collect();
6338 assert_eq!(hints.len(), 1);
6339 assert!(hints[0].message.contains("B.two"));
6340 }
6341
6342 #[test]
6345 fn valid_trigger_ok() {
6346 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
6347 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
6348 }
6349
6350 #[test]
6351 fn qualified_trigger_call_is_valid() {
6352 let ds = analyze_src(
6356 "use \"./emitter.allium\" as emitter\n\nrule HandlePing {\n when: emitter/Pinged(subject)\n ensures: PingHandled(subject: subject)\n}\n",
6357 );
6358 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
6359 }
6360
6361 #[test]
6364 fn duplicate_let_binding() {
6365 let ds = analyze_src(
6366 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
6367 );
6368 assert!(has_code(&ds, "allium.let.duplicateBinding"));
6369 }
6370
6371 #[test]
6374 fn config_undefined_reference() {
6375 let ds = analyze_src(
6376 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
6377 );
6378 assert!(has_code(&ds, "allium.config.undefinedReference"));
6379 }
6380
6381 #[test]
6382 fn config_valid_reference_ok() {
6383 let ds = analyze_src(
6384 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
6385 );
6386 assert!(!has_code(&ds, "allium.config.undefinedReference"));
6387 }
6388}