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