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