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 mut ctx = Ctx::new(module);
19
20 ctx.check_related_surface_references();
21 ctx.check_discriminator_variants();
22 ctx.check_surface_binding_usage();
23 ctx.check_status_state_machine();
24 ctx.check_external_entity_source_hints();
25 ctx.check_type_references();
26 ctx.check_unreachable_triggers();
27 ctx.check_unused_fields();
28 ctx.check_unused_entities();
29 ctx.check_unused_definitions();
30 ctx.check_deferred_location_hints();
31 ctx.check_rule_invalid_triggers();
32 ctx.check_rule_undefined_bindings();
33 ctx.check_duplicate_let_bindings();
34 ctx.check_config_undefined_references();
35
36 apply_suppressions(ctx.diagnostics, source)
37}
38
39pub fn analyse(module: &Module, source: &str) -> crate::diagnostic::AnalyseResult {
42 let diagnostics = analyze(module, source);
43 let findings = find_process_issues(module);
44 crate::diagnostic::AnalyseResult {
45 diagnostics,
46 findings,
47 }
48}
49
50struct EntityInfo<'a> {
52 status_values: HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
54 field_types: HashMap<&'a str, HashMap<&'a str, &'a str>>,
56 graph_edges: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
58 terminals: HashMap<&'a str, HashSet<&'a str>>,
60}
61
62impl<'a> EntityInfo<'a> {
63 fn from_module(module: &'a Module) -> Self {
64 let mut status_values: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = HashMap::new();
65 let mut field_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
66 let mut graph_edges: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
67 let mut terminals: HashMap<&str, HashSet<&str>> = HashMap::new();
68
69 let entities = module.declarations.iter().filter_map(|d| match d {
70 Decl::Block(b) if b.kind == BlockKind::Entity => Some(b),
71 _ => None,
72 });
73 for entity in entities {
74 let name = match &entity.name {
75 Some(n) => n.name.as_str(),
76 None => continue,
77 };
78 for item in &entity.items {
79 match &item.kind {
80 BlockItemKind::Assignment { name: f, value } if f.name == "status" => {
81 let mut idents = Vec::new();
82 collect_pipe_idents(value, &mut idents);
83 if idents.len() >= 2
84 && !idents.iter().any(|id| starts_uppercase(&id.name))
85 {
86 let set: HashSet<&str> =
87 idents.iter().map(|id| id.name.as_str()).collect();
88 status_values.insert(name, (set, idents));
89 }
90 }
91 BlockItemKind::Assignment { name: f, value } => {
92 if let Some(t) = extract_field_entity_type(value) {
93 field_types.entry(name).or_default().insert(f.name.as_str(), t);
94 }
95 }
96 BlockItemKind::TransitionsBlock(graph) => {
97 let edges: Vec<(&str, &str)> = graph
98 .edges
99 .iter()
100 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
101 .collect();
102 graph_edges.insert(name, edges);
103 let terms: HashSet<&str> =
104 graph.terminal.iter().map(|t| t.name.as_str()).collect();
105 if !terms.is_empty() {
106 terminals.insert(name, terms);
107 }
108 }
109 _ => {}
110 }
111 }
112 }
113
114 Self { status_values, field_types, graph_edges, terminals }
115 }
116
117 fn status_by_entity(&self) -> HashMap<&'a str, HashSet<&'a str>> {
119 self.status_values
120 .iter()
121 .map(|(k, (set, _))| (*k, set.clone()))
122 .collect()
123 }
124}
125
126fn find_process_issues(module: &Module) -> Vec<crate::diagnostic::Finding> {
128 let mut ctx = Ctx::new(module);
129 let info = EntityInfo::from_module(module);
130 ctx.collect_process_findings(&info);
131 ctx.collect_conflict_findings(&info);
132 ctx.collect_invariant_findings(&info);
133 std::mem::take(&mut ctx.findings)
134}
135
136fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
141 if diagnostics.is_empty() {
142 return diagnostics;
143 }
144 let sm = SourceMap::new(source);
145 let directives = collect_suppression_directives(source, &sm);
146 if directives.is_empty() {
147 return diagnostics;
148 }
149 diagnostics
150 .into_iter()
151 .filter(|d| {
152 let (line, _) = sm.line_col(d.span.start);
153 let line = line as i64;
154 let active = directives
155 .get(&(line as u32))
156 .or_else(|| directives.get(&((line - 1).max(0) as u32)));
157 match (active, d.code) {
158 (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
159 (Some(codes), None) => !codes.contains("all"),
160 _ => true,
161 }
162 })
163 .collect()
164}
165
166fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
167 let mut directives = HashMap::new();
168 let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
169 for m in pattern.find_iter(source) {
170 let text = m.as_str();
171 let (line, _) = sm.line_col(m.start());
172 if let Some(idx) = text.find("allium-ignore") {
174 let offset = m.start() + idx + "allium-ignore".len();
175 let source_after = &source[offset..m.end()];
176 let codes: HashSet<&'a str> = source_after
177 .split(',')
178 .map(|c| c.trim())
179 .filter(|c| !c.is_empty())
180 .collect();
181 directives.insert(line, codes);
182 }
183 }
184 directives
185}
186
187struct Ctx<'a> {
192 module: &'a Module,
193 diagnostics: Vec<Diagnostic>,
194 findings: Vec<crate::diagnostic::Finding>,
195}
196
197impl<'a> Ctx<'a> {
198 fn new(module: &'a Module) -> Self {
199 Self {
200 module,
201 diagnostics: Vec::new(),
202 findings: Vec::new(),
203 }
204 }
205
206 fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
207 self.module.declarations.iter().filter_map(move |d| match d {
208 Decl::Block(b) if b.kind == kind => Some(b),
209 _ => None,
210 })
211 }
212
213 fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
214 self.module
215 .declarations
216 .iter()
217 .filter_map(|d| match d {
218 Decl::Variant(v) => Some(v),
219 _ => None,
220 })
221 }
222
223 fn has_use_imports(&self) -> bool {
224 self.module
225 .declarations
226 .iter()
227 .any(|d| matches!(d, Decl::Use(_)))
228 }
229
230 fn push(&mut self, d: Diagnostic) {
231 self.diagnostics.push(d);
232 }
233
234 fn push_finding(&mut self, finding: Finding) {
235 self.findings.push(finding);
236 }
237
238 fn declared_type_names(&self) -> HashSet<&'a str> {
241 let mut names = HashSet::new();
242 for d in &self.module.declarations {
243 match d {
244 Decl::Block(b) => {
245 if matches!(
246 b.kind,
247 BlockKind::Entity
248 | BlockKind::ExternalEntity
249 | BlockKind::Value
250 | BlockKind::Enum
251 | BlockKind::Actor
252 ) {
253 if let Some(n) = &b.name {
254 names.insert(n.name.as_str());
255 }
256 }
257 }
258 Decl::Variant(v) => {
259 names.insert(v.name.name.as_str());
260 }
261 _ => {}
262 }
263 }
264 for t in &[
266 "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
267 "List", "Set", "Map", "Any", "Void",
268 ] {
269 names.insert(t);
270 }
271 for d in &self.module.declarations {
273 if let Decl::Use(u) = d {
274 if let Some(alias) = &u.alias {
275 names.insert(alias.name.as_str());
276 }
277 }
278 }
279 names
280 }
281
282 fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
284 let mut names = HashSet::new();
285 for d in &self.module.declarations {
286 match d {
287 Decl::Block(b) => {
288 for item in &b.items {
289 collect_accessed_fields_from_item(&item.kind, &mut names);
290 }
291 }
292 Decl::Invariant(inv) => {
293 collect_accessed_fields_from_expr(&inv.body, &mut names);
294 }
295 _ => {}
296 }
297 }
298 names
299 }
300}
301
302impl Ctx<'_> {
307 fn check_related_surface_references(&mut self) {
308 let surface_names: HashSet<&str> = self
309 .blocks(BlockKind::Surface)
310 .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
311 .collect();
312
313 for surface in self.blocks(BlockKind::Surface) {
314 let surface_name = match &surface.name {
315 Some(n) => &n.name,
316 None => continue,
317 };
318
319 for item in &surface.items {
320 let BlockItemKind::Clause { keyword, value } = &item.kind else {
321 continue;
322 };
323 if keyword != "related" {
324 continue;
325 }
326
327 let refs = extract_related_surface_names(value);
328 for ident in refs {
329 if !surface_names.contains(ident.name.as_str()) {
330 self.push(
331 Diagnostic::error(
332 ident.span,
333 format!(
334 "Surface '{surface_name}' references unknown related surface '{}'.",
335 ident.name
336 ),
337 )
338 .with_code("allium.surface.relatedUndefined"),
339 );
340 }
341 }
342 }
343 }
344 }
345}
346
347fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
348 match expr {
349 Expr::Ident(id) => vec![id],
350 Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
351 Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
352 Expr::Block { items, .. } => items
353 .iter()
354 .flat_map(extract_related_surface_names)
355 .collect(),
356 _ => vec![],
357 }
358}
359
360fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
361 match expr {
362 Expr::Ident(id) => Some(id),
363 Expr::MemberAccess { object, .. } => extract_leading_ident(object),
364 _ => None,
365 }
366}
367
368impl Ctx<'_> {
373 fn check_discriminator_variants(&mut self) {
374 let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
375 for v in self.variants() {
376 let base_name = expr_as_ident(&v.base).or_else(|| {
377 if let Expr::JoinLookup { entity, .. } = &v.base {
379 expr_as_ident(entity)
380 } else {
381 None
382 }
383 });
384 if let Some(base_name) = base_name {
385 variants_by_base
386 .entry(base_name)
387 .or_default()
388 .insert(&v.name.name);
389 }
390 }
391
392 for entity in self.blocks(BlockKind::Entity) {
393 let entity_name = match &entity.name {
394 Some(n) => &n.name,
395 None => continue,
396 };
397
398 for item in &entity.items {
399 let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
400 continue;
401 };
402
403 let mut pipe_idents = Vec::new();
404 collect_pipe_idents(value, &mut pipe_idents);
405 if pipe_idents.len() < 2 {
406 continue;
407 }
408
409 let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
410 if !has_capitalised {
411 continue;
412 }
413
414 let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
415 if !all_capitalised {
416 self.push(
417 Diagnostic::error(
418 value.span(),
419 format!(
420 "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
421 field_name.name
422 ),
423 )
424 .with_code("allium.sum.invalidDiscriminator"),
425 );
426 continue;
427 }
428
429 let declared = variants_by_base
430 .get(entity_name.as_str())
431 .cloned()
432 .unwrap_or_default();
433
434 let missing: Vec<&&Ident> = pipe_idents
435 .iter()
436 .filter(|id| !declared.contains(id.name.as_str()))
437 .collect();
438
439 if missing.len() == pipe_idents.len() && declared.is_empty() {
440 self.push(
441 Diagnostic::error(
442 value.span(),
443 format!(
444 "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
445 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
446 declarations. Use lowercase values for a plain enum.",
447 field_name.name
448 ),
449 )
450 .with_code("allium.sum.v1InlineEnum"),
451 );
452 } else {
453 for id in missing {
454 self.push(
455 Diagnostic::error(
456 id.span,
457 format!(
458 "Entity '{entity_name}' discriminator references '{}' without matching \
459 'variant {} : {entity_name}'.",
460 id.name, id.name
461 ),
462 )
463 .with_code("allium.sum.discriminatorUnknownVariant"),
464 );
465 }
466 }
467 }
468 }
469 }
470}
471
472fn starts_uppercase(s: &str) -> bool {
473 s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
474}
475
476fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
477 match expr {
478 Expr::Ident(id) => out.push(id),
479 Expr::Pipe { left, right, .. } => {
480 collect_pipe_idents(left, out);
481 collect_pipe_idents(right, out);
482 }
483 _ => {}
484 }
485}
486
487fn expr_as_ident(expr: &Expr) -> Option<&str> {
488 match expr {
489 Expr::Ident(id) => Some(&id.name),
490 _ => None,
491 }
492}
493
494impl Ctx<'_> {
499 fn check_surface_binding_usage(&mut self) {
500 for surface in self.blocks(BlockKind::Surface) {
501 let surface_name = match &surface.name {
502 Some(n) => &n.name,
503 None => continue,
504 };
505
506 let has_provides = surface
508 .items
509 .iter()
510 .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
511
512 let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); for item in &surface.items {
514 let BlockItemKind::Clause { keyword, value } = &item.kind else {
515 continue;
516 };
517 if keyword != "facing" && keyword != "context" {
518 continue;
519 }
520 if let Expr::Binding { name, .. } = value {
521 bindings.push((&name.name, name.span, keyword == "facing"));
522 }
523 }
524
525 for (name, span, is_facing) in &bindings {
526 if *name == "_" {
527 continue;
528 }
529 if *is_facing && !has_provides {
531 continue;
532 }
533 let used = surface.items.iter().any(|item| {
534 let BlockItemKind::Clause { keyword, value } = &item.kind else {
535 return item_contains_ident(&item.kind, name);
536 };
537 if keyword == "facing" || keyword == "context" {
538 if let Expr::Binding {
539 name: binding_name, ..
540 } = value
541 {
542 if binding_name.name == *name {
543 return false;
544 }
545 }
546 }
547 expr_contains_ident(value, name)
548 });
549
550 if !used {
551 self.push(
552 Diagnostic::warning(
553 *span,
554 format!(
555 "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
556 ),
557 )
558 .with_code("allium.surface.unusedBinding"),
559 );
560 }
561 }
562 }
563 }
564}
565
566impl Ctx<'_> {
571 fn check_status_state_machine(&mut self) {
572 let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
573 let mut terminal_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
574 let mut has_transitions: HashSet<&str> = HashSet::new();
575 let mut declared_edges: HashMap<&str, HashSet<(&str, &str)>> = HashMap::new();
576 let mut field_entity_types: HashMap<&str, HashMap<&str, &str>> = HashMap::new();
577 for entity in self.blocks(BlockKind::Entity) {
578 let entity_name = match &entity.name {
579 Some(n) => n.name.as_str(),
580 None => continue,
581 };
582 for item in &entity.items {
583 match &item.kind {
584 BlockItemKind::Assignment { name, value } if name.name == "status" => {
585 let mut idents = Vec::new();
586 collect_pipe_idents(value, &mut idents);
587 if idents.len() < 2 {
588 continue;
589 }
590 if idents.iter().any(|id| starts_uppercase(&id.name)) {
591 continue;
592 }
593 let set: HashSet<&str> =
594 idents.iter().map(|id| id.name.as_str()).collect();
595 status_by_entity.insert(entity_name, (idents, set));
596 }
597 BlockItemKind::Assignment { name, value } => {
598 if let Some(type_name) = extract_field_entity_type(value) {
600 field_entity_types
601 .entry(entity_name)
602 .or_default()
603 .insert(name.name.as_str(), type_name);
604 }
605 }
606 BlockItemKind::TransitionsBlock(graph) => {
607 has_transitions.insert(entity_name);
608 let terminals: HashSet<&str> =
609 graph.terminal.iter().map(|t| t.name.as_str()).collect();
610 if !terminals.is_empty() {
611 terminal_by_entity.insert(entity_name, terminals);
612 }
613 let edges: HashSet<(&str, &str)> = graph
614 .edges
615 .iter()
616 .map(|e| (e.from.name.as_str(), e.to.name.as_str()))
617 .collect();
618 declared_edges.insert(entity_name, edges);
619 }
620 _ => {}
621 }
622 }
623 }
624 for fields in field_entity_types.values_mut() {
626 fields.retain(|_, type_name| status_by_entity.contains_key(type_name));
627 }
628
629 if status_by_entity.is_empty() {
630 return;
631 }
632
633 let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
634 let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
635 HashMap::new();
636 let mut created_issues: Vec<Diagnostic> = Vec::new();
637
638 for rule in self.blocks(BlockKind::Rule) {
639 let binding_types = collect_rule_binding_types(rule, &status_by_entity);
640 let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
641
642 for item in &rule.items {
643 let BlockItemKind::Clause { keyword, value } = &item.kind else {
644 continue;
645 };
646 if keyword != "requires" {
647 continue;
648 }
649 visit_status_comparisons(
650 value,
651 &binding_types,
652 &status_by_entity,
653 &field_entity_types,
654 &mut |binding, status| {
655 requires_by_binding
656 .entry(binding)
657 .or_default()
658 .insert(status);
659 },
660 );
661 }
662
663 for item in &rule.items {
664 let BlockItemKind::Clause { keyword, value } = &item.kind else {
665 continue;
666 };
667 if keyword != "ensures" {
668 continue;
669 }
670 visit_status_assignments(
671 value,
672 &binding_types,
673 &status_by_entity,
674 &field_entity_types,
675 &mut |binding, target, entity| {
676 assigned_by_entity
677 .entry(entity)
678 .or_default()
679 .insert(target);
680
681 if let Some(sources) = requires_by_binding.get(binding) {
682 let entity_transitions =
683 transitions_by_entity.entry(entity).or_default();
684 for source in sources {
685 entity_transitions
686 .entry(source)
687 .or_default()
688 .insert(target);
689 }
690 }
691 },
692 );
693 visit_created_calls(
694 value,
695 &status_by_entity,
696 &has_transitions,
697 &mut |entity, status| {
698 assigned_by_entity
699 .entry(entity)
700 .or_default()
701 .insert(status);
702 },
703 &mut created_issues,
704 );
705 }
706 }
707
708 for (entity_name, (idents, values)) in &status_by_entity {
709 let assigned = assigned_by_entity.get(entity_name);
710 let transitions = transitions_by_entity.get(entity_name);
711
712 if let Some(assigned) = assigned {
713 if assigned.iter().any(|v| !values.contains(v)) {
714 continue;
715 }
716 }
717
718 let assigned_set = assigned.cloned().unwrap_or_default();
719 let transition_map = transitions.cloned().unwrap_or_default();
720
721 for id in idents {
722 if !assigned_set.contains(id.name.as_str()) {
723 self.push(
724 Diagnostic::warning(
725 id.span,
726 format!(
727 "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
728 id.name
729 ),
730 )
731 .with_code("allium.status.unreachableValue"),
732 );
733 }
734
735 let is_terminal = terminal_by_entity
736 .get(entity_name)
737 .map_or_else(
738 || is_likely_terminal(&id.name),
739 |terminals| terminals.contains(id.name.as_str()),
740 );
741 if is_terminal {
742 continue;
743 }
744 let exits = transition_map.get(id.name.as_str());
745 if exits.is_some_and(|e| !e.is_empty()) {
746 continue;
747 }
748 self.push(
749 Diagnostic::warning(
750 id.span,
751 format!(
752 "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
753 id.name
754 ),
755 )
756 .with_code("allium.status.noExit"),
757 );
758 }
759 }
760
761 for (entity_name, transition_map) in &transitions_by_entity {
763 if let Some(edges) = declared_edges.get(entity_name) {
764 if let Some((idents, _)) = status_by_entity.get(entity_name) {
765 for (from, targets) in transition_map {
766 for to in targets {
767 if from != to && !edges.contains(&(*from, *to)) {
768 let span = idents
770 .iter()
771 .find(|id| id.name == *from)
772 .map(|id| id.span)
773 .unwrap_or(idents[0].span);
774 self.push(
775 Diagnostic::warning(
776 span,
777 format!(
778 "Rule produces transition '{from}' → '{to}' on entity '{entity_name}', but this edge is not in the declared transition graph.",
779 ),
780 )
781 .with_code("allium.status.undeclaredTransition"),
782 );
783 }
784 }
785 }
786 }
787 }
788 }
789
790 for issue in created_issues {
791 self.push(issue);
792 }
793 }
794}
795
796impl Ctx<'_> {
801 fn collect_process_findings(&mut self, info: &EntityInfo<'_>) {
802 let status_values = &info.status_values;
803 let field_types = &info.field_types;
804 let graph_edges = &info.graph_edges;
805 let terminals = &info.terminals;
806
807 if status_values.is_empty() {
808 return;
809 }
810
811 let mut surface_triggers: HashSet<&str> = HashSet::new();
813 let mut surface_names: Vec<String> = Vec::new();
814 for surface in self.blocks(BlockKind::Surface) {
815 if let Some(n) = &surface.name {
816 surface_names.push(n.name.clone());
817 }
818 for item in &surface.items {
819 let BlockItemKind::Clause { keyword, value } = &item.kind else {
820 continue;
821 };
822 if keyword == "provides" {
823 collect_call_names(value, &mut surface_triggers);
824 }
825 }
826 }
827
828 let mut emitted_triggers: HashSet<&str> = HashSet::new();
830 for rule in self.blocks(BlockKind::Rule) {
831 for item in &rule.items {
832 collect_emitted_trigger_from_item(&item.kind, &mut emitted_triggers);
833 }
834 }
835
836 let mut assigned_fields: HashSet<String> = HashSet::new();
838
839 struct RuleData<'b> {
840 name: &'b str,
841 trigger_reachable: bool,
842 requires_fields: Vec<(String, String, String)>,
843 transitions: Vec<(String, String, String)>,
844 field_assignments: HashSet<String>,
845 entity_bindings: Vec<String>,
846 }
847 let mut rules: Vec<RuleData> = Vec::new();
848
849 for rule in self.blocks(BlockKind::Rule) {
850 let rule_name = match &rule.name {
851 Some(n) => n.name.as_str(),
852 None => continue,
853 };
854 let mut trigger_name: Option<&str> = None;
855 let mut requires_statuses: HashMap<&str, HashSet<&str>> = HashMap::new();
856 let mut requires_fields: Vec<(String, String, String)> = Vec::new();
857 let mut ensures_statuses: Vec<(&str, &str)> = Vec::new();
858 let mut rule_assigned: HashSet<String> = HashSet::new();
859
860 for item in &rule.items {
861 let BlockItemKind::Clause { keyword, value } = &item.kind else {
862 continue;
863 };
864 if keyword == "when" {
865 let names = extract_trigger_names(value);
866 if let Some((name, _)) = names.first() {
867 trigger_name = Some(*name);
868 }
869 }
870 }
871
872 let trigger_reachable = trigger_name.map_or(true, |t| {
873 surface_triggers.contains(t) || emitted_triggers.contains(t)
874 });
875
876 let binding_types = collect_rule_binding_types(rule, &status_values_for_binding(&status_values));
877
878 let entity_bindings: Vec<String> = binding_types
880 .values()
881 .map(|v| v.to_string())
882 .collect::<HashSet<_>>()
883 .into_iter()
884 .collect();
885
886 for item in &rule.items {
887 let BlockItemKind::Clause { keyword, value } = &item.kind else {
888 continue;
889 };
890 if keyword != "requires" {
891 continue;
892 }
893 collect_requires_conditions(
894 value,
895 &binding_types,
896 status_values,
897 &mut |binding, field, val| {
898 if field == "status" {
899 requires_statuses
900 .entry(binding)
901 .or_default()
902 .insert(val);
903 } else {
904 let entity = resolve_binding_entity_from_status(
905 binding, None, &binding_types, &status_values,
906 );
907 if let Some(e) = entity {
908 requires_fields.push((
909 e.to_string(),
910 field.to_string(),
911 val.to_string(),
912 ));
913 }
914 }
915 },
916 );
917 }
918
919 for item in &rule.items {
920 let BlockItemKind::Clause { keyword, value } = &item.kind else {
921 continue;
922 };
923 if keyword != "ensures" {
924 continue;
925 }
926 collect_field_assignments(
927 value,
928 &binding_types,
929 &status_values,
930 &field_types,
931 &mut |entity, field, value| {
932 let key = format!("{entity}.{field}");
933 assigned_fields.insert(key.clone());
934 rule_assigned.insert(key);
935 if field == "status" && value != "_variable_" {
936 assigned_fields.insert(format!("{entity}.status.{value}"));
937 }
938 },
939 );
940 collect_ensures_status(
941 value,
942 &binding_types,
943 &status_values,
944 &field_types,
945 &mut |binding, target| {
946 ensures_statuses.push((binding, target));
947 },
948 );
949 }
950
951 let mut transitions = Vec::new();
952 for (binding, target) in &ensures_statuses {
953 let entity = resolve_binding_entity_from_status(
954 binding,
955 Some(target),
956 &binding_types,
957 &status_values,
958 );
959 if let Some(e) = entity {
960 if let Some(sources) = requires_statuses.get(binding) {
961 for source in sources {
962 transitions.push((
963 e.to_string(),
964 source.to_string(),
965 target.to_string(),
966 ));
967 }
968 }
969 }
970 }
971
972 rules.push(RuleData {
973 name: rule_name,
974 trigger_reachable,
975 requires_fields,
976 transitions,
977 field_assignments: rule_assigned,
978 entity_bindings,
979 });
980 }
981
982 let mut created_fields: HashSet<String> = HashSet::new();
984 for rule in self.blocks(BlockKind::Rule) {
985 for item in &rule.items {
986 let BlockItemKind::Clause { keyword, value } = &item.kind else {
987 continue;
988 };
989 if keyword != "ensures" {
990 continue;
991 }
992 collect_created_field_assignments(value, &status_values, &mut assigned_fields);
993 collect_created_field_assignments(value, &status_values, &mut created_fields);
994 }
995 }
996
997 let mut surface_provided_fields: HashSet<String> = HashSet::new();
999 for surface in self.blocks(BlockKind::Surface) {
1000 for item in &surface.items {
1001 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1002 continue;
1003 };
1004 if keyword == "provides" {
1005 collect_surface_provided_fields(value, &status_values, &mut surface_provided_fields);
1006 }
1007 }
1008 }
1009
1010 let build_searched = |entity: &str, field: &str| -> Vec<serde_json::Value> {
1012 let key = format!("{entity}.{field}");
1013 let mut searched = Vec::new();
1014
1015 let matching_rule: Option<&RuleData> = rules.iter().find(|r| {
1017 r.field_assignments.contains(&key)
1018 });
1019 if let Some(r) = matching_rule {
1020 if !r.trigger_reachable {
1021 searched.push(serde_json::json!({
1022 "kind": "rule_ensures",
1023 "found": r.name,
1024 "but": "trigger has no providing surface"
1025 }));
1026 } else {
1027 searched.push(serde_json::json!({
1028 "kind": "rule_ensures",
1029 "found": r.name
1030 }));
1031 }
1032 } else {
1033 searched.push(serde_json::json!({
1034 "kind": "rule_ensures",
1035 "found": false
1036 }));
1037 }
1038
1039 searched.push(serde_json::json!({
1041 "kind": "surface_provides",
1042 "found": surface_provided_fields.contains(&key)
1043 }));
1044
1045 searched.push(serde_json::json!({
1047 "kind": "created_calls",
1048 "found": created_fields.contains(&key)
1049 }));
1050
1051 searched
1052 };
1053
1054 for (entity, edges) in graph_edges {
1056 let _statuses = match status_values.get(entity) {
1057 Some(v) => v,
1058 None => continue,
1059 };
1060
1061 for (from, to) in edges {
1062 let witnesses: Vec<&RuleData> = rules
1063 .iter()
1064 .filter(|r| {
1065 r.transitions
1066 .iter()
1067 .any(|(e, f, t)| e == *entity && f == *from && t == *to)
1068 })
1069 .collect();
1070
1071 if witnesses.is_empty() {
1072 continue;
1073 }
1074
1075 let any_achievable = witnesses.iter().any(|r| {
1076 r.requires_fields.iter().all(|(e, f, _v)| {
1077 assigned_fields.contains(&format!("{e}.{f}"))
1078 })
1079 });
1080
1081 if !any_achievable {
1082 let witness_names: Vec<String> =
1083 witnesses.iter().map(|r| r.name.to_string()).collect();
1084 let unsatisfiable: Vec<serde_json::Value> = witnesses
1085 .iter()
1086 .flat_map(|r| {
1087 r.requires_fields.iter().filter(|(e, f, _)| {
1088 !assigned_fields.contains(&format!("{e}.{f}"))
1089 })
1090 })
1091 .map(|(e, f, v)| {
1092 serde_json::json!({
1093 "entity": e,
1094 "field": f,
1095 "value": v,
1096 "searched": build_searched(e, f),
1097 })
1098 })
1099 .collect();
1100
1101 self.push_finding(serde_json::json!({
1102 "type": "dead_transition",
1103 "summary": format!(
1104 "Transition '{from}' → '{to}' on entity '{entity}' is declared but unachievable"
1105 ),
1106 "edge": {"entity": entity, "from": from, "to": to},
1107 "witnessing_rules": witness_names,
1108 "unsatisfiable_requires": unsatisfiable,
1109 "affected_entities": [entity],
1110 }));
1111 }
1112 }
1113 }
1114
1115 for r in &rules {
1117 for (entity, field, value) in &r.requires_fields {
1118 let key = format!("{entity}.{field}");
1119 if !assigned_fields.contains(&key) {
1120 self.push_finding(serde_json::json!({
1121 "type": "missing_producer",
1122 "summary": format!("Nothing establishes {entity}.{field} = {value}"),
1123 "requires": {"rule": r.name, "field": field, "value": value},
1124 "searched": build_searched(entity, field),
1125 "affected_entities": [entity],
1126 }));
1127 }
1128 }
1129 }
1130
1131 for (entity, edges) in graph_edges {
1133 let entity_terminals = match terminals.get(entity) {
1134 Some(t) => t,
1135 None => continue,
1136 };
1137 let (statuses, _idents) = match status_values.get(entity) {
1138 Some(v) => v,
1139 None => continue,
1140 };
1141
1142 let achievable_edges: HashSet<(&str, &str)> = edges
1143 .iter()
1144 .filter(|(_from, to)| {
1145 let producers: Vec<&RuleData> = rules
1146 .iter()
1147 .filter(|r| {
1148 r.transitions
1149 .iter()
1150 .any(|(e, _f, t)| e == *entity && t == *to)
1151 })
1152 .collect();
1153 if producers.is_empty() {
1154 return assigned_fields.contains(&format!("{entity}.status.{to}"));
1155 }
1156 producers.iter().any(|r| {
1157 r.requires_fields.iter().all(|(e, f, _v)| {
1158 assigned_fields.contains(&format!("{e}.{f}"))
1159 })
1160 })
1161 })
1162 .copied()
1163 .collect();
1164
1165 for status in statuses {
1166 if entity_terminals.contains(status) {
1167 continue;
1168 }
1169 let mut visited = HashSet::new();
1170 let mut queue = vec![*status];
1171 let mut found_terminal = false;
1172 while let Some(current) = queue.pop() {
1173 if !visited.insert(current) {
1174 continue;
1175 }
1176 if entity_terminals.contains(current) {
1177 found_terminal = true;
1178 break;
1179 }
1180 for (from, to) in &achievable_edges {
1181 if *from == current {
1182 queue.push(to);
1183 }
1184 }
1185 }
1186 if !found_terminal {
1187 let has_inbound = achievable_edges
1188 .iter()
1189 .any(|(_, to)| *to == *status);
1190
1191 if has_inbound || statuses.len() <= 6 {
1192 let outbound: Vec<serde_json::Value> = edges
1194 .iter()
1195 .filter(|(f, _)| *f == *status)
1196 .map(|(f, t)| {
1197 let witness_rules: Vec<(&str, &[(String, String, String)])> =
1198 rules
1199 .iter()
1200 .filter(|r| {
1201 r.transitions.iter().any(|(e, _ef, et)| {
1202 e == *entity && et == *t
1203 })
1204 })
1205 .map(|r| {
1206 (r.name, r.requires_fields.as_slice())
1207 })
1208 .collect();
1209 let reason = edge_blocked_reason(
1210 &witness_rules, &assigned_fields,
1211 );
1212 serde_json::json!({
1213 "from": f,
1214 "to": t,
1215 "reason": reason,
1216 })
1217 })
1218 .collect();
1219
1220 let cycle = detect_cycle(*status, &achievable_edges);
1222
1223 self.push_finding(serde_json::json!({
1224 "type": "deadlock",
1225 "summary": format!(
1226 "Entity '{entity}' can reach state '{status}' but has no achievable path to any terminal state"
1227 ),
1228 "state": status,
1229 "outbound_edges": outbound,
1230 "cycle": cycle,
1231 "affected_entities": [entity],
1232 }));
1233 }
1234 }
1235 }
1236 }
1237
1238 let mut unreachable_by_trigger: HashMap<&str, Vec<(&str, Vec<String>)>> = HashMap::new();
1240 for rule in self.blocks(BlockKind::Rule) {
1241 let rule_name = match &rule.name {
1242 Some(n) => n.name.as_str(),
1243 None => continue,
1244 };
1245 for item in &rule.items {
1246 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1247 continue;
1248 };
1249 if keyword != "when" {
1250 continue;
1251 }
1252 let trigger_names = extract_trigger_names(value);
1253 for (name, _span) in trigger_names {
1254 if !surface_triggers.contains(name) && !emitted_triggers.contains(name) {
1255 let rule_data = rules.iter().find(|r| r.name == rule_name);
1257 let bindings = rule_data
1258 .map(|r| r.entity_bindings.clone())
1259 .unwrap_or_default();
1260 unreachable_by_trigger
1261 .entry(name)
1262 .or_default()
1263 .push((rule_name, bindings));
1264 }
1265 }
1266 }
1267 }
1268 for (trigger, rule_entries) in &unreachable_by_trigger {
1269 let listening_rules: Vec<&str> = rule_entries.iter().map(|(n, _)| *n).collect();
1270 let affected_entities: Vec<String> = rule_entries
1271 .iter()
1272 .flat_map(|(_, bindings)| bindings.iter().cloned())
1273 .collect::<HashSet<_>>()
1274 .into_iter()
1275 .collect();
1276 self.push_finding(serde_json::json!({
1277 "type": "unreachable_trigger",
1278 "summary": format!(
1279 "Trigger '{trigger}' is not provided by any surface"
1280 ),
1281 "trigger": trigger,
1282 "listening_rules": listening_rules,
1283 "surfaces_checked": surface_names,
1284 "affected_entities": affected_entities,
1285 }));
1286 }
1287 }
1288
1289 fn collect_conflict_findings(&mut self, info: &EntityInfo<'_>) {
1290 let status_by_entity = info.status_by_entity();
1291
1292 if status_by_entity.is_empty() {
1293 return;
1294 }
1295
1296 struct ConflictRule<'b> {
1297 name: &'b str,
1298 trigger_kind: ConflictTriggerKind<'b>,
1299 requires_statuses: HashMap<String, HashSet<String>>,
1300 ensures_statuses: HashMap<String, String>,
1301 }
1302
1303 let mut conflict_rules: Vec<ConflictRule> = Vec::new();
1304
1305 for rule in self.blocks(BlockKind::Rule) {
1306 let rule_name = match &rule.name {
1307 Some(n) => n.name.as_str(),
1308 None => continue,
1309 };
1310 let binding_types = collect_rule_binding_types(rule, &HashMap::new());
1313
1314 let mut trigger_kind = ConflictTriggerKind::Unknown;
1315 let mut requires_statuses: HashMap<String, HashSet<String>> = HashMap::new();
1316 let mut ensures_statuses: HashMap<String, String> = HashMap::new();
1317
1318 for item in &rule.items {
1319 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1320 continue;
1321 };
1322 match keyword.as_str() {
1323 "when" => {
1324 trigger_kind = classify_trigger(value);
1325 }
1326 "requires" => {
1327 collect_requires_statuses_for_conflict(
1328 value,
1329 &binding_types,
1330 &status_by_entity,
1331 &mut requires_statuses,
1332 );
1333 }
1334 "ensures" => {
1335 collect_ensures_statuses_for_conflict(
1336 value,
1337 &binding_types,
1338 &status_by_entity,
1339 &mut ensures_statuses,
1340 );
1341 }
1342 _ => {}
1343 }
1344 }
1345
1346 conflict_rules.push(ConflictRule {
1347 name: rule_name,
1348 trigger_kind,
1349 requires_statuses,
1350 ensures_statuses,
1351 });
1352 }
1353
1354 let mut reported: HashSet<(usize, usize)> = HashSet::new();
1356 for i in 0..conflict_rules.len() {
1357 for j in (i + 1)..conflict_rules.len() {
1358 let a = &conflict_rules[i];
1359 let b = &conflict_rules[j];
1360
1361 if matches!(
1362 (&a.trigger_kind, &b.trigger_kind),
1363 (ConflictTriggerKind::Call(_), ConflictTriggerKind::Call(_))
1364 ) {
1365 continue;
1366 }
1367
1368 let mut overlap_state: Option<(&str, &str)> = None;
1370 let mut compatible = false;
1371 for (entity, a_statuses) in &a.requires_statuses {
1372 if let Some(b_statuses) = b.requires_statuses.get(entity) {
1373 let intersection: Vec<&String> =
1374 a_statuses.intersection(b_statuses).collect();
1375 if !intersection.is_empty() {
1376 compatible = true;
1377 overlap_state = Some((entity.as_str(), intersection[0].as_str()));
1378 break;
1379 }
1380 }
1381 }
1382 if !compatible {
1383 continue;
1384 }
1385
1386 for (entity, a_target) in &a.ensures_statuses {
1387 if let Some(b_target) = b.ensures_statuses.get(entity) {
1388 if a_target != b_target && !reported.contains(&(i, j)) {
1389 reported.insert((i, j));
1390 let state = overlap_state
1391 .map(|(_, s)| s.to_string())
1392 .unwrap_or_default();
1393 let mut values = serde_json::Map::new();
1394 values.insert(a.name.to_string(), serde_json::json!(a_target));
1395 values.insert(b.name.to_string(), serde_json::json!(b_target));
1396
1397 self.push_finding(serde_json::json!({
1398 "type": "conflict",
1399 "summary": format!(
1400 "Rules '{}' and '{}' can both fire when entity '{entity}' is in state '{state}', setting status to conflicting values",
1401 a.name, b.name,
1402 ),
1403 "rule_a": a.name,
1404 "rule_b": b.name,
1405 "field": "status",
1406 "state": state,
1407 "values": values,
1408 "affected_entities": [entity],
1409 }));
1410 }
1411 }
1412 }
1413 }
1414 }
1415 }
1416
1417 fn collect_invariant_findings(&mut self, info: &EntityInfo<'_>) {
1418 let status_by_entity = info.status_by_entity();
1419 let field_types = &info.field_types;
1420
1421 struct RuleEffect<'b> {
1422 name: &'b str,
1423 status_sets: Vec<(String, String)>,
1424 field_sets: HashSet<String>,
1425 requires: Vec<(String, String, String)>,
1426 }
1427
1428 let binding_map: HashMap<&str, (HashSet<&str>, Vec<&Ident>)> = status_by_entity
1429 .iter()
1430 .map(|(k, v)| (*k, (v.clone(), Vec::new())))
1431 .collect();
1432 let binding_map_for_types: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = status_by_entity
1433 .iter()
1434 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1435 .collect();
1436 let mut rule_effects: Vec<RuleEffect> = Vec::new();
1437
1438 for rule in self.blocks(BlockKind::Rule) {
1439 let rule_name = match &rule.name {
1440 Some(n) => n.name.as_str(),
1441 None => continue,
1442 };
1443 let binding_types = collect_rule_binding_types(rule, &binding_map_for_types);
1444 let mut status_sets = Vec::new();
1445 let mut field_sets = HashSet::new();
1446 let mut requires = Vec::new();
1447
1448 for item in &rule.items {
1449 let BlockItemKind::Clause { keyword, value } = &item.kind else {
1450 continue;
1451 };
1452 match keyword.as_str() {
1453 "ensures" => {
1454 collect_rule_effects(
1455 value,
1456 &binding_types,
1457 &status_by_entity,
1458 &field_types,
1459 &mut status_sets,
1460 &mut field_sets,
1461 );
1462 }
1463 "requires" => {
1464 collect_requires_conditions(
1465 value,
1466 &binding_types,
1467 &binding_map,
1468 &mut |binding, field, val| {
1469 let entity = resolve_binding_entity(
1470 binding,
1471 None,
1472 &binding_types,
1473 &binding_map_for_types,
1474 );
1475 if let Some(e) = entity {
1476 requires.push((
1477 e.to_string(),
1478 field.to_string(),
1479 val.to_string(),
1480 ));
1481 }
1482 },
1483 );
1484 }
1485 _ => {}
1486 }
1487 }
1488
1489 rule_effects.push(RuleEffect {
1490 name: rule_name,
1491 status_sets,
1492 field_sets,
1493 requires,
1494 });
1495 }
1496
1497 for decl in &self.module.declarations {
1499 let Decl::Invariant(inv) = decl else {
1500 continue;
1501 };
1502
1503 if let Some(pattern) = extract_uniqueness_invariant(&inv.body) {
1504 let key_entity_type: Option<&str> = status_by_entity
1505 .keys()
1506 .find_map(|entity_name| {
1507 field_types
1508 .get(entity_name)
1509 .and_then(|fields| fields.get(pattern.key_field).copied())
1510 });
1511
1512 for effect in &rule_effects {
1513 for (entity, target) in &effect.status_sets {
1514 if target == pattern.prohibited_status {
1515 let has_guard = key_entity_type.map_or(false, |ket| {
1516 effect.field_sets.iter().any(|f| {
1517 f.starts_with(&format!("{ket}."))
1518 }) || effect.requires.iter().any(|(e, _f, _v)| {
1519 e == ket
1520 })
1521 });
1522
1523 if !has_guard {
1524 let needed = format!(
1525 "Rule should set {}.status to prevent concurrent {} states",
1526 key_entity_type.unwrap_or("related entity"),
1527 pattern.prohibited_status,
1528 );
1529 self.push_finding(serde_json::json!({
1530 "type": "invariant_risk",
1531 "summary": format!(
1532 "Rule '{}' could violate invariant '{}'",
1533 effect.name, inv.name.name,
1534 ),
1535 "rule": effect.name,
1536 "invariant": inv.name.name,
1537 "mechanism": format!(
1538 "Sets {entity}.status to '{target}' without preventing concurrent instances"
1539 ),
1540 "guard_analysis": {
1541 "has_guard": false,
1542 "needed": needed,
1543 },
1544 "affected_entities": [entity],
1545 }));
1546 }
1547 }
1548 }
1549 }
1550 }
1551 }
1552 }
1553}
1554
1555fn edge_blocked_reason(
1560 witness_rules: &[(&str, &[(String, String, String)])],
1561 assigned_fields: &HashSet<String>,
1562) -> String {
1563 if witness_rules.is_empty() {
1564 return "no witnessing rule".to_string();
1565 }
1566
1567 for (name, requires_fields) in witness_rules {
1568 for (e, f, v) in *requires_fields {
1569 if !assigned_fields.contains(&format!("{e}.{f}")) {
1570 return format!(
1571 "rule {name} requires {e}.{f} = {v}, never established",
1572 );
1573 }
1574 }
1575 }
1576
1577 "no achievable witnessing rule".to_string()
1578}
1579
1580fn detect_cycle<'a>(
1583 start: &'a str,
1584 edges: &HashSet<(&'a str, &'a str)>,
1585) -> Option<Vec<&'a str>> {
1586 let mut stack: Vec<(&str, Vec<&str>)> = vec![(start, vec![start])];
1588 let mut visited: HashSet<&str> = HashSet::new();
1589
1590 while let Some((current, path)) = stack.pop() {
1591 if !visited.insert(current) {
1592 continue;
1593 }
1594 for (from, to) in edges {
1595 if *from != current {
1596 continue;
1597 }
1598 if let Some(pos) = path.iter().position(|s| *s == *to) {
1599 let mut cycle: Vec<&str> = path[pos..].to_vec();
1601 cycle.push(to);
1602 return Some(cycle);
1603 }
1604 let mut next_path = path.clone();
1605 next_path.push(to);
1606 visited.remove(to);
1608 stack.push((to, next_path));
1609 }
1610 }
1611 None
1612}
1613
1614fn collect_surface_provided_fields(
1616 expr: &Expr,
1617 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
1618 out: &mut HashSet<String>,
1619) {
1620 match expr {
1621 Expr::Call { function, args, .. } => {
1622 if let Expr::Ident(fn_name) = function.as_ref() {
1623 for arg in args {
1626 if let CallArg::Positional(Expr::Ident(binding)) = arg {
1627 if status_values.contains_key(binding.name.as_str()) {
1629 out.insert(format!("{}.status", binding.name));
1631 }
1632 }
1633 if let CallArg::Named(named) = arg {
1634 if let Expr::Ident(val) = &named.value {
1635 if status_values.contains_key(val.name.as_str()) {
1636 out.insert(format!("{}.{}", val.name, named.name.name));
1637 }
1638 }
1639 }
1640 }
1641 let _ = fn_name;
1643 }
1644 }
1645 Expr::Block { items, .. } => {
1646 for item in items {
1647 collect_surface_provided_fields(item, status_values, out);
1648 }
1649 }
1650 Expr::WhenGuard { action, .. } => {
1651 collect_surface_provided_fields(action, status_values, out);
1652 }
1653 Expr::Conditional { branches, else_body, .. } => {
1654 for b in branches {
1655 collect_surface_provided_fields(&b.body, status_values, out);
1656 }
1657 if let Some(body) = else_body {
1658 collect_surface_provided_fields(body, status_values, out);
1659 }
1660 }
1661 _ => {}
1662 }
1663}
1664
1665fn collect_rule_effects(
1667 expr: &Expr,
1668 binding_types: &HashMap<&str, &str>,
1669 status_by_entity: &HashMap<&str, HashSet<&str>>,
1670 field_types: &HashMap<&str, HashMap<&str, &str>>,
1671 status_sets: &mut Vec<(String, String)>,
1672 field_sets: &mut HashSet<String>,
1673) {
1674 match expr {
1675 Expr::Comparison {
1676 left,
1677 op: ComparisonOp::Eq,
1678 right,
1679 ..
1680 } => {
1681 if let Some(target) = expr_as_ident(right) {
1682 if let Some((binding, field)) = expr_as_member_access(left) {
1683 let entity = resolve_binding_entity(
1684 binding,
1685 if field == "status" { Some(target) } else { None },
1686 binding_types,
1687 &status_by_entity
1688 .iter()
1689 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1690 .collect(),
1691 );
1692 if let Some(e) = entity {
1693 if field == "status" {
1694 status_sets.push((e.to_string(), target.to_string()));
1695 }
1696 field_sets.insert(format!("{e}.{field}"));
1697 }
1698 }
1699 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
1701 let root_entity = resolve_binding_entity(
1702 root,
1703 None,
1704 binding_types,
1705 &status_by_entity
1706 .iter()
1707 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1708 .collect(),
1709 );
1710 if let Some(re) = root_entity {
1711 if let Some(nested) =
1712 field_types.get(re).and_then(|f| f.get(mid).copied())
1713 {
1714 if field == "status" {
1715 status_sets.push((nested.to_string(), target.to_string()));
1716 }
1717 field_sets.insert(format!("{nested}.{field}"));
1718 }
1719 }
1720 }
1721 }
1722 }
1723 Expr::Block { items, .. } => {
1724 for item in items {
1725 collect_rule_effects(
1726 item, binding_types, status_by_entity, field_types, status_sets, field_sets,
1727 );
1728 }
1729 }
1730 Expr::Conditional {
1731 branches,
1732 else_body,
1733 ..
1734 } => {
1735 for branch in branches {
1736 collect_rule_effects(
1737 &branch.body, binding_types, status_by_entity, field_types, status_sets,
1738 field_sets,
1739 );
1740 }
1741 if let Some(body) = else_body {
1742 collect_rule_effects(
1743 body, binding_types, status_by_entity, field_types, status_sets, field_sets,
1744 );
1745 }
1746 }
1747 _ => {}
1748 }
1749}
1750
1751struct UniquenessPattern<'a> {
1754 prohibited_status: &'a str,
1755 key_field: &'a str,
1756}
1757
1758fn extract_uniqueness_invariant<'a>(expr: &'a Expr) -> Option<UniquenessPattern<'a>> {
1760 let Expr::For { body, .. } = expr else {
1762 return None;
1763 };
1764 let Expr::For { body: inner_body, .. } = body.as_ref() else {
1765 return None;
1766 };
1767
1768 let Expr::LogicalOp {
1770 op: LogicalOp::Implies,
1771 left: premise,
1772 right: conclusion,
1773 ..
1774 } = inner_body.as_ref()
1775 else {
1776 return None;
1777 };
1778
1779 let Expr::Not { operand, .. } = conclusion.as_ref() else {
1781 return None;
1782 };
1783
1784 let prohibited = extract_prohibited_status(operand)?;
1786
1787 let key_field = extract_key_field(premise)?;
1789
1790 Some(UniquenessPattern {
1791 prohibited_status: prohibited,
1792 key_field,
1793 })
1794}
1795
1796fn extract_prohibited_status(expr: &Expr) -> Option<&str> {
1798 let Expr::LogicalOp {
1799 op: LogicalOp::And,
1800 left,
1801 right,
1802 ..
1803 } = expr
1804 else {
1805 return None;
1806 };
1807
1808 let l_status = extract_status_value(left)?;
1810 let r_status = extract_status_value(right)?;
1811
1812 if l_status == r_status {
1813 Some(l_status)
1814 } else {
1815 None
1816 }
1817}
1818
1819fn extract_status_value(expr: &Expr) -> Option<&str> {
1820 if let Expr::Comparison {
1821 left,
1822 op: ComparisonOp::Eq,
1823 right,
1824 ..
1825 } = expr
1826 {
1827 if let Some((_, "status")) = expr_as_member_access(left) {
1828 return expr_as_ident(right);
1829 }
1830 }
1831 None
1832}
1833
1834fn extract_key_field(expr: &Expr) -> Option<&str> {
1836 let Expr::LogicalOp {
1837 op: LogicalOp::And,
1838 left: _,
1839 right,
1840 ..
1841 } = expr
1842 else {
1843 return None;
1844 };
1845
1846 if let Expr::Comparison {
1848 left,
1849 op: ComparisonOp::Eq,
1850 right: _,
1851 ..
1852 } = right.as_ref()
1853 {
1854 if let Some((_, field)) = expr_as_member_access(left) {
1855 return Some(field);
1859 }
1860 }
1861 None
1862}
1863
1864#[derive(PartialEq)]
1865enum ConflictTriggerKind<'a> {
1866 Call(&'a str),
1867 Temporal,
1868 Unknown,
1869}
1870
1871fn classify_trigger(expr: &Expr) -> ConflictTriggerKind<'_> {
1872 match expr {
1873 Expr::Call { function, .. } => {
1874 if let Expr::Ident(id) = function.as_ref() {
1875 return ConflictTriggerKind::Call(&id.name);
1876 }
1877 ConflictTriggerKind::Unknown
1878 }
1879 Expr::Binding { value, .. } => classify_trigger(value),
1880 Expr::Comparison { .. }
1881 | Expr::Becomes { .. }
1882 | Expr::TransitionsTo { .. } => ConflictTriggerKind::Temporal,
1883 _ => ConflictTriggerKind::Unknown,
1884 }
1885}
1886
1887fn collect_requires_statuses_for_conflict(
1888 expr: &Expr,
1889 binding_types: &HashMap<&str, &str>,
1890 status_by_entity: &HashMap<&str, HashSet<&str>>,
1891 out: &mut HashMap<String, HashSet<String>>,
1892) {
1893 match expr {
1894 Expr::Comparison {
1895 left,
1896 op: ComparisonOp::Eq,
1897 right,
1898 ..
1899 } => {
1900 if let (Some((binding, "status")), Some(target)) =
1901 (expr_as_member_access(left), expr_as_ident(right))
1902 {
1903 let entity = resolve_binding_entity(
1904 binding,
1905 Some(target),
1906 binding_types,
1907 &status_by_entity
1908 .iter()
1909 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1910 .collect(),
1911 );
1912 if let Some(e) = entity {
1913 out.entry(e.to_string()).or_default().insert(target.to_string());
1914 }
1915 }
1916 }
1917 Expr::LogicalOp { left, right, .. } => {
1918 collect_requires_statuses_for_conflict(left, binding_types, status_by_entity, out);
1919 collect_requires_statuses_for_conflict(right, binding_types, status_by_entity, out);
1920 }
1921 Expr::Block { items, .. } => {
1922 for item in items {
1923 collect_requires_statuses_for_conflict(item, binding_types, status_by_entity, out);
1924 }
1925 }
1926 _ => {}
1927 }
1928}
1929
1930fn collect_ensures_statuses_for_conflict(
1931 expr: &Expr,
1932 binding_types: &HashMap<&str, &str>,
1933 status_by_entity: &HashMap<&str, HashSet<&str>>,
1934 out: &mut HashMap<String, String>,
1935) {
1936 match expr {
1937 Expr::Comparison {
1938 left,
1939 op: ComparisonOp::Eq,
1940 right,
1941 ..
1942 } => {
1943 if let (Some((binding, "status")), Some(target)) =
1944 (expr_as_member_access(left), expr_as_ident(right))
1945 {
1946 let entity = resolve_binding_entity(
1947 binding,
1948 Some(target),
1949 binding_types,
1950 &status_by_entity
1951 .iter()
1952 .map(|(k, v)| (*k, (Vec::new(), v.clone())))
1953 .collect(),
1954 );
1955 if let Some(e) = entity {
1956 out.insert(e.to_string(), target.to_string());
1957 }
1958 }
1959 }
1960 Expr::Block { items, .. } => {
1961 for item in items {
1962 collect_ensures_statuses_for_conflict(item, binding_types, status_by_entity, out);
1963 }
1964 }
1965 Expr::Conditional {
1966 branches,
1967 else_body,
1968 ..
1969 } => {
1970 for branch in branches {
1971 collect_ensures_statuses_for_conflict(
1972 &branch.body, binding_types, status_by_entity, out,
1973 );
1974 }
1975 if let Some(body) = else_body {
1976 collect_ensures_statuses_for_conflict(body, binding_types, status_by_entity, out);
1977 }
1978 }
1979 _ => {}
1980 }
1981}
1982
1983fn status_values_for_binding<'a>(
1985 status_values: &'a HashMap<&'a str, (HashSet<&'a str>, Vec<&'a Ident>)>,
1986) -> HashMap<&'a str, (Vec<&'a Ident>, HashSet<&'a str>)> {
1987 status_values
1988 .iter()
1989 .map(|(k, (set, idents))| (*k, (idents.clone(), set.clone())))
1990 .collect()
1991}
1992
1993fn resolve_binding_entity_from_status<'a>(
1996 binding: &str,
1997 target: Option<&str>,
1998 binding_types: &HashMap<&'a str, &'a str>,
1999 status_values: &HashMap<&'a str, (HashSet<&'a str>, Vec<&Ident>)>,
2000) -> Option<&'a str> {
2001 binding_types
2002 .get(binding)
2003 .copied()
2004 .or_else(|| {
2005 status_values
2006 .keys()
2007 .find(|name| name.eq_ignore_ascii_case(binding))
2008 .copied()
2009 })
2010 .or_else(|| {
2011 let target = target?;
2012 let mut candidates = status_values
2013 .iter()
2014 .filter(|(_, (values, _))| values.contains(target));
2015 let first = candidates.next()?;
2016 if candidates.next().is_none() {
2017 Some(first.0)
2018 } else {
2019 None
2020 }
2021 })
2022}
2023
2024fn collect_requires_conditions<'a>(
2026 expr: &'a Expr,
2027 binding_types: &HashMap<&'a str, &'a str>,
2028 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2029 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2030) {
2031 match expr {
2032 Expr::Comparison {
2033 left,
2034 op: ComparisonOp::Eq,
2035 right,
2036 ..
2037 } => {
2038 if let Some(target) = expr_as_ident(right) {
2039 if let Some((binding, field)) = expr_as_member_access(left) {
2040 cb(binding, field, target);
2041 } else if let Some((root, _mid, field)) =
2042 expr_as_nested_member_access(left)
2043 {
2044 if field == "status" {
2045 cb(root, "status", target);
2046 }
2047 }
2048 }
2049 if let Expr::BoolLiteral { value: true, .. } = right.as_ref() {
2051 if let Some((binding, field)) = expr_as_member_access(left) {
2052 cb(binding, field, "true");
2053 }
2054 }
2055 }
2056 Expr::Comparison {
2057 op: ComparisonOp::GtEq,
2058 ..
2059 } => {
2060 }
2062 Expr::LogicalOp { left, right, .. } => {
2063 collect_requires_conditions(left, binding_types, status_values, cb);
2064 collect_requires_conditions(right, binding_types, status_values, cb);
2065 }
2066 Expr::Block { items, .. } => {
2067 for item in items {
2068 collect_requires_conditions(item, binding_types, status_values, cb);
2069 }
2070 }
2071 _ => {}
2072 }
2073}
2074
2075fn collect_field_assignments<'a>(
2077 expr: &'a Expr,
2078 binding_types: &HashMap<&'a str, &'a str>,
2079 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2080 field_types: &HashMap<&str, HashMap<&str, &str>>,
2081 cb: &mut impl FnMut(&str, &str, &str),
2082) {
2083 match expr {
2084 Expr::Comparison {
2085 left,
2086 op: ComparisonOp::Eq,
2087 right,
2088 ..
2089 } => {
2090 if let Some((binding, field)) = expr_as_member_access(left) {
2091 let entity = resolve_binding_entity_from_status(
2092 binding, None, binding_types, status_values,
2093 );
2094 if let Some(entity) = entity {
2095 let val = expr_as_ident(right).unwrap_or("_variable_");
2096 cb(entity, field, val);
2097 }
2098 }
2099 if let Some((root, mid, field)) = expr_as_nested_member_access(left) {
2101 let root_entity = resolve_binding_entity_from_status(
2102 root, None, binding_types, status_values,
2103 );
2104 if let Some(root_entity) = root_entity {
2105 if let Some(nested) =
2106 field_types.get(root_entity).and_then(|f| f.get(mid).copied())
2107 {
2108 let val = expr_as_ident(right).unwrap_or("_variable_");
2109 cb(nested, field, val);
2110 }
2111 }
2112 }
2113 }
2114 Expr::Block { items, .. } => {
2115 for item in items {
2116 collect_field_assignments(item, binding_types, status_values, field_types, cb);
2117 }
2118 }
2119 Expr::Conditional {
2120 branches,
2121 else_body,
2122 ..
2123 } => {
2124 for branch in branches {
2125 collect_field_assignments(
2126 &branch.body, binding_types, status_values, field_types, cb,
2127 );
2128 }
2129 if let Some(body) = else_body {
2130 collect_field_assignments(body, binding_types, status_values, field_types, cb);
2131 }
2132 }
2133 _ => {}
2134 }
2135}
2136
2137fn collect_ensures_status<'a>(
2139 expr: &'a Expr,
2140 binding_types: &HashMap<&'a str, &'a str>,
2141 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2142 field_types: &HashMap<&str, HashMap<&str, &str>>,
2143 cb: &mut impl FnMut(&'a str, &'a str),
2144) {
2145 match expr {
2146 Expr::Comparison {
2147 left,
2148 op: ComparisonOp::Eq,
2149 right,
2150 ..
2151 } => {
2152 if let Some(target) = expr_as_ident(right) {
2153 if let Some((binding, "status")) = expr_as_member_access(left) {
2156 cb(binding, target);
2157 }
2158 }
2159 }
2160 Expr::Block { items, .. } => {
2161 for item in items {
2162 collect_ensures_status(item, binding_types, status_values, field_types, cb);
2163 }
2164 }
2165 Expr::Conditional {
2166 branches,
2167 else_body,
2168 ..
2169 } => {
2170 for branch in branches {
2171 collect_ensures_status(
2172 &branch.body, binding_types, status_values, field_types, cb,
2173 );
2174 }
2175 if let Some(body) = else_body {
2176 collect_ensures_status(body, binding_types, status_values, field_types, cb);
2177 }
2178 }
2179 _ => {}
2180 }
2181}
2182
2183fn collect_created_field_assignments<'a>(
2185 expr: &'a Expr,
2186 status_values: &HashMap<&str, (HashSet<&str>, Vec<&Ident>)>,
2187 assigned: &mut HashSet<String>,
2188) {
2189 match expr {
2190 Expr::Call { function, args, .. } => {
2191 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2192 if field.name == "created" {
2193 if let Expr::Ident(entity_id) = object.as_ref() {
2194 let entity = entity_id.name.as_str();
2195 if status_values.contains_key(entity) {
2196 for arg in args {
2197 if let CallArg::Named(named) = arg {
2198 assigned.insert(format!(
2199 "{entity}.{}", named.name.name
2200 ));
2201 if named.name.name == "status" {
2203 if let Expr::Ident(val) = &named.value {
2204 assigned.insert(format!(
2205 "{entity}.status.{}", val.name
2206 ));
2207 }
2208 }
2209 }
2210 }
2211 }
2212 }
2213 }
2214 }
2215 }
2216 Expr::Block { items, .. } => {
2217 for item in items {
2218 collect_created_field_assignments(item, status_values, assigned);
2219 }
2220 }
2221 Expr::Conditional {
2222 branches,
2223 else_body,
2224 ..
2225 } => {
2226 for branch in branches {
2227 collect_created_field_assignments(&branch.body, status_values, assigned);
2228 }
2229 if let Some(body) = else_body {
2230 collect_created_field_assignments(body, status_values, assigned);
2231 }
2232 }
2233 _ => {}
2234 }
2235}
2236
2237fn collect_rule_binding_types<'a>(
2238 rule: &'a BlockDecl,
2239 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2240) -> HashMap<&'a str, &'a str> {
2241 let mut types = HashMap::new();
2242 for item in &rule.items {
2243 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2244 continue;
2245 };
2246 if keyword != "when" {
2247 continue;
2248 }
2249 collect_binding_types_from_expr(value, status_by_entity, &mut types);
2250 }
2251 types
2252}
2253
2254fn collect_binding_types_from_expr<'a>(
2255 expr: &'a Expr,
2256 status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
2257 out: &mut HashMap<&'a str, &'a str>,
2258) {
2259 match expr {
2260 Expr::Binding { name, value, .. } => {
2261 if let Some(entity_name) = extract_entity_from_trigger(value) {
2262 if status_by_entity.contains_key(entity_name) {
2263 out.insert(&name.name, entity_name);
2264 }
2265 }
2266 }
2267 Expr::Call { function, args, .. } => {
2268 if let Expr::Ident(fn_name) = function.as_ref() {
2269 for arg in args {
2270 if let CallArg::Positional(Expr::Ident(binding)) = arg {
2271 if status_by_entity.contains_key(fn_name.name.as_str()) {
2272 out.insert(&binding.name, &fn_name.name);
2273 }
2274 }
2275 }
2276 }
2277 }
2278 Expr::LogicalOp { left, right, .. } => {
2279 collect_binding_types_from_expr(left, status_by_entity, out);
2280 collect_binding_types_from_expr(right, status_by_entity, out);
2281 }
2282 _ => {}
2283 }
2284}
2285
2286fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
2287 match expr {
2288 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
2289 extract_entity_from_member(subject)
2290 }
2291 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2292 _ => None,
2293 }
2294}
2295
2296fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
2297 match expr {
2298 Expr::MemberAccess { object, .. } => expr_as_ident(object),
2299 _ => None,
2300 }
2301}
2302
2303fn visit_status_assignments<'a>(
2304 expr: &'a Expr,
2305 binding_types: &HashMap<&'a str, &'a str>,
2306 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2307 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2308 cb: &mut impl FnMut(&'a str, &'a str, &'a str),
2309) {
2310 match expr {
2311 Expr::Comparison {
2312 left,
2313 op: ComparisonOp::Eq,
2314 right,
2315 ..
2316 } => {
2317 if let Some(target) = expr_as_ident(right) {
2318 if let Some((binding, "status")) = expr_as_member_access(left) {
2320 let entity = resolve_binding_entity(
2321 binding,
2322 Some(target),
2323 binding_types,
2324 status_by_entity,
2325 );
2326 if let Some(entity) = entity {
2327 cb(binding, target, entity);
2328 }
2329 }
2330 else if let Some((root, field, "status")) =
2334 expr_as_nested_member_access(left)
2335 {
2336 let root_entity = resolve_binding_entity(
2337 root, None, binding_types, status_by_entity,
2338 );
2339 if let Some(root_entity) = root_entity {
2340 if let Some(nested_entity) = field_entity_types
2341 .get(root_entity)
2342 .and_then(|fields| fields.get(field).copied())
2343 {
2344 cb("_nested_", target, nested_entity);
2347 }
2348 }
2349 }
2350 }
2351 }
2352 Expr::Block { items, .. } => {
2353 for item in items {
2354 visit_status_assignments(
2355 item,
2356 binding_types,
2357 status_by_entity,
2358 field_entity_types,
2359 cb,
2360 );
2361 }
2362 }
2363 Expr::Conditional {
2364 branches,
2365 else_body,
2366 ..
2367 } => {
2368 for branch in branches {
2369 visit_status_assignments(
2370 &branch.body,
2371 binding_types,
2372 status_by_entity,
2373 field_entity_types,
2374 cb,
2375 );
2376 }
2377 if let Some(body) = else_body {
2378 visit_status_assignments(
2379 body,
2380 binding_types,
2381 status_by_entity,
2382 field_entity_types,
2383 cb,
2384 );
2385 }
2386 }
2387 _ => {}
2388 }
2389}
2390
2391fn visit_created_calls<'a>(
2395 expr: &'a Expr,
2396 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2397 has_transitions: &HashSet<&'a str>,
2398 on_status: &mut impl FnMut(&'a str, &'a str),
2399 issues: &mut Vec<Diagnostic>,
2400) {
2401 match expr {
2402 Expr::Call {
2403 function, args, span, ..
2404 } => {
2405 if let Expr::MemberAccess { object, field, .. } = function.as_ref() {
2406 if field.name == "created" {
2407 if let Expr::Ident(entity_ident) = object.as_ref() {
2408 let entity_name = entity_ident.name.as_str();
2409 if let Some((_, values)) = status_by_entity.get(entity_name) {
2410 let status_arg = args.iter().find_map(|arg| {
2411 if let CallArg::Named(named) = arg {
2412 if named.name.name == "status" {
2413 return Some(named);
2414 }
2415 }
2416 None
2417 });
2418
2419 match status_arg {
2420 Some(named) => {
2421 if let Expr::Ident(status_ident) = &named.value {
2422 let status = status_ident.name.as_str();
2423 if values.contains(status) {
2424 on_status(entity_name, status);
2425 } else {
2426 issues.push(
2427 Diagnostic::error(
2428 named.value.span(),
2429 format!(
2430 ".created() on entity '{entity_name}' sets status to '{status}', which is not a declared status value.",
2431 ),
2432 )
2433 .with_code("allium.created.invalidStatus"),
2434 );
2435 }
2436 }
2437 }
2438 None => {
2439 if has_transitions.contains(entity_name) {
2440 issues.push(
2441 Diagnostic::warning(
2442 *span,
2443 format!(
2444 ".created() on entity '{entity_name}' omits the status field, but the entity has a transition graph. The initial state is unspecified.",
2445 ),
2446 )
2447 .with_code("allium.created.missingStatus"),
2448 );
2449 }
2450 }
2451 }
2452 }
2453 }
2454 }
2455 }
2456 }
2457 Expr::Block { items, .. } => {
2458 for item in items {
2459 visit_created_calls(item, status_by_entity, has_transitions, on_status, issues);
2460 }
2461 }
2462 Expr::Conditional {
2463 branches,
2464 else_body,
2465 ..
2466 } => {
2467 for branch in branches {
2468 visit_created_calls(
2469 &branch.body,
2470 status_by_entity,
2471 has_transitions,
2472 on_status,
2473 issues,
2474 );
2475 }
2476 if let Some(body) = else_body {
2477 visit_created_calls(body, status_by_entity, has_transitions, on_status, issues);
2478 }
2479 }
2480 _ => {}
2481 }
2482}
2483
2484fn visit_status_comparisons<'a>(
2485 expr: &'a Expr,
2486 binding_types: &HashMap<&'a str, &'a str>,
2487 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2488 field_entity_types: &HashMap<&'a str, HashMap<&'a str, &'a str>>,
2489 cb: &mut impl FnMut(&'a str, &'a str),
2490) {
2491 match expr {
2492 Expr::Comparison {
2493 left,
2494 op: ComparisonOp::Eq,
2495 right,
2496 ..
2497 } => {
2498 if let Some(target) = expr_as_ident(right) {
2499 if let Some((binding, "status")) = expr_as_member_access(left) {
2501 let known = resolve_binding_entity(
2502 binding,
2503 Some(target),
2504 binding_types,
2505 status_by_entity,
2506 )
2507 .is_some();
2508 if known {
2509 cb(binding, target);
2510 }
2511 }
2512 }
2517 }
2518 Expr::LogicalOp { left, right, .. } => {
2519 visit_status_comparisons(left, binding_types, status_by_entity, field_entity_types, cb);
2520 visit_status_comparisons(right, binding_types, status_by_entity, field_entity_types, cb);
2521 }
2522 Expr::Block { items, .. } => {
2523 for item in items {
2524 visit_status_comparisons(item, binding_types, status_by_entity, field_entity_types, cb);
2525 }
2526 }
2527 _ => {}
2528 }
2529}
2530
2531fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
2532 match expr {
2533 Expr::MemberAccess { object, field, .. } => {
2534 expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
2535 }
2536 _ => None,
2537 }
2538}
2539
2540fn expr_as_nested_member_access(expr: &Expr) -> Option<(&str, &str, &str)> {
2542 if let Expr::MemberAccess {
2543 object, field: last, ..
2544 } = expr
2545 {
2546 if let Expr::MemberAccess {
2547 object: root_obj,
2548 field: mid,
2549 ..
2550 } = object.as_ref()
2551 {
2552 if let Expr::Ident(root) = root_obj.as_ref() {
2553 return Some((&root.name, &mid.name, &last.name));
2554 }
2555 }
2556 }
2557 None
2558}
2559
2560fn resolve_binding_entity<'a>(
2565 binding: &str,
2566 target: Option<&str>,
2567 binding_types: &HashMap<&'a str, &'a str>,
2568 status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
2569) -> Option<&'a str> {
2570 binding_types
2571 .get(binding)
2572 .copied()
2573 .or_else(|| {
2574 status_by_entity
2575 .keys()
2576 .find(|name| name.eq_ignore_ascii_case(binding))
2577 .copied()
2578 })
2579 .or_else(|| {
2580 let target = target?;
2582 let mut candidates = status_by_entity
2583 .iter()
2584 .filter(|(_, (_, values))| values.contains(target));
2585 let first = candidates.next()?;
2586 if candidates.next().is_none() {
2587 Some(first.0)
2588 } else {
2589 None
2590 }
2591 })
2592}
2593
2594fn extract_field_entity_type(expr: &Expr) -> Option<&str> {
2597 match expr {
2598 Expr::Ident(id) if starts_uppercase(&id.name) => Some(&id.name),
2599 Expr::JoinLookup { entity, .. } => {
2600 if let Expr::Ident(id) = entity.as_ref() {
2601 if starts_uppercase(&id.name) {
2602 return Some(&id.name);
2603 }
2604 }
2605 None
2606 }
2607 _ => None,
2608 }
2609}
2610
2611fn is_likely_terminal(status: &str) -> bool {
2612 matches!(
2613 status,
2614 "completed"
2615 | "cancelled"
2616 | "canceled"
2617 | "expired"
2618 | "closed"
2619 | "deleted"
2620 | "archived"
2621 | "failed"
2622 | "rejected"
2623 | "done"
2624 )
2625}
2626
2627impl Ctx<'_> {
2632 fn check_external_entity_source_hints(&mut self) {
2633 if self.has_use_imports() {
2634 return;
2635 }
2636
2637 let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
2638
2639 for entity in self.blocks(BlockKind::ExternalEntity) {
2640 let name = match &entity.name {
2641 Some(n) => n,
2642 None => continue,
2643 };
2644
2645 let referenced_in_rules = rule_blocks
2646 .iter()
2647 .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
2648
2649 let msg = format!(
2650 "External entity '{}' has no obvious governing specification import in this module.",
2651 name.name
2652 );
2653 if referenced_in_rules {
2654 self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
2655 } else {
2656 self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
2657 }
2658 }
2659 }
2660}
2661
2662impl Ctx<'_> {
2667 fn check_type_references(&mut self) {
2668 let known = self.declared_type_names();
2669
2670 for d in &self.module.declarations {
2671 let block = match d {
2672 Decl::Block(b)
2673 if matches!(
2674 b.kind,
2675 BlockKind::Entity
2676 | BlockKind::ExternalEntity
2677 | BlockKind::Value
2678 ) =>
2679 {
2680 b
2681 }
2682 Decl::Variant(v) => {
2683 for item in &v.items {
2685 self.check_type_ref_in_item(item, &known);
2686 }
2687 continue;
2688 }
2689 _ => continue,
2690 };
2691
2692 for item in &block.items {
2693 self.check_type_ref_in_item(item, &known);
2694 }
2695 }
2696
2697 for rule in self.blocks(BlockKind::Rule) {
2699 for item in &rule.items {
2700 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2701 continue;
2702 };
2703 if keyword == "when" || keyword == "ensures" || keyword == "requires" {
2704 self.check_type_refs_in_rule_expr(value, &known);
2705 }
2706 }
2707 }
2708 }
2709
2710 fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
2711 match &item.kind {
2712 BlockItemKind::Assignment { value, .. }
2713 | BlockItemKind::FieldWithWhen { value, .. } => {
2714 self.check_type_refs_in_value(value, known);
2715 }
2716 _ => {}
2717 }
2718 }
2719
2720 fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
2721 match expr {
2722 Expr::Ident(id) if starts_uppercase(&id.name) => {
2723 if !known.contains(id.name.as_str()) {
2724 self.push(
2725 Diagnostic::error(
2726 id.span,
2727 format!(
2728 "Type reference '{}' is not declared locally or imported.",
2729 id.name
2730 ),
2731 )
2732 .with_code("allium.type.undefinedReference"),
2733 );
2734 }
2735 }
2736 Expr::GenericType { name, args, .. } => {
2737 self.check_type_refs_in_value(name, known);
2738 for arg in args {
2739 self.check_type_refs_in_value(arg, known);
2740 }
2741 }
2742 Expr::Pipe { left, right, .. } => {
2743 self.check_type_refs_in_value(left, known);
2744 self.check_type_refs_in_value(right, known);
2745 }
2746 Expr::TypeOptional { inner, .. } => {
2747 self.check_type_refs_in_value(inner, known);
2748 }
2749 _ => {}
2750 }
2751 }
2752
2753 fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
2754 match expr {
2755 Expr::Binding { value, .. } => {
2757 self.check_type_refs_in_rule_expr(value, known);
2758 }
2759 Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
2760 if let Expr::MemberAccess { object, .. } = subject.as_ref() {
2761 if let Expr::Ident(id) = object.as_ref() {
2762 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
2763 self.push(
2764 Diagnostic::error(
2765 id.span,
2766 format!(
2767 "Type reference '{}' is not declared locally or imported.",
2768 id.name
2769 ),
2770 )
2771 .with_code("allium.rule.undefinedTypeReference"),
2772 );
2773 }
2774 }
2775 }
2776 }
2777 Expr::Call { function, .. } => {
2779 if let Expr::MemberAccess { object, .. } = function.as_ref() {
2780 if let Expr::Ident(id) = object.as_ref() {
2781 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
2782 self.push(
2783 Diagnostic::error(
2784 id.span,
2785 format!(
2786 "Type reference '{}' is not declared locally or imported.",
2787 id.name
2788 ),
2789 )
2790 .with_code("allium.rule.undefinedTypeReference"),
2791 );
2792 }
2793 }
2794 }
2795 }
2796 Expr::MemberAccess { object, .. } => {
2798 if let Expr::Ident(id) = object.as_ref() {
2799 if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
2800 self.push(
2801 Diagnostic::error(
2802 id.span,
2803 format!(
2804 "Type reference '{}' is not declared locally or imported.",
2805 id.name
2806 ),
2807 )
2808 .with_code("allium.rule.undefinedTypeReference"),
2809 );
2810 }
2811 }
2812 }
2813 Expr::Block { items, .. } => {
2814 for item in items {
2815 self.check_type_refs_in_rule_expr(item, known);
2816 }
2817 }
2818 Expr::LogicalOp { left, right, .. } => {
2819 self.check_type_refs_in_rule_expr(left, known);
2820 self.check_type_refs_in_rule_expr(right, known);
2821 }
2822 _ => {}
2823 }
2824 }
2825}
2826
2827impl Ctx<'_> {
2832 fn check_unreachable_triggers(&mut self) {
2833 let mut provided: HashSet<&str> = HashSet::new();
2835 for surface in self.blocks(BlockKind::Surface) {
2836 for item in &surface.items {
2837 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2838 continue;
2839 };
2840 if keyword != "provides" {
2841 continue;
2842 }
2843 collect_call_names(value, &mut provided);
2844 }
2845 }
2846
2847 let mut emitted: HashSet<&str> = HashSet::new();
2851 for rule in self.blocks(BlockKind::Rule) {
2852 for item in &rule.items {
2853 collect_emitted_trigger_from_item(&item.kind, &mut emitted);
2854 }
2855 }
2856
2857 for rule in self.blocks(BlockKind::Rule) {
2858 let rule_name = match &rule.name {
2859 Some(n) => &n.name,
2860 None => continue,
2861 };
2862 for item in &rule.items {
2863 let BlockItemKind::Clause { keyword, value } = &item.kind else {
2864 continue;
2865 };
2866 if keyword != "when" {
2867 continue;
2868 }
2869 let trigger_names = extract_trigger_names(value);
2870 for (name, span) in trigger_names {
2871 if !provided.contains(name) && !emitted.contains(name) {
2872 self.push(
2873 Diagnostic::info(
2874 span,
2875 format!(
2876 "Rule '{rule_name}' listens for trigger '{name}' but no local surface provides or rule emits it.",
2877 ),
2878 )
2879 .with_code("allium.rule.unreachableTrigger"),
2880 );
2881 }
2882 }
2883 }
2884 }
2885 }
2886}
2887
2888fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
2891 match kind {
2892 BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
2893 collect_leading_ensures_call(value, out);
2894 }
2895 BlockItemKind::ForBlock { items, .. } => {
2896 for item in items {
2897 collect_emitted_trigger_from_item(&item.kind, out);
2898 }
2899 }
2900 BlockItemKind::IfBlock { branches, else_items, .. } => {
2901 for b in branches {
2902 for item in &b.items {
2903 collect_emitted_trigger_from_item(&item.kind, out);
2904 }
2905 }
2906 if let Some(items) = else_items {
2907 for item in items {
2908 collect_emitted_trigger_from_item(&item.kind, out);
2909 }
2910 }
2911 }
2912 _ => {}
2913 }
2914}
2915
2916fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
2920 match expr {
2921 Expr::Call { function, .. } => {
2922 if let Expr::Ident(id) = function.as_ref() {
2923 if starts_uppercase(&id.name) {
2924 out.insert(&id.name);
2925 }
2926 }
2927 }
2928 Expr::Block { items, .. } => {
2929 if let Some(first) = items.first() {
2930 collect_leading_ensures_call(first, out);
2931 }
2932 }
2933 _ => {}
2934 }
2935}
2936
2937fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
2938 match expr {
2939 Expr::Call { function, .. } => {
2940 if let Expr::Ident(id) = function.as_ref() {
2941 if starts_uppercase(&id.name) {
2942 out.insert(&id.name);
2943 }
2944 }
2945 }
2946 Expr::Block { items, .. } => {
2947 for item in items {
2948 collect_call_names(item, out);
2949 }
2950 }
2951 Expr::WhenGuard { action, .. } => {
2952 collect_call_names(action, out);
2953 }
2954 Expr::Conditional { branches, else_body, .. } => {
2955 for b in branches {
2956 collect_call_names(&b.body, out);
2957 }
2958 if let Some(body) = else_body {
2959 collect_call_names(body, out);
2960 }
2961 }
2962 _ => {}
2963 }
2964}
2965
2966fn extract_trigger_names(expr: &Expr) -> Vec<(&str, Span)> {
2967 match expr {
2968 Expr::Call { function, .. } => {
2969 if let Expr::Ident(id) = function.as_ref() {
2970 if starts_uppercase(&id.name) {
2971 return vec![(&id.name, id.span)];
2972 }
2973 }
2974 vec![]
2975 }
2976 Expr::Binding { .. } => {
2977 vec![]
2979 }
2980 Expr::LogicalOp { left, right, .. } => {
2981 let mut out = extract_trigger_names(left);
2982 out.extend(extract_trigger_names(right));
2983 out
2984 }
2985 _ => vec![],
2986 }
2987}
2988
2989impl Ctx<'_> {
2994 fn check_unused_fields(&mut self) {
2995 let accessed = self.collect_all_accessed_field_names();
2996
2997 for d in &self.module.declarations {
2998 let block = match d {
2999 Decl::Block(b)
3000 if matches!(
3001 b.kind,
3002 BlockKind::Entity | BlockKind::ExternalEntity
3003 ) =>
3004 {
3005 b
3006 }
3007 Decl::Variant(v) => {
3008 let entity_name = &v.name.name;
3009 for item in &v.items {
3010 if let BlockItemKind::Assignment { name, .. }
3011 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3012 {
3013 if !accessed.contains(name.name.as_str()) {
3014 self.push(
3015 Diagnostic::info(
3016 name.span,
3017 format!(
3018 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3019 name.name
3020 ),
3021 )
3022 .with_code("allium.field.unused"),
3023 );
3024 }
3025 }
3026 }
3027 continue;
3028 }
3029 _ => continue,
3030 };
3031
3032 let entity_name = match &block.name {
3033 Some(n) => &n.name,
3034 None => continue,
3035 };
3036
3037 for item in &block.items {
3038 if let BlockItemKind::Assignment { name, .. }
3039 | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
3040 {
3041 if !accessed.contains(name.name.as_str()) {
3042 self.push(
3043 Diagnostic::info(
3044 name.span,
3045 format!(
3046 "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
3047 name.name
3048 ),
3049 )
3050 .with_code("allium.field.unused"),
3051 );
3052 }
3053 }
3054 }
3055 }
3056 }
3057}
3058
3059fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3060 match kind {
3061 BlockItemKind::Clause { value, .. }
3062 | BlockItemKind::Assignment { value, .. }
3063 | BlockItemKind::ParamAssignment { value, .. }
3064 | BlockItemKind::Let { value, .. }
3065 | BlockItemKind::PathAssignment { value, .. }
3066 | BlockItemKind::InvariantBlock { body: value, .. }
3067 | BlockItemKind::FieldWithWhen { value, .. } => {
3068 collect_accessed_fields_from_expr(value, out);
3069 }
3070 BlockItemKind::ForBlock {
3071 collection,
3072 filter,
3073 items,
3074 ..
3075 } => {
3076 collect_accessed_fields_from_expr(collection, out);
3077 if let Some(f) = filter {
3078 collect_accessed_fields_from_expr(f, out);
3079 }
3080 for item in items {
3081 collect_accessed_fields_from_item(&item.kind, out);
3082 }
3083 }
3084 BlockItemKind::IfBlock {
3085 branches,
3086 else_items,
3087 } => {
3088 for b in branches {
3089 collect_accessed_fields_from_expr(&b.condition, out);
3090 for item in &b.items {
3091 collect_accessed_fields_from_item(&item.kind, out);
3092 }
3093 }
3094 if let Some(items) = else_items {
3095 for item in items {
3096 collect_accessed_fields_from_item(&item.kind, out);
3097 }
3098 }
3099 }
3100 _ => {}
3101 }
3102}
3103
3104fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3105 match expr {
3106 Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
3107 out.insert(&field.name);
3108 collect_accessed_fields_from_expr(object, out);
3109 }
3110 Expr::Call { function, args, .. } => {
3111 collect_accessed_fields_from_expr(function, out);
3112 for a in args {
3113 match a {
3114 CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
3115 CallArg::Named(n) => collect_accessed_fields_from_expr(&n.value, out),
3116 }
3117 }
3118 }
3119 Expr::BinaryOp { left, right, .. }
3120 | Expr::Comparison { left, right, .. }
3121 | Expr::LogicalOp { left, right, .. }
3122 | Expr::Pipe { left, right, .. }
3123 | Expr::NullCoalesce { left, right, .. } => {
3124 collect_accessed_fields_from_expr(left, out);
3125 collect_accessed_fields_from_expr(right, out);
3126 }
3127 Expr::Not { operand, .. }
3128 | Expr::Exists { operand, .. }
3129 | Expr::NotExists { operand, .. }
3130 | Expr::TypeOptional { inner: operand, .. } => {
3131 collect_accessed_fields_from_expr(operand, out);
3132 }
3133 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3134 collect_accessed_fields_from_expr(element, out);
3135 collect_accessed_fields_from_expr(collection, out);
3136 }
3137 Expr::Where { source, condition, .. }
3138 | Expr::With {
3139 source,
3140 predicate: condition,
3141 ..
3142 } => {
3143 collect_accessed_fields_from_expr(source, out);
3144 collect_accessed_fields_from_expr(condition, out);
3145 }
3146 Expr::WhenGuard { action, condition, .. } => {
3147 collect_accessed_fields_from_expr(action, out);
3148 collect_accessed_fields_from_expr(condition, out);
3149 }
3150 Expr::Block { items, .. } => {
3151 for item in items {
3152 collect_accessed_fields_from_expr(item, out);
3153 }
3154 }
3155 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3156 collect_accessed_fields_from_expr(value, out);
3157 }
3158 Expr::Conditional { branches, else_body, .. } => {
3159 for b in branches {
3160 collect_accessed_fields_from_expr(&b.condition, out);
3161 collect_accessed_fields_from_expr(&b.body, out);
3162 }
3163 if let Some(body) = else_body {
3164 collect_accessed_fields_from_expr(body, out);
3165 }
3166 }
3167 Expr::For { collection, filter, body, .. } => {
3168 collect_accessed_fields_from_expr(collection, out);
3169 if let Some(f) = filter {
3170 collect_accessed_fields_from_expr(f, out);
3171 }
3172 collect_accessed_fields_from_expr(body, out);
3173 }
3174 Expr::Lambda { body, .. } => {
3175 collect_accessed_fields_from_expr(body, out);
3176 }
3177 Expr::JoinLookup { entity, fields, .. } => {
3178 collect_accessed_fields_from_expr(entity, out);
3179 for f in fields {
3180 out.insert(&f.field.name);
3181 if let Some(v) = &f.value {
3182 collect_accessed_fields_from_expr(v, out);
3183 }
3184 }
3185 }
3186 Expr::TransitionsTo { subject, new_state, .. }
3187 | Expr::Becomes { subject, new_state, .. } => {
3188 collect_accessed_fields_from_expr(subject, out);
3189 collect_accessed_fields_from_expr(new_state, out);
3190 }
3191 Expr::SetLiteral { elements, .. } => {
3192 for e in elements {
3193 collect_accessed_fields_from_expr(e, out);
3194 }
3195 }
3196 Expr::ObjectLiteral { fields, .. } => {
3197 for f in fields {
3198 collect_accessed_fields_from_expr(&f.value, out);
3199 }
3200 }
3201 Expr::GenericType { name, args, .. } => {
3202 collect_accessed_fields_from_expr(name, out);
3203 for a in args {
3204 collect_accessed_fields_from_expr(a, out);
3205 }
3206 }
3207 Expr::ProjectionMap { source, .. } => {
3208 collect_accessed_fields_from_expr(source, out);
3209 }
3210 _ => {}
3211 }
3212}
3213
3214impl Ctx<'_> {
3219 fn check_unused_entities(&mut self) {
3220 let mut all_idents = self.collect_all_referenced_idents();
3221 for v in self.variants() {
3223 let base = expr_as_ident(&v.base).or_else(|| {
3224 if let Expr::JoinLookup { entity, .. } = &v.base {
3225 expr_as_ident(entity)
3226 } else {
3227 None
3228 }
3229 });
3230 if let Some(name) = base {
3231 all_idents.insert(name);
3232 }
3233 }
3234 let mut findings = Vec::new();
3235
3236 for d in &self.module.declarations {
3237 let block = match d {
3238 Decl::Block(b)
3239 if matches!(
3240 b.kind,
3241 BlockKind::Entity | BlockKind::ExternalEntity
3242 ) =>
3243 {
3244 b
3245 }
3246 _ => continue,
3247 };
3248 let name = match &block.name {
3249 Some(n) => n,
3250 None => continue,
3251 };
3252 if !all_idents.contains(name.name.as_str()) {
3253 findings.push(
3254 Diagnostic::warning(
3255 name.span,
3256 format!(
3257 "Entity '{}' is declared but not referenced elsewhere in this specification.",
3258 name.name
3259 ),
3260 )
3261 .with_code("allium.entity.unused"),
3262 );
3263 }
3264 }
3265 self.diagnostics.extend(findings);
3266 }
3267
3268 fn check_unused_definitions(&mut self) {
3269 let all_idents = self.collect_all_referenced_idents();
3270 let mut findings = Vec::new();
3271
3272 for d in &self.module.declarations {
3273 match d {
3274 Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
3275 let name = match &b.name {
3276 Some(n) => n,
3277 None => continue,
3278 };
3279 if !all_idents.contains(name.name.as_str()) {
3280 findings.push(
3281 Diagnostic::warning(
3282 name.span,
3283 format!(
3284 "Value '{}' is declared but not referenced elsewhere.",
3285 name.name
3286 ),
3287 )
3288 .with_code("allium.definition.unused"),
3289 );
3290 }
3291 }
3292 _ => {}
3293 }
3294 }
3295 self.diagnostics.extend(findings);
3296 }
3297
3298 fn collect_all_referenced_idents(&self) -> HashSet<&str> {
3301 let mut names = HashSet::new();
3302 for d in &self.module.declarations {
3303 match d {
3304 Decl::Block(b) => {
3305 for item in &b.items {
3306 collect_uppercase_idents_from_item(&item.kind, &mut names);
3307 }
3308 }
3309 Decl::Variant(v) => {
3310 if let Some(name) = expr_as_ident(&v.base) {
3312 names.insert(name);
3313 }
3314 for item in &v.items {
3315 collect_uppercase_idents_from_item(&item.kind, &mut names);
3316 }
3317 }
3318 Decl::Invariant(inv) => {
3319 collect_uppercase_idents_from_expr(&inv.body, &mut names);
3320 }
3321 Decl::Default(def) => {
3322 if let Some(tn) = &def.type_name {
3323 names.insert(tn.name.as_str());
3324 }
3325 collect_uppercase_idents_from_expr(&def.value, &mut names);
3326 }
3327 _ => {}
3328 }
3329 }
3330 names
3331 }
3332}
3333
3334fn collect_uppercase_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
3335 match kind {
3336 BlockItemKind::Clause { value, .. }
3337 | BlockItemKind::Assignment { value, .. }
3338 | BlockItemKind::ParamAssignment { value, .. }
3339 | BlockItemKind::Let { value, .. }
3340 | BlockItemKind::PathAssignment { value, .. }
3341 | BlockItemKind::InvariantBlock { body: value, .. }
3342 | BlockItemKind::FieldWithWhen { value, .. } => {
3343 collect_uppercase_idents_from_expr(value, out);
3344 }
3345 BlockItemKind::ForBlock {
3346 collection,
3347 filter,
3348 items,
3349 ..
3350 } => {
3351 collect_uppercase_idents_from_expr(collection, out);
3352 if let Some(f) = filter {
3353 collect_uppercase_idents_from_expr(f, out);
3354 }
3355 for item in items {
3356 collect_uppercase_idents_from_item(&item.kind, out);
3357 }
3358 }
3359 BlockItemKind::IfBlock {
3360 branches,
3361 else_items,
3362 } => {
3363 for b in branches {
3364 collect_uppercase_idents_from_expr(&b.condition, out);
3365 for item in &b.items {
3366 collect_uppercase_idents_from_item(&item.kind, out);
3367 }
3368 }
3369 if let Some(items) = else_items {
3370 for item in items {
3371 collect_uppercase_idents_from_item(&item.kind, out);
3372 }
3373 }
3374 }
3375 BlockItemKind::ContractsClause { entries } => {
3376 for e in entries {
3377 out.insert(e.name.name.as_str());
3378 }
3379 }
3380 _ => {}
3381 }
3382}
3383
3384fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3385 match expr {
3386 Expr::Ident(id) if starts_uppercase(&id.name) => {
3387 out.insert(&id.name);
3388 }
3389 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
3390 collect_uppercase_idents_from_expr(object, out);
3391 }
3392 Expr::Call { function, args, .. } => {
3393 collect_uppercase_idents_from_expr(function, out);
3394 for a in args {
3395 match a {
3396 CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
3397 CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
3398 }
3399 }
3400 }
3401 Expr::JoinLookup { entity, fields, .. } => {
3402 collect_uppercase_idents_from_expr(entity, out);
3403 for f in fields {
3404 if let Some(v) = &f.value {
3405 collect_uppercase_idents_from_expr(v, out);
3406 }
3407 }
3408 }
3409 Expr::BinaryOp { left, right, .. }
3410 | Expr::Comparison { left, right, .. }
3411 | Expr::LogicalOp { left, right, .. }
3412 | Expr::Pipe { left, right, .. }
3413 | Expr::NullCoalesce { left, right, .. } => {
3414 collect_uppercase_idents_from_expr(left, out);
3415 collect_uppercase_idents_from_expr(right, out);
3416 }
3417 Expr::Not { operand, .. }
3418 | Expr::Exists { operand, .. }
3419 | Expr::NotExists { operand, .. }
3420 | Expr::TypeOptional { inner: operand, .. } => {
3421 collect_uppercase_idents_from_expr(operand, out);
3422 }
3423 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3424 collect_uppercase_idents_from_expr(element, out);
3425 collect_uppercase_idents_from_expr(collection, out);
3426 }
3427 Expr::Where { source, condition, .. }
3428 | Expr::With {
3429 source,
3430 predicate: condition,
3431 ..
3432 } => {
3433 collect_uppercase_idents_from_expr(source, out);
3434 collect_uppercase_idents_from_expr(condition, out);
3435 }
3436 Expr::WhenGuard { action, condition, .. } => {
3437 collect_uppercase_idents_from_expr(action, out);
3438 collect_uppercase_idents_from_expr(condition, out);
3439 }
3440 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3441 collect_uppercase_idents_from_expr(value, out);
3442 }
3443 Expr::Block { items, .. } => {
3444 for item in items {
3445 collect_uppercase_idents_from_expr(item, out);
3446 }
3447 }
3448 Expr::Conditional { branches, else_body, .. } => {
3449 for b in branches {
3450 collect_uppercase_idents_from_expr(&b.condition, out);
3451 collect_uppercase_idents_from_expr(&b.body, out);
3452 }
3453 if let Some(body) = else_body {
3454 collect_uppercase_idents_from_expr(body, out);
3455 }
3456 }
3457 Expr::For { collection, filter, body, .. } => {
3458 collect_uppercase_idents_from_expr(collection, out);
3459 if let Some(f) = filter {
3460 collect_uppercase_idents_from_expr(f, out);
3461 }
3462 collect_uppercase_idents_from_expr(body, out);
3463 }
3464 Expr::Lambda { body, .. } => {
3465 collect_uppercase_idents_from_expr(body, out);
3466 }
3467 Expr::TransitionsTo { subject, new_state, .. }
3468 | Expr::Becomes { subject, new_state, .. } => {
3469 collect_uppercase_idents_from_expr(subject, out);
3470 collect_uppercase_idents_from_expr(new_state, out);
3471 }
3472 Expr::GenericType { name, args, .. } => {
3473 collect_uppercase_idents_from_expr(name, out);
3474 for a in args {
3475 collect_uppercase_idents_from_expr(a, out);
3476 }
3477 }
3478 Expr::SetLiteral { elements, .. } => {
3479 for e in elements {
3480 collect_uppercase_idents_from_expr(e, out);
3481 }
3482 }
3483 Expr::ObjectLiteral { fields, .. } => {
3484 for f in fields {
3485 collect_uppercase_idents_from_expr(&f.value, out);
3486 }
3487 }
3488 Expr::ProjectionMap { source, .. } => {
3489 collect_uppercase_idents_from_expr(source, out);
3490 }
3491 Expr::QualifiedName(q) => {
3492 out.insert(&q.name);
3493 }
3494 _ => {}
3495 }
3496}
3497
3498impl Ctx<'_> {
3503 fn check_deferred_location_hints(&mut self) {
3504 for d in &self.module.declarations {
3505 let Decl::Deferred(def) = d else {
3506 continue;
3507 };
3508 self.push(
3512 Diagnostic::warning(
3513 def.span,
3514 format!(
3515 "Deferred specification '{}' should include a location hint.",
3516 expr_to_dotpath(&def.path),
3517 ),
3518 )
3519 .with_code("allium.deferred.missingLocationHint"),
3520 );
3521 }
3522 }
3523}
3524
3525fn expr_to_dotpath(expr: &Expr) -> String {
3526 match expr {
3527 Expr::Ident(id) => id.name.clone(),
3528 Expr::MemberAccess { object, field, .. } => {
3529 format!("{}.{}", expr_to_dotpath(object), field.name)
3530 }
3531 _ => "?".to_string(),
3532 }
3533}
3534
3535impl Ctx<'_> {
3540 fn check_rule_invalid_triggers(&mut self) {
3541 for rule in self.blocks(BlockKind::Rule) {
3542 let rule_name = match &rule.name {
3543 Some(n) => &n.name,
3544 None => continue,
3545 };
3546
3547 for item in &rule.items {
3548 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3549 continue;
3550 };
3551 if keyword != "when" {
3552 continue;
3553 }
3554 if !is_valid_trigger(value) {
3555 self.push(
3556 Diagnostic::error(
3557 item.span,
3558 format!(
3559 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
3560 ),
3561 )
3562 .with_code("allium.rule.invalidTrigger"),
3563 );
3564 }
3565 }
3566 }
3567 }
3568}
3569
3570fn is_valid_trigger(expr: &Expr) -> bool {
3571 match expr {
3572 Expr::Call { function, .. } => {
3574 matches!(function.as_ref(), Expr::Ident(_) | Expr::MemberAccess { .. })
3575 }
3576 Expr::Binding { value, .. } => {
3578 matches!(
3579 value.as_ref(),
3580 Expr::Becomes { .. }
3581 | Expr::TransitionsTo { .. }
3582 | Expr::MemberAccess { .. }
3583 | Expr::Comparison { .. }
3584 )
3585 }
3586 Expr::LogicalOp {
3588 op: LogicalOp::Or,
3589 left,
3590 right,
3591 ..
3592 } => is_valid_trigger(left) && is_valid_trigger(right),
3593 Expr::Comparison { left, .. } => {
3595 matches!(left.as_ref(), Expr::MemberAccess { .. })
3596 }
3597 _ => false,
3598 }
3599}
3600
3601impl Ctx<'_> {
3606 fn check_rule_undefined_bindings(&mut self) {
3607 let mut given_bindings: HashSet<&str> = HashSet::new();
3609 for given in self.blocks(BlockKind::Given) {
3610 for item in &given.items {
3611 if let BlockItemKind::Assignment { name, .. } = &item.kind {
3612 given_bindings.insert(&name.name);
3613 }
3614 }
3615 }
3616
3617 let mut default_names: HashSet<&str> = HashSet::new();
3619 for d in &self.module.declarations {
3620 if let Decl::Default(def) = d {
3621 default_names.insert(&def.name.name);
3622 }
3623 }
3624
3625 for rule in self.blocks(BlockKind::Rule) {
3626 let rule_name = match &rule.name {
3627 Some(n) => &n.name,
3628 None => continue,
3629 };
3630
3631 let mut bound: HashSet<&str> = HashSet::new();
3632 bound.extend(&given_bindings);
3633 bound.extend(&default_names);
3634
3635 for item in &rule.items {
3637 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3638 continue;
3639 };
3640 if keyword != "when" {
3641 continue;
3642 }
3643 collect_bound_names(value, &mut bound);
3644 }
3645
3646 for item in &rule.items {
3648 if let BlockItemKind::Let { name, .. } = &item.kind {
3649 bound.insert(&name.name);
3650 }
3651 }
3652
3653 for item in &rule.items {
3655 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3656 continue;
3657 };
3658 if keyword != "requires" && keyword != "ensures" {
3659 continue;
3660 }
3661 check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
3662 }
3663
3664 for item in &rule.items {
3666 match &item.kind {
3667 BlockItemKind::ForBlock {
3668 binding,
3669 items,
3670 ..
3671 } => {
3672 let mut inner_bound = bound.clone();
3673 match binding {
3674 ForBinding::Single(id) => { inner_bound.insert(&id.name); }
3675 ForBinding::Destructured(ids, _) => {
3676 for id in ids {
3677 inner_bound.insert(&id.name);
3678 }
3679 }
3680 }
3681 for sub_item in items {
3682 if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
3683 if keyword == "ensures" || keyword == "requires" {
3684 check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
3685 }
3686 }
3687 }
3688 }
3689 _ => {}
3690 }
3691 }
3692
3693 for item in &rule.items {
3697 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
3698 if keyword != "when" { continue }
3699 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
3700 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
3701 continue;
3702 }
3703 let mut found = false;
3705 for check_item in &rule.items {
3706 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
3707 if kw != "requires" && kw != "ensures" { continue }
3708 if expr_contains_ident(v, &binding_name.name) {
3709 self.push(
3710 Diagnostic::error(
3711 check_item.span,
3712 format!(
3713 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
3714 binding_name.name
3715 ),
3716 )
3717 .with_code("allium.rule.undefinedBinding"),
3718 );
3719 found = true;
3720 break;
3721 }
3722 }
3723 if found { break; }
3724 }
3725 }
3726 }
3727}
3728
3729fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
3730 match expr {
3731 Expr::Binding { name, .. } => {
3732 out.insert(&name.name);
3733 }
3734 Expr::Call { args, .. } => {
3735 for arg in args {
3736 if let CallArg::Positional(Expr::Ident(id)) = arg {
3737 out.insert(&id.name);
3738 }
3739 }
3740 }
3741 Expr::LogicalOp { left, right, .. } => {
3742 collect_bound_names(left, out);
3743 collect_bound_names(right, out);
3744 }
3745 _ => {}
3746 }
3747}
3748
3749fn check_unbound_roots(
3750 expr: &Expr,
3751 bound: &HashSet<&str>,
3752 rule_name: &str,
3753 diagnostics: &mut Vec<Diagnostic>,
3754) {
3755 match expr {
3756 Expr::MemberAccess { object, .. } => {
3757 if let Expr::Ident(id) = object.as_ref() {
3758 if !starts_uppercase(&id.name)
3759 && !bound.contains(id.name.as_str())
3760 && !is_builtin_name(&id.name)
3761 {
3762 diagnostics.push(
3763 Diagnostic::error(
3764 id.span,
3765 format!(
3766 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
3767 id.name
3768 ),
3769 )
3770 .with_code("allium.rule.undefinedBinding"),
3771 );
3772 }
3773 }
3774 }
3775 Expr::Comparison { left, right, .. } => {
3776 check_unbound_roots(left, bound, rule_name, diagnostics);
3777 check_unbound_roots(right, bound, rule_name, diagnostics);
3778 }
3779 Expr::LogicalOp { left, right, .. } => {
3780 check_unbound_roots(left, bound, rule_name, diagnostics);
3781 check_unbound_roots(right, bound, rule_name, diagnostics);
3782 }
3783 Expr::Block { items, .. } => {
3784 let mut block_bound = bound.clone();
3785 for item in items {
3786 if let Expr::LetExpr { name, value, .. } = item {
3787 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
3788 block_bound.insert(name.name.as_str());
3789 } else {
3790 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
3791 }
3792 }
3793 }
3794 Expr::For { binding, collection, body, .. } => {
3795 check_unbound_roots(collection, bound, rule_name, diagnostics);
3796 let mut inner = bound.clone();
3798 match binding {
3799 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
3800 ForBinding::Destructured(ids, _) => {
3801 for id in ids {
3802 inner.insert(id.name.as_str());
3803 }
3804 }
3805 }
3806 check_unbound_roots(body, &inner, rule_name, diagnostics);
3807 }
3808 Expr::BinaryOp { left, right, .. } => {
3809 check_unbound_roots(left, bound, rule_name, diagnostics);
3810 check_unbound_roots(right, bound, rule_name, diagnostics);
3811 }
3812 Expr::Call { function, args, .. } => {
3813 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
3815 check_unbound_roots(function, bound, rule_name, diagnostics);
3816 }
3817 let mut call_bound = bound.clone();
3819 for a in args {
3820 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
3821 if let Expr::Ident(id) = param.as_ref() {
3822 call_bound.insert(id.name.as_str());
3823 }
3824 }
3825 }
3826 for a in args {
3827 match a {
3828 CallArg::Positional(Expr::Lambda { body, .. }) => {
3829 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
3830 }
3831 CallArg::Positional(e) => {
3832 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
3833 }
3834 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
3835 }
3836 }
3837 }
3838 Expr::Not { operand, .. }
3839 | Expr::Exists { operand, .. }
3840 | Expr::NotExists { operand, .. } => {
3841 check_unbound_roots(operand, bound, rule_name, diagnostics);
3842 }
3843 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3844 check_unbound_roots(element, bound, rule_name, diagnostics);
3845 check_unbound_roots(collection, bound, rule_name, diagnostics);
3846 }
3847 Expr::Conditional { branches, else_body, .. } => {
3848 for b in branches {
3849 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
3850 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
3851 }
3852 if let Some(body) = else_body {
3853 check_unbound_roots(body, bound, rule_name, diagnostics);
3854 }
3855 }
3856 _ => {}
3857 }
3858}
3859
3860fn is_builtin_name(name: &str) -> bool {
3861 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
3862}
3863
3864impl Ctx<'_> {
3869 fn check_duplicate_let_bindings(&mut self) {
3870 for rule in self.blocks(BlockKind::Rule) {
3871 let mut seen: HashMap<&str, Span> = HashMap::new();
3872 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
3873 }
3874 }
3875
3876 fn check_duplicate_lets_in_items<'b>(
3877 &mut self,
3878 items: &'b [BlockItem],
3879 seen: &mut HashMap<&'b str, Span>,
3880 ) {
3881 for item in items {
3882 match &item.kind {
3883 BlockItemKind::Let { name, .. } => {
3884 if seen.contains_key(name.name.as_str()) {
3885 self.push(
3886 Diagnostic::error(
3887 name.span,
3888 format!("Duplicate let binding '{}' in this rule.", name.name),
3889 )
3890 .with_code("allium.let.duplicateBinding"),
3891 );
3892 } else {
3893 seen.insert(&name.name, name.span);
3894 }
3895 }
3896 BlockItemKind::ForBlock { items, .. } => {
3897 self.check_duplicate_lets_in_items(items, seen);
3898 }
3899 BlockItemKind::IfBlock {
3900 branches,
3901 else_items,
3902 } => {
3903 for b in branches {
3904 self.check_duplicate_lets_in_items(&b.items, seen);
3905 }
3906 if let Some(items) = else_items {
3907 self.check_duplicate_lets_in_items(items, seen);
3908 }
3909 }
3910 BlockItemKind::Clause { value, .. } => {
3911 self.check_duplicate_lets_in_expr(value, seen);
3912 }
3913 _ => {}
3914 }
3915 }
3916 }
3917
3918 fn check_duplicate_lets_in_expr<'b>(
3919 &mut self,
3920 expr: &'b Expr,
3921 seen: &mut HashMap<&'b str, Span>,
3922 ) {
3923 match expr {
3924 Expr::LetExpr { name, value, .. } => {
3925 if seen.contains_key(name.name.as_str()) {
3926 self.push(
3927 Diagnostic::error(
3928 name.span,
3929 format!("Duplicate let binding '{}' in this rule.", name.name),
3930 )
3931 .with_code("allium.let.duplicateBinding"),
3932 );
3933 } else {
3934 seen.insert(&name.name, name.span);
3935 }
3936 self.check_duplicate_lets_in_expr(value, seen);
3937 }
3938 Expr::Block { items, .. } => {
3939 for item in items {
3940 self.check_duplicate_lets_in_expr(item, seen);
3941 }
3942 }
3943 Expr::For { body, .. } => {
3944 self.check_duplicate_lets_in_expr(body, seen);
3945 }
3946 Expr::Conditional { branches, else_body, .. } => {
3947 for b in branches {
3948 self.check_duplicate_lets_in_expr(&b.body, seen);
3949 }
3950 if let Some(body) = else_body {
3951 self.check_duplicate_lets_in_expr(body, seen);
3952 }
3953 }
3954 _ => {}
3955 }
3956 }
3957}
3958
3959impl Ctx<'_> {
3964 fn check_config_undefined_references(&mut self) {
3965 let mut config_params: HashSet<&str> = HashSet::new();
3966 for config in self.blocks(BlockKind::Config) {
3967 for item in &config.items {
3968 if let BlockItemKind::Assignment { name, .. } = &item.kind {
3969 config_params.insert(&name.name);
3970 }
3971 }
3972 }
3973
3974 for d in &self.module.declarations {
3976 match d {
3977 Decl::Block(b) => {
3978 if b.kind == BlockKind::Config {
3979 continue;
3980 }
3981 for item in &b.items {
3982 self.check_config_refs_in_item(&item.kind, &config_params);
3983 }
3984 }
3985 Decl::Invariant(inv) => {
3986 self.check_config_refs_in_expr(&inv.body, &config_params);
3987 }
3988 _ => {}
3989 }
3990 }
3991 }
3992
3993 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
3994 match kind {
3995 BlockItemKind::Clause { value, .. }
3996 | BlockItemKind::Assignment { value, .. }
3997 | BlockItemKind::ParamAssignment { value, .. }
3998 | BlockItemKind::Let { value, .. }
3999 | BlockItemKind::FieldWithWhen { value, .. } => {
4000 self.check_config_refs_in_expr(value, params);
4001 }
4002 BlockItemKind::ForBlock { collection, filter, items, .. } => {
4003 self.check_config_refs_in_expr(collection, params);
4004 if let Some(f) = filter {
4005 self.check_config_refs_in_expr(f, params);
4006 }
4007 for item in items {
4008 self.check_config_refs_in_item(&item.kind, params);
4009 }
4010 }
4011 BlockItemKind::IfBlock { branches, else_items } => {
4012 for b in branches {
4013 self.check_config_refs_in_expr(&b.condition, params);
4014 for item in &b.items {
4015 self.check_config_refs_in_item(&item.kind, params);
4016 }
4017 }
4018 if let Some(items) = else_items {
4019 for item in items {
4020 self.check_config_refs_in_item(&item.kind, params);
4021 }
4022 }
4023 }
4024 _ => {}
4025 }
4026 }
4027
4028 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
4029 match expr {
4030 Expr::MemberAccess { object, field, .. } => {
4031 if let Expr::Ident(id) = object.as_ref() {
4032 if id.name == "config" && !params.contains(field.name.as_str()) {
4033 self.push(
4034 Diagnostic::warning(
4035 field.span,
4036 format!(
4037 "Config reference 'config.{}' is not declared in any config block.",
4038 field.name
4039 ),
4040 )
4041 .with_code("allium.config.undefinedReference"),
4042 );
4043 return;
4044 }
4045 }
4046 self.check_config_refs_in_expr(object, params);
4047 }
4048 Expr::Call { function, args, .. } => {
4049 self.check_config_refs_in_expr(function, params);
4050 for a in args {
4051 match a {
4052 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
4053 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
4054 }
4055 }
4056 }
4057 Expr::BinaryOp { left, right, .. }
4058 | Expr::Comparison { left, right, .. }
4059 | Expr::LogicalOp { left, right, .. }
4060 | Expr::Pipe { left, right, .. }
4061 | Expr::NullCoalesce { left, right, .. } => {
4062 self.check_config_refs_in_expr(left, params);
4063 self.check_config_refs_in_expr(right, params);
4064 }
4065 Expr::Not { operand, .. }
4066 | Expr::Exists { operand, .. }
4067 | Expr::NotExists { operand, .. } => {
4068 self.check_config_refs_in_expr(operand, params);
4069 }
4070 Expr::Block { items, .. } => {
4071 for item in items {
4072 self.check_config_refs_in_expr(item, params);
4073 }
4074 }
4075 Expr::Conditional { branches, else_body, .. } => {
4076 for b in branches {
4077 self.check_config_refs_in_expr(&b.condition, params);
4078 self.check_config_refs_in_expr(&b.body, params);
4079 }
4080 if let Some(body) = else_body {
4081 self.check_config_refs_in_expr(body, params);
4082 }
4083 }
4084 Expr::For { collection, filter, body, .. } => {
4085 self.check_config_refs_in_expr(collection, params);
4086 if let Some(f) = filter {
4087 self.check_config_refs_in_expr(f, params);
4088 }
4089 self.check_config_refs_in_expr(body, params);
4090 }
4091 Expr::LetExpr { value, .. } => {
4092 self.check_config_refs_in_expr(value, params);
4093 }
4094 Expr::Lambda { body, .. } => {
4095 self.check_config_refs_in_expr(body, params);
4096 }
4097 _ => {}
4098 }
4099 }
4100}
4101
4102fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
4107 match kind {
4108 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
4109 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
4110 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
4111 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
4112 BlockItemKind::ForBlock {
4113 collection,
4114 filter,
4115 items,
4116 ..
4117 } => {
4118 expr_contains_ident(collection, name)
4119 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
4120 || items.iter().any(|i| item_contains_ident(&i.kind, name))
4121 }
4122 BlockItemKind::IfBlock {
4123 branches,
4124 else_items,
4125 } => {
4126 branches.iter().any(|b| {
4127 expr_contains_ident(&b.condition, name)
4128 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
4129 }) || else_items
4130 .as_ref()
4131 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
4132 }
4133 BlockItemKind::PathAssignment { path, value } => {
4134 expr_contains_ident(path, name) || expr_contains_ident(value, name)
4135 }
4136 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
4137 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
4138 BlockItemKind::ContractsClause { .. }
4139 | BlockItemKind::EnumVariant { .. }
4140 | BlockItemKind::OpenQuestion { .. }
4141 | BlockItemKind::Annotation(_)
4142 | BlockItemKind::TransitionsBlock(_) => false,
4143 }
4144}
4145
4146fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
4147 match expr {
4148 Expr::Ident(id) => id.name == name,
4149 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
4150 expr_contains_ident(object, name)
4151 }
4152 Expr::Call { function, args, .. } => {
4153 expr_contains_ident(function, name)
4154 || args.iter().any(|a| match a {
4155 CallArg::Positional(e) => expr_contains_ident(e, name),
4156 CallArg::Named(n) => expr_contains_ident(&n.value, name),
4157 })
4158 }
4159 Expr::JoinLookup { entity, fields, .. } => {
4160 expr_contains_ident(entity, name)
4161 || fields
4162 .iter()
4163 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
4164 }
4165 Expr::BinaryOp { left, right, .. }
4166 | Expr::Comparison { left, right, .. }
4167 | Expr::LogicalOp { left, right, .. }
4168 | Expr::Pipe { left, right, .. }
4169 | Expr::NullCoalesce { left, right, .. } => {
4170 expr_contains_ident(left, name) || expr_contains_ident(right, name)
4171 }
4172 Expr::Not { operand, .. }
4173 | Expr::Exists { operand, .. }
4174 | Expr::NotExists { operand, .. }
4175 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
4176 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4177 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
4178 }
4179 Expr::Where {
4180 source, condition, ..
4181 }
4182 | Expr::With {
4183 source,
4184 predicate: condition,
4185 ..
4186 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
4187 Expr::WhenGuard {
4188 action, condition, ..
4189 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
4190 Expr::Lambda { param, body, .. } => {
4191 expr_contains_ident(param, name) || expr_contains_ident(body, name)
4192 }
4193 Expr::Binding { name: n, value, .. } => {
4194 n.name == name || expr_contains_ident(value, name)
4195 }
4196 Expr::SetLiteral { elements, .. } => {
4197 elements.iter().any(|e| expr_contains_ident(e, name))
4198 }
4199 Expr::ObjectLiteral { fields, .. } => {
4200 fields.iter().any(|f| expr_contains_ident(&f.value, name))
4201 }
4202 Expr::GenericType { name: n, args, .. } => {
4203 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
4204 }
4205 Expr::Conditional {
4206 branches,
4207 else_body,
4208 ..
4209 } => {
4210 branches.iter().any(|b| {
4211 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
4212 }) || else_body
4213 .as_ref()
4214 .is_some_and(|e| expr_contains_ident(e, name))
4215 }
4216 Expr::For {
4217 collection,
4218 filter,
4219 body,
4220 ..
4221 } => {
4222 expr_contains_ident(collection, name)
4223 || filter
4224 .as_ref()
4225 .is_some_and(|f| expr_contains_ident(f, name))
4226 || expr_contains_ident(body, name)
4227 }
4228 Expr::TransitionsTo {
4229 subject, new_state, ..
4230 }
4231 | Expr::Becomes {
4232 subject, new_state, ..
4233 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
4234 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
4235 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
4236 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
4237 Expr::QualifiedName(_)
4238 | Expr::StringLiteral(_)
4239 | Expr::BacktickLiteral { .. }
4240 | Expr::NumberLiteral { .. }
4241 | Expr::BoolLiteral { .. }
4242 | Expr::Null { .. }
4243 | Expr::Now { .. }
4244 | Expr::This { .. }
4245 | Expr::Within { .. }
4246 | Expr::DurationLiteral { .. } => false,
4247 }
4248}
4249
4250#[cfg(test)]
4255mod tests {
4256 use super::*;
4257 use crate::diagnostic::Severity;
4258 use crate::parser::parse;
4259
4260 fn analyze_src(src: &str) -> Vec<Diagnostic> {
4261 let input = if src.starts_with("-- allium:") {
4262 src.to_string()
4263 } else {
4264 format!("-- allium: 3\n{src}")
4265 };
4266 let result = parse(&input);
4267 analyze(&result.module, &input)
4268 }
4269
4270 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
4271 diagnostics.iter().any(|d| d.code == Some(code))
4272 }
4273
4274 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
4275 diagnostics.iter().filter(|d| d.code == Some(code)).count()
4276 }
4277
4278 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
4279 let input = if src.starts_with("-- allium:") {
4280 src.to_string()
4281 } else {
4282 format!("-- allium: 3\n{src}")
4283 };
4284 let result = parse(&input);
4285 analyse(&result.module, &input)
4286 }
4287
4288 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
4289 result.findings.iter().any(|f| f["type"] == finding_type)
4290 }
4291
4292 #[test]
4295 fn suppression_on_previous_line() {
4296 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
4297 assert!(!has_code(&ds, "allium.field.unused"));
4298 }
4299
4300 #[test]
4301 fn suppression_all() {
4302 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
4303 assert!(!has_code(&ds, "allium.field.unused"));
4304 }
4305
4306 #[test]
4309 fn related_clause_with_binding_and_guard() {
4310 let ds = analyze_src(
4311 "surface QuoteVersions {\n facing user: User\n}\n\n\
4312 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
4313 );
4314 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
4315 }
4316
4317 #[test]
4318 fn related_clause_reports_unknown_surface() {
4319 let ds = analyze_src(
4320 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
4321 );
4322 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
4323 }
4324
4325 #[test]
4328 fn v1_capitalised_inline_enum() {
4329 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
4330 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
4331 }
4332
4333 #[test]
4336 fn discard_binding_no_warning() {
4337 let ds = analyze_src(
4338 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
4339 );
4340 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
4341 }
4342
4343 #[test]
4346 fn variable_status_assignment_suppresses_unreachable() {
4347 let ds = analyze_src(
4348 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
4349 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
4350 ensures: update.status = new_status\n}\n",
4351 );
4352 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4353 assert!(!has_code(&ds, "allium.status.noExit"));
4354 }
4355
4356 #[test]
4359 fn created_with_status_suppresses_unreachable() {
4360 let ds = analyze_src(
4361 "entity Order {\n status: pending | confirmed\n customer: String\n \
4362 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
4363 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
4364 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
4365 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
4366 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
4367 );
4368 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4369 }
4370
4371 #[test]
4372 fn created_omitting_status_warns() {
4373 let ds = analyze_src(
4374 "entity Order {\n status: pending | confirmed\n customer: String\n \
4375 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
4376 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
4377 Order.created(\n customer: customer\n )\n}\n",
4378 );
4379 assert!(has_code(&ds, "allium.created.missingStatus"));
4380 }
4381
4382 #[test]
4383 fn created_multiple_initial_statuses() {
4384 let ds = analyze_src(
4385 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
4386 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
4387 terminal: reviewed\n }\n}\n\n\
4388 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
4389 Proposal.created(status: draft, author: author)\n}\n\n\
4390 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
4391 Proposal.created(status: submitted, author: author)\n}\n\n\
4392 rule Review {\n when: ReviewerReviews(proposal)\n \
4393 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
4394 );
4395 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4397 }
4398
4399 #[test]
4400 fn created_invalid_status_errors() {
4401 let ds = analyze_src(
4402 "entity Task {\n status: open | in_progress | done\n title: String\n \
4403 transitions status {\n open -> in_progress\n in_progress -> done\n \
4404 terminal: done\n }\n}\n\n\
4405 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
4406 Task.created(status: archived, title: title)\n}\n",
4407 );
4408 assert!(has_code(&ds, "allium.created.invalidStatus"));
4409 }
4410
4411 #[test]
4412 fn created_without_transitions_no_missing_status_warning() {
4413 let ds = analyze_src(
4415 "entity Note {\n status: draft | published\n content: String\n}\n\n\
4416 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
4417 Note.created(content: content)\n}\n",
4418 );
4419 assert!(!has_code(&ds, "allium.created.missingStatus"));
4420 }
4421
4422 #[test]
4425 fn terminal_declared_suppresses_no_exit() {
4426 let ds = analyze_src(
4427 "entity Subscription {\n status: active | paused | completed | cancelled\n \
4428 transitions status {\n active -> paused\n paused -> active\n \
4429 active -> completed\n active -> cancelled\n paused -> cancelled\n \
4430 terminal: completed, cancelled\n }\n}\n\n\
4431 rule Activate {\n when: UserActivates(user, subscription)\n \
4432 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
4433 rule Pause {\n when: UserPauses(user, subscription)\n \
4434 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
4435 rule Complete {\n when: PeriodEnds(subscription)\n \
4436 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
4437 rule Cancel {\n when: UserCancels(user, subscription)\n \
4438 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
4439 );
4440 assert!(!has_code(&ds, "allium.status.noExit"));
4441 }
4442
4443 #[test]
4444 fn non_terminal_no_exit_still_warns() {
4445 let ds = analyze_src(
4446 "entity Ticket {\n status: open | stuck | resolved\n \
4447 transitions status {\n open -> stuck\n open -> resolved\n \
4448 terminal: resolved\n }\n}\n\n\
4449 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
4450 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
4451 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
4452 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
4453 );
4454 assert!(has_code(&ds, "allium.status.noExit"));
4456 }
4457
4458 #[test]
4461 fn cross_entity_trigger_param_recognised() {
4462 let ds = analyze_src(
4463 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
4464 transitions status {\n scheduled -> confirmed\n \
4465 confirmed -> completed\n terminal: completed\n }\n}\n\n\
4466 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
4467 InterviewSlot.created(status: scheduled)\n}\n\n\
4468 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
4469 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
4470 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
4471 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
4472 );
4473 assert!(!ds.iter().any(|d| {
4475 d.code == Some("allium.status.unreachableValue")
4476 && d.message.contains("InterviewSlot")
4477 }));
4478 assert!(!ds.iter().any(|d| {
4479 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
4480 }));
4481 }
4482
4483 #[test]
4484 fn cross_entity_undeclared_transition() {
4485 let ds = analyze_src(
4486 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
4487 transitions status {\n scheduled -> confirmed\n \
4488 confirmed -> completed\n terminal: completed\n }\n}\n\n\
4489 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
4490 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
4491 );
4492 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
4493 }
4494
4495 #[test]
4496 fn nested_entity_status_recognised() {
4497 let ds = analyze_src(
4498 "entity Order {\n status: placed | paid\n payment: Payment\n \
4499 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
4500 entity Payment {\n status: pending | captured | failed\n \
4501 transitions status {\n pending -> captured\n pending -> failed\n \
4502 terminal: captured, failed\n }\n}\n\n\
4503 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
4504 requires: order.payment.status = pending\n \
4505 ensures: order.payment.status = captured\n}\n",
4506 );
4507 assert!(!ds.iter().any(|d| {
4509 (d.code == Some("allium.status.unreachableValue")
4510 || d.code == Some("allium.status.noExit"))
4511 && d.message.contains("'captured'")
4512 }));
4513 }
4514
4515 #[test]
4518 fn dead_transition_missing_producer() {
4519 let r = analyse_src(
4520 "entity App {\n status: submitted | screening | approved | rejected\n \
4521 verified: Boolean\n \
4522 transitions status {\n submitted -> screening\n screening -> approved\n \
4523 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4524 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
4525 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
4526 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
4527 requires:\n app.status = screening\n app.verified = true\n \
4528 ensures: app.status = approved\n}\n\n\
4529 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
4530 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
4531 );
4532 assert!(has_finding(&r, "dead_transition"));
4533 assert!(has_finding(&r, "missing_producer"));
4534 }
4535
4536 #[test]
4537 fn satisfied_requires_no_dead_transition() {
4538 let r = analyse_src(
4539 "entity App {\n status: submitted | screening | approved | rejected\n \
4540 verified: Boolean\n \
4541 transitions status {\n submitted -> screening\n screening -> approved\n \
4542 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4543 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
4544 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
4545 rule Verify {\n when: SystemVerifies(app, result)\n \
4546 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
4547 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
4548 requires:\n app.status = screening\n app.verified = true\n \
4549 ensures: app.status = approved\n}\n\n\
4550 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
4551 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
4552 );
4553 assert!(!has_finding(&r, "dead_transition"));
4554 assert!(!has_finding(&r, "missing_producer"));
4555 }
4556
4557 #[test]
4558 fn deadlock_detected() {
4559 let r = analyse_src(
4560 "entity Doc {\n status: submitted | review | approved | rejected\n \
4561 reviewer_assigned: Boolean\n \
4562 transitions status {\n submitted -> review\n review -> approved\n \
4563 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4564 rule Submit {\n when: AuthorSubmits(author, doc)\n \
4565 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
4566 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
4567 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
4568 ensures: doc.status = approved\n}\n\n\
4569 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
4570 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
4571 ensures: doc.status = rejected\n}\n",
4572 );
4573 assert!(has_finding(&r, "deadlock"));
4574 }
4575
4576 #[test]
4577 fn no_deadlock_when_paths_open() {
4578 let r = analyse_src(
4579 "entity Invoice {\n status: draft | sent | paid | void\n \
4580 transitions status {\n draft -> sent\n draft -> void\n \
4581 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
4582 rule Send {\n when: AccountantSends(accountant, invoice)\n \
4583 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
4584 rule Pay {\n when: PaymentReceived(invoice)\n \
4585 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
4586 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
4587 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
4588 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
4589 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
4590 );
4591 assert!(!has_finding(&r, "deadlock"));
4592 }
4593
4594 #[test]
4597 fn conflict_temporal_vs_external() {
4598 let r = analyse_src(
4599 "entity Membership {\n status: active | expired | extended\n \
4600 expires_at: Timestamp\n \
4601 transitions status {\n active -> expired\n active -> extended\n \
4602 terminal: expired, extended\n }\n}\n\n\
4603 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
4604 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
4605 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
4606 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
4607 );
4608 assert!(has_finding(&r, "conflict"));
4609 }
4610
4611 #[test]
4612 fn no_conflict_actor_choice() {
4613 let r = analyse_src(
4614 "entity LeaveRequest {\n status: pending | approved | denied\n \
4615 transitions status {\n pending -> approved\n pending -> denied\n \
4616 terminal: approved, denied\n }\n}\n\n\
4617 rule Approve {\n when: ManagerApproves(manager, request)\n \
4618 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
4619 rule Deny {\n when: ManagerDenies(manager, request)\n \
4620 requires: request.status = pending\n ensures: request.status = denied\n}\n",
4621 );
4622 assert!(!has_finding(&r, "conflict"));
4623 }
4624
4625 #[test]
4628 fn invariant_violation_detected() {
4629 let r = analyse_src(
4630 "entity JobRole {\n status: open | filled\n \
4631 candidacies: Candidacy with role = this\n \
4632 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
4633 entity Candidacy {\n status: active | hired | rejected\n \
4634 role: JobRole\n \
4635 transitions status {\n active -> hired\n active -> rejected\n \
4636 terminal: hired, rejected\n }\n}\n\n\
4637 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
4638 requires: candidacy.status = active\n \
4639 ensures: candidacy.status = hired\n}\n\n\
4640 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
4641 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
4642 );
4643 assert!(has_finding(&r, "invariant_risk"));
4644 }
4645
4646 #[test]
4647 fn invariant_guarded_no_violation() {
4648 let r = analyse_src(
4649 "entity JobRole {\n status: open | filled\n \
4650 candidacies: Candidacy with role = this\n \
4651 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
4652 entity Candidacy {\n status: active | hired | rejected\n \
4653 role: JobRole\n \
4654 transitions status {\n active -> hired\n active -> rejected\n \
4655 terminal: hired, rejected\n }\n}\n\n\
4656 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
4657 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
4658 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
4659 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
4660 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
4661 );
4662 assert!(!has_finding(&r, "invariant_risk"));
4663 }
4664
4665 #[test]
4668 fn external_entity_referenced_in_rules_info() {
4669 let ds = analyze_src(
4670 "external entity Client {\n id: String\n}\n\n\
4671 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
4672 );
4673 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
4674 assert!(hint.is_some());
4675 assert_eq!(hint.unwrap().severity, Severity::Info);
4676 }
4677
4678 #[test]
4681 fn undefined_type_reference() {
4682 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
4683 assert!(has_code(&ds, "allium.type.undefinedReference"));
4684 }
4685
4686 #[test]
4687 fn known_type_reference_ok() {
4688 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
4689 assert!(!has_code(&ds, "allium.type.undefinedReference"));
4690 }
4691
4692 #[test]
4695 fn unreachable_trigger_reported() {
4696 let ds = analyze_src(
4697 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
4698 );
4699 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
4700 }
4701
4702 #[test]
4705 fn unused_field_reported() {
4706 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");
4707 assert!(has_code(&ds, "allium.field.unused"));
4708 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
4710 assert!(unused.iter().any(|d| d.message.contains("A.y")));
4711 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
4712 }
4713
4714 #[test]
4717 fn unused_entity_reported() {
4718 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
4719 assert!(has_code(&ds, "allium.entity.unused"));
4720 }
4721
4722 #[test]
4725 fn deferred_missing_location_hint() {
4726 let ds = analyze_src("deferred Foo.bar\n");
4727 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
4728 }
4729
4730 #[test]
4733 fn valid_trigger_ok() {
4734 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
4735 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
4736 }
4737
4738 #[test]
4741 fn duplicate_let_binding() {
4742 let ds = analyze_src(
4743 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
4744 );
4745 assert!(has_code(&ds, "allium.let.duplicateBinding"));
4746 }
4747
4748 #[test]
4751 fn config_undefined_reference() {
4752 let ds = analyze_src(
4753 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
4754 );
4755 assert!(has_code(&ds, "allium.config.undefinedReference"));
4756 }
4757
4758 #[test]
4759 fn config_valid_reference_ok() {
4760 let ds = analyze_src(
4761 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
4762 );
4763 assert!(!has_code(&ds, "allium.config.undefinedReference"));
4764 }
4765}