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