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