Skip to main content

allium_parser/
analysis.rs

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