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
3646pub fn collect_all_referenced_idents(module: &Module) -> HashSet<String> {
3652 let mut names: HashSet<&str> = HashSet::new();
3653 for d in &module.declarations {
3654 match d {
3655 Decl::Block(b) => {
3656 for item in &b.items {
3657 collect_uppercase_idents_from_item(&item.kind, &mut names);
3658 }
3659 }
3660 Decl::Variant(v) => {
3661 if let Some(name) = expr_as_ident(&v.base) {
3662 names.insert(name);
3663 }
3664 for item in &v.items {
3665 collect_uppercase_idents_from_item(&item.kind, &mut names);
3666 }
3667 }
3668 Decl::Invariant(inv) => {
3669 collect_uppercase_idents_from_expr(&inv.body, &mut names);
3670 }
3671 Decl::Default(def) => {
3672 if let Some(tn) = &def.type_name {
3673 names.insert(tn.name.as_str());
3674 }
3675 collect_uppercase_idents_from_expr(&def.value, &mut names);
3676 }
3677 _ => {}
3678 }
3679 }
3680 names.into_iter().map(|s| s.to_string()).collect()
3681}
3682
3683pub fn collect_declared_names(module: &Module) -> HashSet<String> {
3690 let mut names = HashSet::new();
3691 for d in &module.declarations {
3692 match d {
3693 Decl::Block(b) => {
3694 if matches!(
3695 b.kind,
3696 BlockKind::Entity
3697 | BlockKind::ExternalEntity
3698 | BlockKind::Value
3699 | BlockKind::Enum
3700 | BlockKind::Actor
3701 | BlockKind::Contract
3702 ) {
3703 if let Some(n) = &b.name {
3704 names.insert(n.name.clone());
3705 }
3706 }
3707 }
3708 Decl::Variant(v) => {
3709 names.insert(v.name.name.clone());
3710 }
3711 _ => {}
3712 }
3713 }
3714 names
3715}
3716
3717fn collect_qrefs_from_item(kind: &BlockItemKind, out: &mut Vec<(String, String)>) {
3718 match kind {
3719 BlockItemKind::Clause { value, .. }
3720 | BlockItemKind::Assignment { value, .. }
3721 | BlockItemKind::ParamAssignment { value, .. }
3722 | BlockItemKind::Let { value, .. }
3723 | BlockItemKind::PathAssignment { value, .. }
3724 | BlockItemKind::InvariantBlock { body: value, .. }
3725 | BlockItemKind::FieldWithWhen { value, .. } => {
3726 collect_qrefs_from_expr(value, out);
3727 }
3728 BlockItemKind::ForBlock {
3729 collection,
3730 filter,
3731 items,
3732 ..
3733 } => {
3734 collect_qrefs_from_expr(collection, out);
3735 if let Some(f) = filter {
3736 collect_qrefs_from_expr(f, out);
3737 }
3738 for item in items {
3739 collect_qrefs_from_item(&item.kind, out);
3740 }
3741 }
3742 BlockItemKind::IfBlock {
3743 branches,
3744 else_items,
3745 } => {
3746 for b in branches {
3747 collect_qrefs_from_expr(&b.condition, out);
3748 for item in &b.items {
3749 collect_qrefs_from_item(&item.kind, out);
3750 }
3751 }
3752 if let Some(items) = else_items {
3753 for item in items {
3754 collect_qrefs_from_item(&item.kind, out);
3755 }
3756 }
3757 }
3758 _ => {}
3762 }
3763}
3764
3765fn collect_qrefs_from_expr(expr: &Expr, out: &mut Vec<(String, String)>) {
3766 match expr {
3767 Expr::QualifiedName(q) => {
3768 if let Some(ref qualifier) = q.qualifier {
3769 out.push((qualifier.clone(), q.name.clone()));
3770 }
3771 }
3772 Expr::MemberAccess { object, field, .. }
3773 | Expr::OptionalAccess { object, field, .. } => {
3774 if let Expr::Ident(id) = object.as_ref() {
3776 if starts_uppercase(&field.name) {
3777 out.push((id.name.clone(), field.name.clone()));
3778 }
3779 }
3780 collect_qrefs_from_expr(object, out);
3781 }
3782 Expr::Call { function, args, .. } => {
3783 collect_qrefs_from_expr(function, out);
3784 for a in args {
3785 match a {
3786 CallArg::Positional(e) => collect_qrefs_from_expr(e, out),
3787 CallArg::Named(n) => collect_qrefs_from_expr(&n.value, out),
3788 }
3789 }
3790 }
3791 Expr::JoinLookup { entity, fields, .. } => {
3792 collect_qrefs_from_expr(entity, out);
3793 for f in fields {
3794 if let Some(v) = &f.value {
3795 collect_qrefs_from_expr(v, out);
3796 }
3797 }
3798 }
3799 Expr::BinaryOp { left, right, .. }
3800 | Expr::Comparison { left, right, .. }
3801 | Expr::LogicalOp { left, right, .. }
3802 | Expr::Pipe { left, right, .. }
3803 | Expr::NullCoalesce { left, right, .. } => {
3804 collect_qrefs_from_expr(left, out);
3805 collect_qrefs_from_expr(right, out);
3806 }
3807 Expr::Not { operand, .. }
3808 | Expr::Exists { operand, .. }
3809 | Expr::NotExists { operand, .. }
3810 | Expr::TypeOptional { inner: operand, .. } => {
3811 collect_qrefs_from_expr(operand, out);
3812 }
3813 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
3814 collect_qrefs_from_expr(element, out);
3815 collect_qrefs_from_expr(collection, out);
3816 }
3817 Expr::Where { source, condition, .. }
3818 | Expr::With {
3819 source,
3820 predicate: condition,
3821 ..
3822 } => {
3823 collect_qrefs_from_expr(source, out);
3824 collect_qrefs_from_expr(condition, out);
3825 }
3826 Expr::WhenGuard { action, condition, .. } => {
3827 collect_qrefs_from_expr(action, out);
3828 collect_qrefs_from_expr(condition, out);
3829 }
3830 Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
3831 collect_qrefs_from_expr(value, out);
3832 }
3833 Expr::Block { items, .. } => {
3834 for item in items {
3835 collect_qrefs_from_expr(item, out);
3836 }
3837 }
3838 Expr::Conditional {
3839 branches,
3840 else_body,
3841 ..
3842 } => {
3843 for b in branches {
3844 collect_qrefs_from_expr(&b.condition, out);
3845 collect_qrefs_from_expr(&b.body, out);
3846 }
3847 if let Some(body) = else_body {
3848 collect_qrefs_from_expr(body, out);
3849 }
3850 }
3851 Expr::For {
3852 collection,
3853 filter,
3854 body,
3855 ..
3856 } => {
3857 collect_qrefs_from_expr(collection, out);
3858 if let Some(f) = filter {
3859 collect_qrefs_from_expr(f, out);
3860 }
3861 collect_qrefs_from_expr(body, out);
3862 }
3863 Expr::Lambda { body, .. } => {
3864 collect_qrefs_from_expr(body, out);
3865 }
3866 Expr::TransitionsTo {
3867 subject, new_state, ..
3868 }
3869 | Expr::Becomes {
3870 subject, new_state, ..
3871 } => {
3872 collect_qrefs_from_expr(subject, out);
3873 collect_qrefs_from_expr(new_state, out);
3874 }
3875 Expr::GenericType { name, args, .. } => {
3876 collect_qrefs_from_expr(name, out);
3877 for a in args {
3878 collect_qrefs_from_expr(a, out);
3879 }
3880 }
3881 Expr::SetLiteral { elements, .. } => {
3882 for e in elements {
3883 collect_qrefs_from_expr(e, out);
3884 }
3885 }
3886 Expr::ObjectLiteral { fields, .. } => {
3887 for f in fields {
3888 collect_qrefs_from_expr(&f.value, out);
3889 }
3890 }
3891 Expr::ProjectionMap { source, .. } => {
3892 collect_qrefs_from_expr(source, out);
3893 }
3894 _ => {}
3895 }
3896}
3897
3898impl Ctx<'_> {
3903 fn check_deferred_location_hints(&mut self) {
3904 for d in &self.module.declarations {
3905 let Decl::Deferred(def) = d else {
3906 continue;
3907 };
3908 self.push(
3912 Diagnostic::warning(
3913 def.span,
3914 format!(
3915 "Deferred specification '{}' should include a location hint.",
3916 expr_to_dotpath(&def.path),
3917 ),
3918 )
3919 .with_code("allium.deferred.missingLocationHint"),
3920 );
3921 }
3922 }
3923}
3924
3925fn expr_to_dotpath(expr: &Expr) -> String {
3926 match expr {
3927 Expr::Ident(id) => id.name.clone(),
3928 Expr::MemberAccess { object, field, .. } => {
3929 format!("{}.{}", expr_to_dotpath(object), field.name)
3930 }
3931 _ => "?".to_string(),
3932 }
3933}
3934
3935impl Ctx<'_> {
3940 fn check_rule_invalid_triggers(&mut self) {
3941 for rule in self.blocks(BlockKind::Rule) {
3942 let rule_name = match &rule.name {
3943 Some(n) => &n.name,
3944 None => continue,
3945 };
3946
3947 for item in &rule.items {
3948 let BlockItemKind::Clause { keyword, value } = &item.kind else {
3949 continue;
3950 };
3951 if keyword != "when" {
3952 continue;
3953 }
3954 if !is_valid_trigger(value) {
3955 self.push(
3956 Diagnostic::error(
3957 item.span,
3958 format!(
3959 "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
3960 ),
3961 )
3962 .with_code("allium.rule.invalidTrigger"),
3963 );
3964 }
3965 }
3966 }
3967 }
3968}
3969
3970fn is_valid_trigger(expr: &Expr) -> bool {
3971 match expr {
3972 Expr::Call { function, .. } => {
3974 matches!(function.as_ref(), Expr::Ident(_) | Expr::MemberAccess { .. })
3975 }
3976 Expr::Binding { value, .. } => {
3978 matches!(
3979 value.as_ref(),
3980 Expr::Becomes { .. }
3981 | Expr::TransitionsTo { .. }
3982 | Expr::MemberAccess { .. }
3983 | Expr::Comparison { .. }
3984 )
3985 }
3986 Expr::LogicalOp {
3988 op: LogicalOp::Or,
3989 left,
3990 right,
3991 ..
3992 } => is_valid_trigger(left) && is_valid_trigger(right),
3993 Expr::Comparison { left, .. } => {
3995 matches!(left.as_ref(), Expr::MemberAccess { .. })
3996 }
3997 _ => false,
3998 }
3999}
4000
4001impl Ctx<'_> {
4006 fn check_rule_undefined_bindings(&mut self) {
4007 let mut given_bindings: HashSet<&str> = HashSet::new();
4009 for given in self.blocks(BlockKind::Given) {
4010 for item in &given.items {
4011 if let BlockItemKind::Assignment { name, .. } = &item.kind {
4012 given_bindings.insert(&name.name);
4013 }
4014 }
4015 }
4016
4017 let mut default_names: HashSet<&str> = HashSet::new();
4019 for d in &self.module.declarations {
4020 if let Decl::Default(def) = d {
4021 default_names.insert(&def.name.name);
4022 }
4023 }
4024
4025 for rule in self.blocks(BlockKind::Rule) {
4026 let rule_name = match &rule.name {
4027 Some(n) => &n.name,
4028 None => continue,
4029 };
4030
4031 let mut bound: HashSet<&str> = HashSet::new();
4032 bound.extend(&given_bindings);
4033 bound.extend(&default_names);
4034
4035 for item in &rule.items {
4037 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4038 continue;
4039 };
4040 if keyword != "when" {
4041 continue;
4042 }
4043 collect_bound_names(value, &mut bound);
4044 }
4045
4046 for item in &rule.items {
4048 if let BlockItemKind::Let { name, .. } = &item.kind {
4049 bound.insert(&name.name);
4050 }
4051 }
4052
4053 for item in &rule.items {
4055 let BlockItemKind::Clause { keyword, value } = &item.kind else {
4056 continue;
4057 };
4058 if keyword != "requires" && keyword != "ensures" {
4059 continue;
4060 }
4061 check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
4062 }
4063
4064 for item in &rule.items {
4066 match &item.kind {
4067 BlockItemKind::ForBlock {
4068 binding,
4069 items,
4070 ..
4071 } => {
4072 let mut inner_bound = bound.clone();
4073 match binding {
4074 ForBinding::Single(id) => { inner_bound.insert(&id.name); }
4075 ForBinding::Destructured(ids, _) => {
4076 for id in ids {
4077 inner_bound.insert(&id.name);
4078 }
4079 }
4080 }
4081 for sub_item in items {
4082 if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
4083 if keyword == "ensures" || keyword == "requires" {
4084 check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
4085 }
4086 }
4087 }
4088 }
4089 _ => {}
4090 }
4091 }
4092
4093 for item in &rule.items {
4097 let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
4098 if keyword != "when" { continue }
4099 let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
4100 if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
4101 continue;
4102 }
4103 let mut found = false;
4105 for check_item in &rule.items {
4106 let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
4107 if kw != "requires" && kw != "ensures" { continue }
4108 if expr_contains_ident(v, &binding_name.name) {
4109 self.push(
4110 Diagnostic::error(
4111 check_item.span,
4112 format!(
4113 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
4114 binding_name.name
4115 ),
4116 )
4117 .with_code("allium.rule.undefinedBinding"),
4118 );
4119 found = true;
4120 break;
4121 }
4122 }
4123 if found { break; }
4124 }
4125 }
4126 }
4127}
4128
4129fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
4130 match expr {
4131 Expr::Binding { name, .. } => {
4132 out.insert(&name.name);
4133 }
4134 Expr::Call { args, .. } => {
4135 for arg in args {
4136 if let CallArg::Positional(Expr::Ident(id)) = arg {
4137 out.insert(&id.name);
4138 }
4139 }
4140 }
4141 Expr::LogicalOp { left, right, .. } => {
4142 collect_bound_names(left, out);
4143 collect_bound_names(right, out);
4144 }
4145 _ => {}
4146 }
4147}
4148
4149fn check_unbound_roots(
4150 expr: &Expr,
4151 bound: &HashSet<&str>,
4152 rule_name: &str,
4153 diagnostics: &mut Vec<Diagnostic>,
4154) {
4155 match expr {
4156 Expr::MemberAccess { object, .. } => {
4157 if let Expr::Ident(id) = object.as_ref() {
4158 if !starts_uppercase(&id.name)
4159 && !bound.contains(id.name.as_str())
4160 && !is_builtin_name(&id.name)
4161 {
4162 diagnostics.push(
4163 Diagnostic::error(
4164 id.span,
4165 format!(
4166 "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
4167 id.name
4168 ),
4169 )
4170 .with_code("allium.rule.undefinedBinding"),
4171 );
4172 }
4173 }
4174 }
4175 Expr::Comparison { left, right, .. } => {
4176 check_unbound_roots(left, bound, rule_name, diagnostics);
4177 check_unbound_roots(right, bound, rule_name, diagnostics);
4178 }
4179 Expr::LogicalOp { left, right, .. } => {
4180 check_unbound_roots(left, bound, rule_name, diagnostics);
4181 check_unbound_roots(right, bound, rule_name, diagnostics);
4182 }
4183 Expr::Block { items, .. } => {
4184 let mut block_bound = bound.clone();
4185 for item in items {
4186 if let Expr::LetExpr { name, value, .. } = item {
4187 check_unbound_roots(value, &block_bound, rule_name, diagnostics);
4188 block_bound.insert(name.name.as_str());
4189 } else {
4190 check_unbound_roots(item, &block_bound, rule_name, diagnostics);
4191 }
4192 }
4193 }
4194 Expr::For { binding, collection, body, .. } => {
4195 check_unbound_roots(collection, bound, rule_name, diagnostics);
4196 let mut inner = bound.clone();
4198 match binding {
4199 ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
4200 ForBinding::Destructured(ids, _) => {
4201 for id in ids {
4202 inner.insert(id.name.as_str());
4203 }
4204 }
4205 }
4206 check_unbound_roots(body, &inner, rule_name, diagnostics);
4207 }
4208 Expr::BinaryOp { left, right, .. } => {
4209 check_unbound_roots(left, bound, rule_name, diagnostics);
4210 check_unbound_roots(right, bound, rule_name, diagnostics);
4211 }
4212 Expr::Call { function, args, .. } => {
4213 if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
4215 check_unbound_roots(function, bound, rule_name, diagnostics);
4216 }
4217 let mut call_bound = bound.clone();
4219 for a in args {
4220 if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
4221 if let Expr::Ident(id) = param.as_ref() {
4222 call_bound.insert(id.name.as_str());
4223 }
4224 }
4225 }
4226 for a in args {
4227 match a {
4228 CallArg::Positional(Expr::Lambda { body, .. }) => {
4229 check_unbound_roots(body, &call_bound, rule_name, diagnostics);
4230 }
4231 CallArg::Positional(e) => {
4232 check_unbound_roots(e, &call_bound, rule_name, diagnostics);
4233 }
4234 CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
4235 }
4236 }
4237 }
4238 Expr::Not { operand, .. }
4239 | Expr::Exists { operand, .. }
4240 | Expr::NotExists { operand, .. } => {
4241 check_unbound_roots(operand, bound, rule_name, diagnostics);
4242 }
4243 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4244 check_unbound_roots(element, bound, rule_name, diagnostics);
4245 check_unbound_roots(collection, bound, rule_name, diagnostics);
4246 }
4247 Expr::Conditional { branches, else_body, .. } => {
4248 for b in branches {
4249 check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
4250 check_unbound_roots(&b.body, bound, rule_name, diagnostics);
4251 }
4252 if let Some(body) = else_body {
4253 check_unbound_roots(body, bound, rule_name, diagnostics);
4254 }
4255 }
4256 _ => {}
4257 }
4258}
4259
4260fn is_builtin_name(name: &str) -> bool {
4261 matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
4262}
4263
4264impl Ctx<'_> {
4269 fn check_duplicate_let_bindings(&mut self) {
4270 for rule in self.blocks(BlockKind::Rule) {
4271 let mut seen: HashMap<&str, Span> = HashMap::new();
4272 self.check_duplicate_lets_in_items(&rule.items, &mut seen);
4273 }
4274 }
4275
4276 fn check_duplicate_lets_in_items<'b>(
4277 &mut self,
4278 items: &'b [BlockItem],
4279 seen: &mut HashMap<&'b str, Span>,
4280 ) {
4281 for item in items {
4282 match &item.kind {
4283 BlockItemKind::Let { name, .. } => {
4284 if seen.contains_key(name.name.as_str()) {
4285 self.push(
4286 Diagnostic::error(
4287 name.span,
4288 format!("Duplicate let binding '{}' in this rule.", name.name),
4289 )
4290 .with_code("allium.let.duplicateBinding"),
4291 );
4292 } else {
4293 seen.insert(&name.name, name.span);
4294 }
4295 }
4296 BlockItemKind::ForBlock { items, .. } => {
4297 self.check_duplicate_lets_in_items(items, seen);
4298 }
4299 BlockItemKind::IfBlock {
4300 branches,
4301 else_items,
4302 } => {
4303 for b in branches {
4304 self.check_duplicate_lets_in_items(&b.items, seen);
4305 }
4306 if let Some(items) = else_items {
4307 self.check_duplicate_lets_in_items(items, seen);
4308 }
4309 }
4310 BlockItemKind::Clause { value, .. } => {
4311 self.check_duplicate_lets_in_expr(value, seen);
4312 }
4313 _ => {}
4314 }
4315 }
4316 }
4317
4318 fn check_duplicate_lets_in_expr<'b>(
4319 &mut self,
4320 expr: &'b Expr,
4321 seen: &mut HashMap<&'b str, Span>,
4322 ) {
4323 match expr {
4324 Expr::LetExpr { name, value, .. } => {
4325 if seen.contains_key(name.name.as_str()) {
4326 self.push(
4327 Diagnostic::error(
4328 name.span,
4329 format!("Duplicate let binding '{}' in this rule.", name.name),
4330 )
4331 .with_code("allium.let.duplicateBinding"),
4332 );
4333 } else {
4334 seen.insert(&name.name, name.span);
4335 }
4336 self.check_duplicate_lets_in_expr(value, seen);
4337 }
4338 Expr::Block { items, .. } => {
4339 for item in items {
4340 self.check_duplicate_lets_in_expr(item, seen);
4341 }
4342 }
4343 Expr::For { body, .. } => {
4344 self.check_duplicate_lets_in_expr(body, seen);
4345 }
4346 Expr::Conditional { branches, else_body, .. } => {
4347 for b in branches {
4348 self.check_duplicate_lets_in_expr(&b.body, seen);
4349 }
4350 if let Some(body) = else_body {
4351 self.check_duplicate_lets_in_expr(body, seen);
4352 }
4353 }
4354 _ => {}
4355 }
4356 }
4357}
4358
4359impl Ctx<'_> {
4364 fn check_config_undefined_references(&mut self) {
4365 let mut config_params: HashSet<&str> = HashSet::new();
4366 for config in self.blocks(BlockKind::Config) {
4367 for item in &config.items {
4368 if let BlockItemKind::Assignment { name, .. } = &item.kind {
4369 config_params.insert(&name.name);
4370 }
4371 }
4372 }
4373
4374 for d in &self.module.declarations {
4376 match d {
4377 Decl::Block(b) => {
4378 if b.kind == BlockKind::Config {
4379 continue;
4380 }
4381 for item in &b.items {
4382 self.check_config_refs_in_item(&item.kind, &config_params);
4383 }
4384 }
4385 Decl::Invariant(inv) => {
4386 self.check_config_refs_in_expr(&inv.body, &config_params);
4387 }
4388 _ => {}
4389 }
4390 }
4391 }
4392
4393 fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
4394 match kind {
4395 BlockItemKind::Clause { value, .. }
4396 | BlockItemKind::Assignment { value, .. }
4397 | BlockItemKind::ParamAssignment { value, .. }
4398 | BlockItemKind::Let { value, .. }
4399 | BlockItemKind::FieldWithWhen { value, .. } => {
4400 self.check_config_refs_in_expr(value, params);
4401 }
4402 BlockItemKind::ForBlock { collection, filter, items, .. } => {
4403 self.check_config_refs_in_expr(collection, params);
4404 if let Some(f) = filter {
4405 self.check_config_refs_in_expr(f, params);
4406 }
4407 for item in items {
4408 self.check_config_refs_in_item(&item.kind, params);
4409 }
4410 }
4411 BlockItemKind::IfBlock { branches, else_items } => {
4412 for b in branches {
4413 self.check_config_refs_in_expr(&b.condition, params);
4414 for item in &b.items {
4415 self.check_config_refs_in_item(&item.kind, params);
4416 }
4417 }
4418 if let Some(items) = else_items {
4419 for item in items {
4420 self.check_config_refs_in_item(&item.kind, params);
4421 }
4422 }
4423 }
4424 _ => {}
4425 }
4426 }
4427
4428 fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
4429 match expr {
4430 Expr::MemberAccess { object, field, .. } => {
4431 if let Expr::Ident(id) = object.as_ref() {
4432 if id.name == "config" && !params.contains(field.name.as_str()) {
4433 self.push(
4434 Diagnostic::warning(
4435 field.span,
4436 format!(
4437 "Config reference 'config.{}' is not declared in any config block.",
4438 field.name
4439 ),
4440 )
4441 .with_code("allium.config.undefinedReference"),
4442 );
4443 return;
4444 }
4445 }
4446 self.check_config_refs_in_expr(object, params);
4447 }
4448 Expr::Call { function, args, .. } => {
4449 self.check_config_refs_in_expr(function, params);
4450 for a in args {
4451 match a {
4452 CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
4453 CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
4454 }
4455 }
4456 }
4457 Expr::BinaryOp { left, right, .. }
4458 | Expr::Comparison { left, right, .. }
4459 | Expr::LogicalOp { left, right, .. }
4460 | Expr::Pipe { left, right, .. }
4461 | Expr::NullCoalesce { left, right, .. } => {
4462 self.check_config_refs_in_expr(left, params);
4463 self.check_config_refs_in_expr(right, params);
4464 }
4465 Expr::Not { operand, .. }
4466 | Expr::Exists { operand, .. }
4467 | Expr::NotExists { operand, .. } => {
4468 self.check_config_refs_in_expr(operand, params);
4469 }
4470 Expr::Block { items, .. } => {
4471 for item in items {
4472 self.check_config_refs_in_expr(item, params);
4473 }
4474 }
4475 Expr::Conditional { branches, else_body, .. } => {
4476 for b in branches {
4477 self.check_config_refs_in_expr(&b.condition, params);
4478 self.check_config_refs_in_expr(&b.body, params);
4479 }
4480 if let Some(body) = else_body {
4481 self.check_config_refs_in_expr(body, params);
4482 }
4483 }
4484 Expr::For { collection, filter, body, .. } => {
4485 self.check_config_refs_in_expr(collection, params);
4486 if let Some(f) = filter {
4487 self.check_config_refs_in_expr(f, params);
4488 }
4489 self.check_config_refs_in_expr(body, params);
4490 }
4491 Expr::LetExpr { value, .. } => {
4492 self.check_config_refs_in_expr(value, params);
4493 }
4494 Expr::Lambda { body, .. } => {
4495 self.check_config_refs_in_expr(body, params);
4496 }
4497 _ => {}
4498 }
4499 }
4500}
4501
4502fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
4507 match kind {
4508 BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
4509 BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
4510 BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
4511 BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
4512 BlockItemKind::ForBlock {
4513 collection,
4514 filter,
4515 items,
4516 ..
4517 } => {
4518 expr_contains_ident(collection, name)
4519 || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
4520 || items.iter().any(|i| item_contains_ident(&i.kind, name))
4521 }
4522 BlockItemKind::IfBlock {
4523 branches,
4524 else_items,
4525 } => {
4526 branches.iter().any(|b| {
4527 expr_contains_ident(&b.condition, name)
4528 || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
4529 }) || else_items
4530 .as_ref()
4531 .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
4532 }
4533 BlockItemKind::PathAssignment { path, value } => {
4534 expr_contains_ident(path, name) || expr_contains_ident(value, name)
4535 }
4536 BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
4537 BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
4538 BlockItemKind::ContractsClause { .. }
4539 | BlockItemKind::EnumVariant { .. }
4540 | BlockItemKind::OpenQuestion { .. }
4541 | BlockItemKind::Annotation(_)
4542 | BlockItemKind::TransitionsBlock(_) => false,
4543 }
4544}
4545
4546fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
4547 match expr {
4548 Expr::Ident(id) => id.name == name,
4549 Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
4550 expr_contains_ident(object, name)
4551 }
4552 Expr::Call { function, args, .. } => {
4553 expr_contains_ident(function, name)
4554 || args.iter().any(|a| match a {
4555 CallArg::Positional(e) => expr_contains_ident(e, name),
4556 CallArg::Named(n) => expr_contains_ident(&n.value, name),
4557 })
4558 }
4559 Expr::JoinLookup { entity, fields, .. } => {
4560 expr_contains_ident(entity, name)
4561 || fields
4562 .iter()
4563 .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
4564 }
4565 Expr::BinaryOp { left, right, .. }
4566 | Expr::Comparison { left, right, .. }
4567 | Expr::LogicalOp { left, right, .. }
4568 | Expr::Pipe { left, right, .. }
4569 | Expr::NullCoalesce { left, right, .. } => {
4570 expr_contains_ident(left, name) || expr_contains_ident(right, name)
4571 }
4572 Expr::Not { operand, .. }
4573 | Expr::Exists { operand, .. }
4574 | Expr::NotExists { operand, .. }
4575 | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
4576 Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
4577 expr_contains_ident(element, name) || expr_contains_ident(collection, name)
4578 }
4579 Expr::Where {
4580 source, condition, ..
4581 }
4582 | Expr::With {
4583 source,
4584 predicate: condition,
4585 ..
4586 } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
4587 Expr::WhenGuard {
4588 action, condition, ..
4589 } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
4590 Expr::Lambda { param, body, .. } => {
4591 expr_contains_ident(param, name) || expr_contains_ident(body, name)
4592 }
4593 Expr::Binding { name: n, value, .. } => {
4594 n.name == name || expr_contains_ident(value, name)
4595 }
4596 Expr::SetLiteral { elements, .. } => {
4597 elements.iter().any(|e| expr_contains_ident(e, name))
4598 }
4599 Expr::ObjectLiteral { fields, .. } => {
4600 fields.iter().any(|f| expr_contains_ident(&f.value, name))
4601 }
4602 Expr::GenericType { name: n, args, .. } => {
4603 expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
4604 }
4605 Expr::Conditional {
4606 branches,
4607 else_body,
4608 ..
4609 } => {
4610 branches.iter().any(|b| {
4611 expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
4612 }) || else_body
4613 .as_ref()
4614 .is_some_and(|e| expr_contains_ident(e, name))
4615 }
4616 Expr::For {
4617 collection,
4618 filter,
4619 body,
4620 ..
4621 } => {
4622 expr_contains_ident(collection, name)
4623 || filter
4624 .as_ref()
4625 .is_some_and(|f| expr_contains_ident(f, name))
4626 || expr_contains_ident(body, name)
4627 }
4628 Expr::TransitionsTo {
4629 subject, new_state, ..
4630 }
4631 | Expr::Becomes {
4632 subject, new_state, ..
4633 } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
4634 Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
4635 Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
4636 Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
4637 Expr::QualifiedName(_)
4638 | Expr::StringLiteral(_)
4639 | Expr::BacktickLiteral { .. }
4640 | Expr::NumberLiteral { .. }
4641 | Expr::BoolLiteral { .. }
4642 | Expr::Null { .. }
4643 | Expr::Now { .. }
4644 | Expr::This { .. }
4645 | Expr::Within { .. }
4646 | Expr::DurationLiteral { .. } => false,
4647 }
4648}
4649
4650#[cfg(test)]
4655mod tests {
4656 use super::*;
4657 use crate::diagnostic::Severity;
4658 use crate::parser::parse;
4659
4660 fn analyze_src(src: &str) -> Vec<Diagnostic> {
4661 let input = if src.starts_with("-- allium:") {
4662 src.to_string()
4663 } else {
4664 format!("-- allium: 3\n{src}")
4665 };
4666 let result = parse(&input);
4667 analyze(&result.module, &input)
4668 }
4669
4670 fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
4671 diagnostics.iter().any(|d| d.code == Some(code))
4672 }
4673
4674 fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
4675 diagnostics.iter().filter(|d| d.code == Some(code)).count()
4676 }
4677
4678 fn analyse_src(src: &str) -> crate::diagnostic::AnalyseResult {
4679 let input = if src.starts_with("-- allium:") {
4680 src.to_string()
4681 } else {
4682 format!("-- allium: 3\n{src}")
4683 };
4684 let result = parse(&input);
4685 analyse(&result.module, &input)
4686 }
4687
4688 fn has_finding(result: &crate::diagnostic::AnalyseResult, finding_type: &str) -> bool {
4689 result.findings.iter().any(|f| f["type"] == finding_type)
4690 }
4691
4692 #[test]
4695 fn suppression_on_previous_line() {
4696 let ds = analyze_src("entity A {\n -- allium-ignore allium.field.unused\n x: String\n}\n");
4697 assert!(!has_code(&ds, "allium.field.unused"));
4698 }
4699
4700 #[test]
4701 fn suppression_all() {
4702 let ds = analyze_src("entity A {\n -- allium-ignore all\n x: String\n}\n");
4703 assert!(!has_code(&ds, "allium.field.unused"));
4704 }
4705
4706 #[test]
4709 fn related_clause_with_binding_and_guard() {
4710 let ds = analyze_src(
4711 "surface QuoteVersions {\n facing user: User\n}\n\n\
4712 surface Dashboard {\n facing user: User\n related:\n QuoteVersions(quote) when quote.version_count > 1\n}\n",
4713 );
4714 assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
4715 }
4716
4717 #[test]
4718 fn related_clause_reports_unknown_surface() {
4719 let ds = analyze_src(
4720 "surface Dashboard {\n facing user: User\n related:\n MissingSurface\n}\n",
4721 );
4722 assert!(has_code(&ds, "allium.surface.relatedUndefined"));
4723 }
4724
4725 #[test]
4728 fn v1_capitalised_inline_enum() {
4729 let ds = analyze_src("entity Quote {\n status: Quoted | OrderSubmitted | Filled\n}\n");
4730 assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
4731 }
4732
4733 #[test]
4736 fn discard_binding_no_warning() {
4737 let ds = analyze_src(
4738 "surface QuoteFeed {\n facing _: Service\n exposes:\n System.status\n}\n",
4739 );
4740 assert!(!has_code(&ds, "allium.surface.unusedBinding"));
4741 }
4742
4743 #[test]
4746 fn variable_status_assignment_suppresses_unreachable() {
4747 let ds = analyze_src(
4748 "entity Quote {\n status: pending | quoted | filled\n}\n\n\
4749 rule ApplyStatusUpdate {\n when: update: Quote.status becomes pending\n \
4750 ensures: update.status = new_status\n}\n",
4751 );
4752 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4753 assert!(!has_code(&ds, "allium.status.noExit"));
4754 }
4755
4756 #[test]
4759 fn created_with_status_suppresses_unreachable() {
4760 let ds = analyze_src(
4761 "entity Order {\n status: pending | confirmed\n customer: String\n \
4762 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
4763 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
4764 Order.created(\n status: pending,\n customer: customer\n )\n}\n\n\
4765 rule ConfirmOrder {\n when: SellerConfirms(seller, order)\n \
4766 requires: order.status = pending\n ensures: order.status = confirmed\n}\n",
4767 );
4768 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4769 }
4770
4771 #[test]
4772 fn created_omitting_status_warns() {
4773 let ds = analyze_src(
4774 "entity Order {\n status: pending | confirmed\n customer: String\n \
4775 transitions status {\n pending -> confirmed\n terminal: confirmed\n }\n}\n\n\
4776 rule PlaceOrder {\n when: CustomerPlacesOrder(customer)\n ensures:\n \
4777 Order.created(\n customer: customer\n )\n}\n",
4778 );
4779 assert!(has_code(&ds, "allium.created.missingStatus"));
4780 }
4781
4782 #[test]
4783 fn created_multiple_initial_statuses() {
4784 let ds = analyze_src(
4785 "entity Proposal {\n status: draft | submitted | reviewed\n author: String\n \
4786 transitions status {\n draft -> submitted\n submitted -> reviewed\n \
4787 terminal: reviewed\n }\n}\n\n\
4788 rule CreateDraft {\n when: AuthorStarts(author)\n ensures:\n \
4789 Proposal.created(status: draft, author: author)\n}\n\n\
4790 rule SubmitDirectly {\n when: AuthorSubmits(author)\n ensures:\n \
4791 Proposal.created(status: submitted, author: author)\n}\n\n\
4792 rule Review {\n when: ReviewerReviews(proposal)\n \
4793 requires: proposal.status = submitted\n ensures: proposal.status = reviewed\n}\n",
4794 );
4795 assert!(!has_code(&ds, "allium.status.unreachableValue"));
4797 }
4798
4799 #[test]
4800 fn created_invalid_status_errors() {
4801 let ds = analyze_src(
4802 "entity Task {\n status: open | in_progress | done\n title: String\n \
4803 transitions status {\n open -> in_progress\n in_progress -> done\n \
4804 terminal: done\n }\n}\n\n\
4805 rule ImportTask {\n when: SystemImports(title)\n ensures:\n \
4806 Task.created(status: archived, title: title)\n}\n",
4807 );
4808 assert!(has_code(&ds, "allium.created.invalidStatus"));
4809 }
4810
4811 #[test]
4812 fn created_without_transitions_no_missing_status_warning() {
4813 let ds = analyze_src(
4815 "entity Note {\n status: draft | published\n content: String\n}\n\n\
4816 rule CreateNote {\n when: UserCreates(content)\n ensures:\n \
4817 Note.created(content: content)\n}\n",
4818 );
4819 assert!(!has_code(&ds, "allium.created.missingStatus"));
4820 }
4821
4822 #[test]
4825 fn terminal_declared_suppresses_no_exit() {
4826 let ds = analyze_src(
4827 "entity Subscription {\n status: active | paused | completed | cancelled\n \
4828 transitions status {\n active -> paused\n paused -> active\n \
4829 active -> completed\n active -> cancelled\n paused -> cancelled\n \
4830 terminal: completed, cancelled\n }\n}\n\n\
4831 rule Activate {\n when: UserActivates(user, subscription)\n \
4832 requires: subscription.status = paused\n ensures: subscription.status = active\n}\n\n\
4833 rule Pause {\n when: UserPauses(user, subscription)\n \
4834 requires: subscription.status = active\n ensures: subscription.status = paused\n}\n\n\
4835 rule Complete {\n when: PeriodEnds(subscription)\n \
4836 requires: subscription.status = active\n ensures: subscription.status = completed\n}\n\n\
4837 rule Cancel {\n when: UserCancels(user, subscription)\n \
4838 requires: subscription.status = active\n ensures: subscription.status = cancelled\n}\n",
4839 );
4840 assert!(!has_code(&ds, "allium.status.noExit"));
4841 }
4842
4843 #[test]
4844 fn non_terminal_no_exit_still_warns() {
4845 let ds = analyze_src(
4846 "entity Ticket {\n status: open | stuck | resolved\n \
4847 transitions status {\n open -> stuck\n open -> resolved\n \
4848 terminal: resolved\n }\n}\n\n\
4849 rule Escalate {\n when: AgentEscalates(agent, ticket)\n \
4850 requires: ticket.status = open\n ensures: ticket.status = stuck\n}\n\n\
4851 rule Resolve {\n when: AgentResolves(agent, ticket)\n \
4852 requires: ticket.status = open\n ensures: ticket.status = resolved\n}\n",
4853 );
4854 assert!(has_code(&ds, "allium.status.noExit"));
4856 }
4857
4858 #[test]
4861 fn cross_entity_trigger_param_recognised() {
4862 let ds = analyze_src(
4863 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
4864 transitions status {\n scheduled -> confirmed\n \
4865 confirmed -> completed\n terminal: completed\n }\n}\n\n\
4866 rule CreateSlot {\n when: RecruiterSchedules(time)\n ensures:\n \
4867 InterviewSlot.created(status: scheduled)\n}\n\n\
4868 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
4869 requires: slot.status = scheduled\n ensures: slot.status = confirmed\n}\n\n\
4870 rule CompleteSlot {\n when: InterviewerSubmits(interviewer, slot)\n \
4871 requires: slot.status = confirmed\n ensures: slot.status = completed\n}\n",
4872 );
4873 assert!(!ds.iter().any(|d| {
4875 d.code == Some("allium.status.unreachableValue")
4876 && d.message.contains("InterviewSlot")
4877 }));
4878 assert!(!ds.iter().any(|d| {
4879 d.code == Some("allium.status.noExit") && d.message.contains("InterviewSlot")
4880 }));
4881 }
4882
4883 #[test]
4884 fn cross_entity_undeclared_transition() {
4885 let ds = analyze_src(
4886 "entity InterviewSlot {\n status: scheduled | confirmed | completed\n \
4887 transitions status {\n scheduled -> confirmed\n \
4888 confirmed -> completed\n terminal: completed\n }\n}\n\n\
4889 rule ConfirmSlot {\n when: InterviewerConfirms(interviewer, slot)\n \
4890 requires: slot.status = completed\n ensures: slot.status = confirmed\n}\n",
4891 );
4892 assert!(has_code(&ds, "allium.status.undeclaredTransition"));
4893 }
4894
4895 #[test]
4896 fn nested_entity_status_recognised() {
4897 let ds = analyze_src(
4898 "entity Order {\n status: placed | paid\n payment: Payment\n \
4899 transitions status {\n placed -> paid\n terminal: paid\n }\n}\n\n\
4900 entity Payment {\n status: pending | captured | failed\n \
4901 transitions status {\n pending -> captured\n pending -> failed\n \
4902 terminal: captured, failed\n }\n}\n\n\
4903 rule CapturePayment {\n when: GatewayConfirms(order, ref)\n \
4904 requires: order.payment.status = pending\n \
4905 ensures: order.payment.status = captured\n}\n",
4906 );
4907 assert!(!ds.iter().any(|d| {
4909 (d.code == Some("allium.status.unreachableValue")
4910 || d.code == Some("allium.status.noExit"))
4911 && d.message.contains("'captured'")
4912 }));
4913 }
4914
4915 #[test]
4918 fn dead_transition_missing_producer() {
4919 let r = analyse_src(
4920 "entity App {\n status: submitted | screening | approved | rejected\n \
4921 verified: Boolean\n \
4922 transitions status {\n submitted -> screening\n screening -> approved\n \
4923 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4924 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
4925 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
4926 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
4927 requires:\n app.status = screening\n app.verified = true\n \
4928 ensures: app.status = approved\n}\n\n\
4929 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
4930 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
4931 );
4932 assert!(has_finding(&r, "dead_transition"));
4933 assert!(has_finding(&r, "missing_producer"));
4934 }
4935
4936 #[test]
4937 fn satisfied_requires_no_dead_transition() {
4938 let r = analyse_src(
4939 "entity App {\n status: submitted | screening | approved | rejected\n \
4940 verified: Boolean\n \
4941 transitions status {\n submitted -> screening\n screening -> approved\n \
4942 screening -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4943 rule Begin {\n when: ReviewerStarts(reviewer, app)\n \
4944 requires: app.status = submitted\n ensures: app.status = screening\n}\n\n\
4945 rule Verify {\n when: SystemVerifies(app, result)\n \
4946 requires: app.status = screening\n ensures: app.verified = result\n}\n\n\
4947 rule Approve {\n when: ReviewerApproves(reviewer, app)\n \
4948 requires:\n app.status = screening\n app.verified = true\n \
4949 ensures: app.status = approved\n}\n\n\
4950 rule Reject {\n when: ReviewerRejects(reviewer, app)\n \
4951 requires: app.status = screening\n ensures: app.status = rejected\n}\n",
4952 );
4953 assert!(!has_finding(&r, "dead_transition"));
4954 assert!(!has_finding(&r, "missing_producer"));
4955 }
4956
4957 #[test]
4958 fn deadlock_detected() {
4959 let r = analyse_src(
4960 "entity Doc {\n status: submitted | review | approved | rejected\n \
4961 reviewer_assigned: Boolean\n \
4962 transitions status {\n submitted -> review\n review -> approved\n \
4963 review -> rejected\n terminal: approved, rejected\n }\n}\n\n\
4964 rule Submit {\n when: AuthorSubmits(author, doc)\n \
4965 requires: doc.status = submitted\n ensures: doc.status = review\n}\n\n\
4966 rule Approve {\n when: ReviewerApproves(reviewer, doc)\n \
4967 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
4968 ensures: doc.status = approved\n}\n\n\
4969 rule Reject {\n when: ReviewerRejects(reviewer, doc)\n \
4970 requires:\n doc.status = review\n doc.reviewer_assigned = true\n \
4971 ensures: doc.status = rejected\n}\n",
4972 );
4973 assert!(has_finding(&r, "deadlock"));
4974 }
4975
4976 #[test]
4977 fn no_deadlock_when_paths_open() {
4978 let r = analyse_src(
4979 "entity Invoice {\n status: draft | sent | paid | void\n \
4980 transitions status {\n draft -> sent\n draft -> void\n \
4981 sent -> paid\n sent -> void\n terminal: paid, void\n }\n}\n\n\
4982 rule Send {\n when: AccountantSends(accountant, invoice)\n \
4983 requires: invoice.status = draft\n ensures: invoice.status = sent\n}\n\n\
4984 rule Pay {\n when: PaymentReceived(invoice)\n \
4985 requires: invoice.status = sent\n ensures: invoice.status = paid\n}\n\n\
4986 rule VoidDraft {\n when: AccountantVoids(accountant, invoice)\n \
4987 requires: invoice.status = draft\n ensures: invoice.status = void\n}\n\n\
4988 rule VoidSent {\n when: AccountantVoids(accountant, invoice)\n \
4989 requires: invoice.status = sent\n ensures: invoice.status = void\n}\n",
4990 );
4991 assert!(!has_finding(&r, "deadlock"));
4992 }
4993
4994 #[test]
4997 fn conflict_temporal_vs_external() {
4998 let r = analyse_src(
4999 "entity Membership {\n status: active | expired | extended\n \
5000 expires_at: Timestamp\n \
5001 transitions status {\n active -> expired\n active -> extended\n \
5002 terminal: expired, extended\n }\n}\n\n\
5003 rule AutoExpire {\n when: m: Membership.expires_at <= now\n \
5004 requires: m.status = active\n ensures: m.status = expired\n}\n\n\
5005 rule ManualExtend {\n when: AdminExtends(admin, membership)\n \
5006 requires: membership.status = active\n ensures: membership.status = extended\n}\n",
5007 );
5008 assert!(has_finding(&r, "conflict"));
5009 }
5010
5011 #[test]
5012 fn no_conflict_actor_choice() {
5013 let r = analyse_src(
5014 "entity LeaveRequest {\n status: pending | approved | denied\n \
5015 transitions status {\n pending -> approved\n pending -> denied\n \
5016 terminal: approved, denied\n }\n}\n\n\
5017 rule Approve {\n when: ManagerApproves(manager, request)\n \
5018 requires: request.status = pending\n ensures: request.status = approved\n}\n\n\
5019 rule Deny {\n when: ManagerDenies(manager, request)\n \
5020 requires: request.status = pending\n ensures: request.status = denied\n}\n",
5021 );
5022 assert!(!has_finding(&r, "conflict"));
5023 }
5024
5025 #[test]
5028 fn invariant_violation_detected() {
5029 let r = analyse_src(
5030 "entity JobRole {\n status: open | filled\n \
5031 candidacies: Candidacy with role = this\n \
5032 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
5033 entity Candidacy {\n status: active | hired | rejected\n \
5034 role: JobRole\n \
5035 transitions status {\n active -> hired\n active -> rejected\n \
5036 terminal: hired, rejected\n }\n}\n\n\
5037 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
5038 requires: candidacy.status = active\n \
5039 ensures: candidacy.status = hired\n}\n\n\
5040 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
5041 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
5042 );
5043 assert!(has_finding(&r, "invariant_risk"));
5044 }
5045
5046 #[test]
5047 fn invariant_guarded_no_violation() {
5048 let r = analyse_src(
5049 "entity JobRole {\n status: open | filled\n \
5050 candidacies: Candidacy with role = this\n \
5051 transitions status {\n open -> filled\n terminal: filled\n }\n}\n\n\
5052 entity Candidacy {\n status: active | hired | rejected\n \
5053 role: JobRole\n \
5054 transitions status {\n active -> hired\n active -> rejected\n \
5055 terminal: hired, rejected\n }\n}\n\n\
5056 rule Hire {\n when: ManagerHires(manager, candidacy)\n \
5057 requires:\n candidacy.status = active\n candidacy.role.status = open\n \
5058 ensures:\n candidacy.status = hired\n candidacy.role.status = filled\n}\n\n\
5059 invariant OneHirePerRole {\n for a in Candidacies:\n for b in Candidacies:\n \
5060 a != b and a.role = b.role implies not (a.status = hired and b.status = hired)\n}\n",
5061 );
5062 assert!(!has_finding(&r, "invariant_risk"));
5063 }
5064
5065 #[test]
5068 fn external_entity_referenced_in_rules_info() {
5069 let ds = analyze_src(
5070 "external entity Client {\n id: String\n}\n\n\
5071 rule IngestQuote {\n when: RawQuoteReceived(data)\n ensures:\n Client.lookup(data.client_id)\n}\n",
5072 );
5073 let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
5074 assert!(hint.is_some());
5075 assert_eq!(hint.unwrap().severity, Severity::Info);
5076 }
5077
5078 #[test]
5081 fn undefined_type_reference() {
5082 let ds = analyze_src("entity Foo {\n bar: MissingType\n}\n");
5083 assert!(has_code(&ds, "allium.type.undefinedReference"));
5084 }
5085
5086 #[test]
5087 fn known_type_reference_ok() {
5088 let ds = analyze_src("entity Foo {\n bar: String\n}\n");
5089 assert!(!has_code(&ds, "allium.type.undefinedReference"));
5090 }
5091
5092 #[test]
5095 fn unreachable_trigger_reported() {
5096 let ds = analyze_src(
5097 "rule A {\n when: ExternalEvent(x)\n ensures: Done()\n}\n",
5098 );
5099 assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
5100 }
5101
5102 #[test]
5105 fn unused_field_reported() {
5106 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");
5107 assert!(has_code(&ds, "allium.field.unused"));
5108 let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
5110 assert!(unused.iter().any(|d| d.message.contains("A.y")));
5111 assert!(!unused.iter().any(|d| d.message.contains("A.x")));
5112 }
5113
5114 #[test]
5117 fn unused_entity_reported() {
5118 let ds = analyze_src("entity Orphan {\n x: String\n}\n");
5119 assert!(has_code(&ds, "allium.entity.unused"));
5120 }
5121
5122 #[test]
5123 fn external_ref_suppresses_unused_entity() {
5124 let src = "entity InputEvent {\n payload: String\n}\n";
5125 let input = format!("-- allium: 3\n{src}");
5126 let result = parse(&input);
5127 let refs: HashSet<String> = ["InputEvent".to_string()].into_iter().collect();
5128 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5129 assert!(!has_code(&ds, "allium.entity.unused"));
5130 }
5131
5132 #[test]
5133 fn external_ref_suppresses_unused_definition() {
5134 let src = "value Snapshot {\n version: Integer\n}\n";
5135 let input = format!("-- allium: 3\n{src}");
5136 let result = parse(&input);
5137 let refs: HashSet<String> = ["Snapshot".to_string()].into_iter().collect();
5138 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5139 assert!(!has_code(&ds, "allium.definition.unused"));
5140 }
5141
5142 #[test]
5143 fn unreferenced_entity_still_warns_without_external_ref() {
5144 let src = "entity InputEvent {\n payload: String\n}\n";
5145 let input = format!("-- allium: 3\n{src}");
5146 let result = parse(&input);
5147 let refs: HashSet<String> = ["SomethingElse".to_string()].into_iter().collect();
5148 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5149 assert!(has_code(&ds, "allium.entity.unused"));
5150 }
5151
5152 #[test]
5153 fn collect_qualified_refs_from_rule_clause() {
5154 let src = "use \"./core.allium\" as core\n\nrule Handle {\n when: event: core/InputEvent\n ensures: event.payload = \"ok\"\n}\n";
5155 let input = format!("-- allium: 3\n{src}");
5156 let result = parse(&input);
5157 let refs = collect_qualified_references(&result.module);
5158 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
5159 }
5160
5161 #[test]
5162 fn collect_qualified_refs_from_field_type() {
5163 let src = "use \"./types.allium\" as types\n\nentity Order {\n snapshot: types/EntitySnapshot\n}\n";
5164 let input = format!("-- allium: 3\n{src}");
5165 let result = parse(&input);
5166 let refs = collect_qualified_references(&result.module);
5167 assert!(refs.iter().any(|(q, n)| q == "types" && n == "EntitySnapshot"));
5168 }
5169
5170 #[test]
5171 fn collect_qualified_refs_from_requires() {
5172 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";
5173 let input = format!("-- allium: 3\n{src}");
5174 let result = parse(&input);
5175 let refs = collect_qualified_references(&result.module);
5176 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "ValidTokens"));
5177 }
5178
5179 #[test]
5180 fn collect_qualified_refs_from_for_block() {
5181 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";
5182 let input = format!("-- allium: 3\n{src}");
5183 let result = parse(&input);
5184 let refs = collect_qualified_references(&result.module);
5185 assert!(refs.iter().any(|(q, n)| q == "core" && n == "ItemList"));
5186 }
5187
5188 #[test]
5189 fn collect_qualified_refs_from_member_access() {
5190 let src = "use \"./core.allium\" as core\n\nentity Order {\n limit: core/config.max_order_size\n}\n";
5191 let input = format!("-- allium: 3\n{src}");
5192 let result = parse(&input);
5193 let refs = collect_qualified_references(&result.module);
5194 assert!(refs.iter().any(|(q, n)| q == "core" && n == "config"));
5195 }
5196
5197 #[test]
5198 fn collect_qualified_refs_multiple_from_same_module() {
5199 let src = "use \"./core.allium\" as core\n\nentity Handler {\n input: core/InputEvent\n output: core/OutputEvent\n}\n";
5200 let input = format!("-- allium: 3\n{src}");
5201 let result = parse(&input);
5202 let refs = collect_qualified_references(&result.module);
5203 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
5204 assert!(refs.iter().any(|(q, n)| q == "core" && n == "OutputEvent"));
5205 }
5206
5207 #[test]
5208 fn collect_qualified_refs_multiple_modules() {
5209 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";
5210 let input = format!("-- allium: 3\n{src}");
5211 let result = parse(&input);
5212 let refs = collect_qualified_references(&result.module);
5213 assert!(refs.iter().any(|(q, n)| q == "core" && n == "InputEvent"));
5214 assert!(refs.iter().any(|(q, n)| q == "auth" && n == "Session"));
5215 }
5216
5217 #[test]
5218 fn collect_qualified_refs_empty_when_none() {
5219 let src = "entity Order {\n total: Decimal\n}\n";
5220 let input = format!("-- allium: 3\n{src}");
5221 let result = parse(&input);
5222 let refs = collect_qualified_references(&result.module);
5223 assert!(refs.is_empty());
5224 }
5225
5226 #[test]
5227 fn collect_qualified_refs_from_ensures() {
5228 let src = "use \"./core.allium\" as core\n\nrule Transition {\n when: order: Order\n ensures: order.status = core/Active\n}\n";
5229 let input = format!("-- allium: 3\n{src}");
5230 let result = parse(&input);
5231 let refs = collect_qualified_references(&result.module);
5232 assert!(refs.iter().any(|(q, n)| q == "core" && n == "Active"));
5233 }
5234
5235 #[test]
5236 fn collect_qualified_refs_from_invariant() {
5237 let src = "use \"./limits.allium\" as limits\n\ninvariant MaxSize {\n for o in Order: o.size <= limits/config.max_size\n}\n";
5238 let input = format!("-- allium: 3\n{src}");
5239 let result = parse(&input);
5240 let refs = collect_qualified_references(&result.module);
5241 assert!(refs.iter().any(|(q, n)| q == "limits" && n == "config"));
5242 }
5243
5244 #[test]
5245 fn collect_qualified_refs_from_deferred() {
5246 let src = "use \"./billing.allium\" as billing\n\ndeferred billing/InvoiceWorkflow\n";
5247 let input = format!("-- allium: 3\n{src}");
5248 let result = parse(&input);
5249 let refs = collect_qualified_references(&result.module);
5250 assert!(refs.iter().any(|(q, n)| q == "billing" && n == "InvoiceWorkflow"));
5251 }
5252
5253 #[test]
5254 fn collect_qualified_refs_from_alias_dot_member() {
5255 let src = "use \"./core.allium\" as core\n\nsurface Dashboard {\n facing user: User\n exposes:\n core.EntityMap\n}\n";
5256 let input = format!("-- allium: 3\n{src}");
5257 let result = parse(&input);
5258 let refs = collect_qualified_references(&result.module);
5259 assert!(refs.iter().any(|(q, n)| q == "core" && n == "EntityMap"));
5260 }
5261
5262 #[test]
5263 fn collect_all_idents_includes_unqualified_entity_ref() {
5264 let src = "use \"./core.allium\" as core\n\nrule Process {\n when: r: Record\n ensures: InputPartition.current_offset = r.offset\n}\n";
5265 let input = format!("-- allium: 3\n{src}");
5266 let result = parse(&input);
5267 let idents = collect_all_referenced_idents(&result.module);
5268 assert!(idents.contains("InputPartition"));
5269 }
5270
5271 #[test]
5272 fn collect_declared_names_returns_entity_and_value_names() {
5273 let src = "entity Order {\n x: String\n}\n\nvalue Money {\n amount: Decimal\n}\n\nenum Status {\n open\n closed\n}\n";
5274 let input = format!("-- allium: 3\n{src}");
5275 let result = parse(&input);
5276 let names = collect_declared_names(&result.module);
5277 assert!(names.contains("Order"));
5278 assert!(names.contains("Money"));
5279 assert!(names.contains("Status"));
5280 }
5281
5282 #[test]
5283 fn external_ref_only_suppresses_matching_name() {
5284 let src = "entity Used {\n x: String\n}\n\nentity Orphan {\n y: String\n}\n";
5286 let input = format!("-- allium: 3\n{src}");
5287 let result = parse(&input);
5288 let refs: HashSet<String> = ["Used".to_string()].into_iter().collect();
5289 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5290 assert!(!ds.iter().any(|d| d.code == Some("allium.entity.unused")
5291 && d.message.contains("Used")));
5292 assert!(ds.iter().any(|d| d.code == Some("allium.entity.unused")
5293 && d.message.contains("Orphan")));
5294 }
5295
5296 #[test]
5297 fn external_ref_suppresses_unused_external_entity() {
5298 let src = "external entity PaymentGateway {\n charge(amount: Decimal): Boolean\n}\n";
5299 let input = format!("-- allium: 3\n{src}");
5300 let result = parse(&input);
5301 let refs: HashSet<String> = ["PaymentGateway".to_string()].into_iter().collect();
5302 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5303 assert!(!has_code(&ds, "allium.entity.unused"));
5304 }
5305
5306 #[test]
5307 fn external_ref_suppresses_unused_enum() {
5308 let src = "enum Priority {\n low\n medium\n high\n}\n";
5309 let input = format!("-- allium: 3\n{src}");
5310 let result = parse(&input);
5311 let refs: HashSet<String> = ["Priority".to_string()].into_iter().collect();
5312 let ds = analyze_with_external_refs(&result.module, &input, &refs);
5313 assert!(!has_code(&ds, "allium.definition.unused"));
5314 }
5315
5316 #[test]
5317 fn empty_external_refs_same_as_plain_analyze() {
5318 let src = "entity Orphan {\n x: String\n}\nvalue Unused {\n y: Integer\n}\n";
5319 let input = format!("-- allium: 3\n{src}");
5320 let result = parse(&input);
5321 let plain = analyze(&result.module, &input);
5322 let with_empty = analyze_with_external_refs(&result.module, &input, &HashSet::new());
5323 assert_eq!(plain.len(), with_empty.len());
5324 for (a, b) in plain.iter().zip(with_empty.iter()) {
5325 assert_eq!(a.code, b.code);
5326 assert_eq!(a.message, b.message);
5327 }
5328 }
5329
5330 #[test]
5333 fn resolved_use_path_no_warning() {
5334 let src = "use \"./core.allium\" as core\n\nentity Handler {\n x: String\n}\n";
5335 let input = format!("-- allium: 3\n{src}");
5336 let result = parse(&input);
5337 let resolved: HashSet<String> = ["./core.allium".to_string()].into_iter().collect();
5338 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved);
5339 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
5340 }
5341
5342 #[test]
5343 fn unresolved_use_path_warns() {
5344 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
5345 let input = format!("-- allium: 3\n{src}");
5346 let result = parse(&input);
5347 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
5349 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved);
5350 assert!(has_code(&ds, "allium.use.unresolvedPath"));
5351 }
5352
5353 #[test]
5354 fn unresolved_use_path_skipped_in_single_file_mode() {
5355 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
5357 let input = format!("-- allium: 3\n{src}");
5358 let result = parse(&input);
5359 let ds = analyze_with_external_refs(&result.module, &input, &HashSet::new());
5360 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
5361 }
5362
5363 #[test]
5364 fn unresolved_use_path_fires_with_empty_resolved_set() {
5365 let src = "use \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
5368 let input = format!("-- allium: 3\n{src}");
5369 let result = parse(&input);
5370 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &HashSet::new());
5371 assert!(has_code(&ds, "allium.use.unresolvedPath"));
5372 }
5373
5374 #[test]
5375 fn unresolved_use_path_message_includes_path() {
5376 let src = "use \"./nowhere.allium\" as nowhere\n\nentity Handler {\n x: String\n}\n";
5377 let input = format!("-- allium: 3\n{src}");
5378 let result = parse(&input);
5379 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
5380 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved);
5381 let diag = ds.iter().find(|d| d.code == Some("allium.use.unresolvedPath")).unwrap();
5382 assert!(diag.message.contains("nowhere.allium"), "message should name the path: {}", diag.message);
5383 }
5384
5385 #[test]
5386 fn unresolved_use_path_suppressible() {
5387 let src = "-- allium-ignore allium.use.unresolvedPath\nuse \"./missing.allium\" as missing\n\nentity Handler {\n x: String\n}\n";
5388 let input = format!("-- allium: 3\n{src}");
5389 let result = parse(&input);
5390 let resolved: HashSet<String> = ["./other.allium".to_string()].into_iter().collect();
5391 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved);
5392 assert!(!has_code(&ds, "allium.use.unresolvedPath"));
5393 }
5394
5395 #[test]
5396 fn multiple_use_paths_mixed_resolution() {
5397 let src = "use \"./found.allium\" as found\nuse \"./lost.allium\" as lost\n\nentity Handler {\n x: String\n}\n";
5398 let input = format!("-- allium: 3\n{src}");
5399 let result = parse(&input);
5400 let resolved: HashSet<String> = ["./found.allium".to_string()].into_iter().collect();
5401 let ds = analyze_with_cross_module(&result.module, &input, &HashSet::new(), &resolved);
5402 let unresolved: Vec<_> = ds.iter()
5403 .filter(|d| d.code == Some("allium.use.unresolvedPath"))
5404 .collect();
5405 assert_eq!(unresolved.len(), 1, "only lost.allium should be unresolved");
5406 assert!(unresolved[0].message.contains("lost.allium"));
5407 }
5408
5409 #[test]
5412 fn deferred_missing_location_hint() {
5413 let ds = analyze_src("deferred Foo.bar\n");
5414 assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
5415 }
5416
5417 #[test]
5420 fn valid_trigger_ok() {
5421 let ds = analyze_src("rule A {\n when: Ping(x)\n ensures: Done()\n}\n");
5422 assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
5423 }
5424
5425 #[test]
5428 fn duplicate_let_binding() {
5429 let ds = analyze_src(
5430 "rule A {\n when: Ping(x)\n let a = 1\n let a = 2\n ensures: Done()\n}\n",
5431 );
5432 assert!(has_code(&ds, "allium.let.duplicateBinding"));
5433 }
5434
5435 #[test]
5438 fn config_undefined_reference() {
5439 let ds = analyze_src(
5440 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.missing_param > 0\n ensures: Done()\n}\n",
5441 );
5442 assert!(has_code(&ds, "allium.config.undefinedReference"));
5443 }
5444
5445 #[test]
5446 fn config_valid_reference_ok() {
5447 let ds = analyze_src(
5448 "config {\n max_retries: 3\n}\n\nrule A {\n when: Ping(x)\n requires: config.max_retries > 0\n ensures: Done()\n}\n",
5449 );
5450 assert!(!has_code(&ds, "allium.config.undefinedReference"));
5451 }
5452}