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