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