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