Skip to main content

allium_parser/
analysis.rs

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