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