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;
12use crate::lexer::SourceMap;
13use crate::Span;
14
15/// Run all semantic checks on a parsed module and return any diagnostics.
16pub fn analyze(module: &Module, source: &str) -> Vec<Diagnostic> {
17    let mut ctx = Ctx::new(module);
18
19    // Existing checks
20    ctx.check_related_surface_references();
21    ctx.check_discriminator_variants();
22    ctx.check_surface_binding_usage();
23    ctx.check_status_state_machine();
24    ctx.check_external_entity_source_hints();
25
26    // New checks
27    ctx.check_type_references();
28    ctx.check_unreachable_triggers();
29    ctx.check_unused_fields();
30    ctx.check_unused_entities();
31    ctx.check_unused_definitions();
32    ctx.check_deferred_location_hints();
33    ctx.check_rule_invalid_triggers();
34    ctx.check_rule_undefined_bindings();
35    ctx.check_duplicate_let_bindings();
36    ctx.check_config_undefined_references();
37    // TODO: surface unused path check needs proper cross-reference resolution
38    // ctx.check_surface_unused_paths();
39
40    apply_suppressions(ctx.diagnostics, source)
41}
42
43// ---------------------------------------------------------------------------
44// Suppression: -- allium-ignore <code>[, <code>...]
45// ---------------------------------------------------------------------------
46
47fn apply_suppressions(diagnostics: Vec<Diagnostic>, source: &str) -> Vec<Diagnostic> {
48    if diagnostics.is_empty() {
49        return diagnostics;
50    }
51    let sm = SourceMap::new(source);
52    let directives = collect_suppression_directives(source, &sm);
53    if directives.is_empty() {
54        return diagnostics;
55    }
56    diagnostics
57        .into_iter()
58        .filter(|d| {
59            let (line, _) = sm.line_col(d.span.start);
60            let line = line as i64;
61            let active = directives
62                .get(&(line as u32))
63                .or_else(|| directives.get(&((line - 1).max(0) as u32)));
64            match (active, d.code) {
65                (Some(codes), Some(code)) => !(codes.contains("all") || codes.contains(&code)),
66                (Some(codes), None) => !codes.contains("all"),
67                _ => true,
68            }
69        })
70        .collect()
71}
72
73fn collect_suppression_directives<'a>(source: &'a str, sm: &SourceMap) -> HashMap<u32, HashSet<&'a str>> {
74    let mut directives = HashMap::new();
75    let pattern = regex_lite::Regex::new(r"(?m)^[^\S\n]*--\s*allium-ignore\s+([A-Za-z0-9._,\- \t]+)$").unwrap();
76    for m in pattern.find_iter(source) {
77        let text = m.as_str();
78        let (line, _) = sm.line_col(m.start());
79        // Extract the codes portion after "allium-ignore "
80        if let Some(idx) = text.find("allium-ignore") {
81            let offset = m.start() + idx + "allium-ignore".len();
82            let source_after = &source[offset..m.end()];
83            let codes: HashSet<&'a str> = source_after
84                .split(',')
85                .map(|c| c.trim())
86                .filter(|c| !c.is_empty())
87                .collect();
88            directives.insert(line, codes);
89        }
90    }
91    directives
92}
93
94// ---------------------------------------------------------------------------
95// Analysis context
96// ---------------------------------------------------------------------------
97
98struct Ctx<'a> {
99    module: &'a Module,
100    diagnostics: Vec<Diagnostic>,
101}
102
103impl<'a> Ctx<'a> {
104    fn new(module: &'a Module) -> Self {
105        Self {
106            module,
107            diagnostics: Vec::new(),
108        }
109    }
110
111    fn blocks(&self, kind: BlockKind) -> impl Iterator<Item = &'a BlockDecl> {
112        self.module.declarations.iter().filter_map(move |d| match d {
113            Decl::Block(b) if b.kind == kind => Some(b),
114            _ => None,
115        })
116    }
117
118    fn variants(&self) -> impl Iterator<Item = &'a VariantDecl> {
119        self.module
120            .declarations
121            .iter()
122            .filter_map(|d| match d {
123                Decl::Variant(v) => Some(v),
124                _ => None,
125            })
126    }
127
128    fn has_use_imports(&self) -> bool {
129        self.module
130            .declarations
131            .iter()
132            .any(|d| matches!(d, Decl::Use(_)))
133    }
134
135    fn push(&mut self, d: Diagnostic) {
136        self.diagnostics.push(d);
137    }
138
139    /// All declared type names (entities, values, enums, actors, variants, externals)
140    /// plus built-in types.
141    fn declared_type_names(&self) -> HashSet<&'a str> {
142        let mut names = HashSet::new();
143        for d in &self.module.declarations {
144            match d {
145                Decl::Block(b) => {
146                    if matches!(
147                        b.kind,
148                        BlockKind::Entity
149                            | BlockKind::ExternalEntity
150                            | BlockKind::Value
151                            | BlockKind::Enum
152                            | BlockKind::Actor
153                    ) {
154                        if let Some(n) = &b.name {
155                            names.insert(n.name.as_str());
156                        }
157                    }
158                }
159                Decl::Variant(v) => {
160                    names.insert(v.name.name.as_str());
161                }
162                _ => {}
163            }
164        }
165        // Built-in types
166        for t in &[
167            "String", "Integer", "Decimal", "Boolean", "Timestamp", "Duration",
168            "List", "Set", "Map", "Any", "Void",
169        ] {
170            names.insert(t);
171        }
172        // Use aliases
173        for d in &self.module.declarations {
174            if let Decl::Use(u) = d {
175                if let Some(alias) = &u.alias {
176                    names.insert(alias.name.as_str());
177                }
178            }
179        }
180        names
181    }
182
183    /// Collect all field names accessed via member access across the module.
184    fn collect_all_accessed_field_names(&self) -> HashSet<&'a str> {
185        let mut names = HashSet::new();
186        for d in &self.module.declarations {
187            match d {
188                Decl::Block(b) => {
189                    for item in &b.items {
190                        collect_accessed_fields_from_item(&item.kind, &mut names);
191                    }
192                }
193                Decl::Invariant(inv) => {
194                    collect_accessed_fields_from_expr(&inv.body, &mut names);
195                }
196                _ => {}
197            }
198        }
199        names
200    }
201}
202
203// ---------------------------------------------------------------------------
204// 1. Related surface references
205// ---------------------------------------------------------------------------
206
207impl Ctx<'_> {
208    fn check_related_surface_references(&mut self) {
209        let surface_names: HashSet<&str> = self
210            .blocks(BlockKind::Surface)
211            .filter_map(|b| b.name.as_ref().map(|n| n.name.as_str()))
212            .collect();
213
214        for surface in self.blocks(BlockKind::Surface) {
215            let surface_name = match &surface.name {
216                Some(n) => &n.name,
217                None => continue,
218            };
219
220            for item in &surface.items {
221                let BlockItemKind::Clause { keyword, value } = &item.kind else {
222                    continue;
223                };
224                if keyword != "related" {
225                    continue;
226                }
227
228                let refs = extract_related_surface_names(value);
229                for ident in refs {
230                    if !surface_names.contains(ident.name.as_str()) {
231                        self.push(
232                            Diagnostic::error(
233                                ident.span,
234                                format!(
235                                    "Surface '{surface_name}' references unknown related surface '{}'.",
236                                    ident.name
237                                ),
238                            )
239                            .with_code("allium.surface.relatedUndefined"),
240                        );
241                    }
242                }
243            }
244        }
245    }
246}
247
248fn extract_related_surface_names(expr: &Expr) -> Vec<&Ident> {
249    match expr {
250        Expr::Ident(id) => vec![id],
251        Expr::Call { function, .. } => extract_leading_ident(function).into_iter().collect(),
252        Expr::WhenGuard { action, .. } => extract_related_surface_names(action),
253        Expr::Block { items, .. } => items
254            .iter()
255            .flat_map(extract_related_surface_names)
256            .collect(),
257        _ => vec![],
258    }
259}
260
261fn extract_leading_ident(expr: &Expr) -> Option<&Ident> {
262    match expr {
263        Expr::Ident(id) => Some(id),
264        Expr::MemberAccess { object, .. } => extract_leading_ident(object),
265        _ => None,
266    }
267}
268
269// ---------------------------------------------------------------------------
270// 2. Discriminator / variant checks
271// ---------------------------------------------------------------------------
272
273impl Ctx<'_> {
274    fn check_discriminator_variants(&mut self) {
275        let mut variants_by_base: HashMap<&str, HashSet<&str>> = HashMap::new();
276        for v in self.variants() {
277            let base_name = expr_as_ident(&v.base).or_else(|| {
278                // Parser may represent `variant X : Base { ... }` as JoinLookup
279                if let Expr::JoinLookup { entity, .. } = &v.base {
280                    expr_as_ident(entity)
281                } else {
282                    None
283                }
284            });
285            if let Some(base_name) = base_name {
286                variants_by_base
287                    .entry(base_name)
288                    .or_default()
289                    .insert(&v.name.name);
290            }
291        }
292
293        for entity in self.blocks(BlockKind::Entity) {
294            let entity_name = match &entity.name {
295                Some(n) => &n.name,
296                None => continue,
297            };
298
299            for item in &entity.items {
300                let BlockItemKind::Assignment { name: field_name, value } = &item.kind else {
301                    continue;
302                };
303
304                let mut pipe_idents = Vec::new();
305                collect_pipe_idents(value, &mut pipe_idents);
306                if pipe_idents.len() < 2 {
307                    continue;
308                }
309
310                let has_capitalised = pipe_idents.iter().any(|id| starts_uppercase(&id.name));
311                if !has_capitalised {
312                    continue;
313                }
314
315                let all_capitalised = pipe_idents.iter().all(|id| starts_uppercase(&id.name));
316                if !all_capitalised {
317                    self.push(
318                        Diagnostic::error(
319                            value.span(),
320                            format!(
321                                "Entity '{entity_name}' discriminator '{}' must use only capitalised variant names.",
322                                field_name.name
323                            ),
324                        )
325                        .with_code("allium.sum.invalidDiscriminator"),
326                    );
327                    continue;
328                }
329
330                let declared = variants_by_base
331                    .get(entity_name.as_str())
332                    .cloned()
333                    .unwrap_or_default();
334
335                let missing: Vec<&&Ident> = pipe_idents
336                    .iter()
337                    .filter(|id| !declared.contains(id.name.as_str()))
338                    .collect();
339
340                if missing.len() == pipe_idents.len() && declared.is_empty() {
341                    self.push(
342                        Diagnostic::error(
343                            value.span(),
344                            format!(
345                                "Entity '{entity_name}' field '{}' uses capitalised pipe values with no variant declarations. \
346                                 In v3, capitalised values are variant references requiring 'variant X : {entity_name}' \
347                                 declarations. Use lowercase values for a plain enum.",
348                                field_name.name
349                            ),
350                        )
351                        .with_code("allium.sum.v1InlineEnum"),
352                    );
353                } else {
354                    for id in missing {
355                        self.push(
356                            Diagnostic::error(
357                                id.span,
358                                format!(
359                                    "Entity '{entity_name}' discriminator references '{}' without matching \
360                                     'variant {} : {entity_name}'.",
361                                    id.name, id.name
362                                ),
363                            )
364                            .with_code("allium.sum.discriminatorUnknownVariant"),
365                        );
366                    }
367                }
368            }
369        }
370    }
371}
372
373fn starts_uppercase(s: &str) -> bool {
374    s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
375}
376
377fn collect_pipe_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
378    match expr {
379        Expr::Ident(id) => out.push(id),
380        Expr::Pipe { left, right, .. } => {
381            collect_pipe_idents(left, out);
382            collect_pipe_idents(right, out);
383        }
384        _ => {}
385    }
386}
387
388fn expr_as_ident(expr: &Expr) -> Option<&str> {
389    match expr {
390        Expr::Ident(id) => Some(&id.name),
391        _ => None,
392    }
393}
394
395// ---------------------------------------------------------------------------
396// 3. Unused surface bindings (skip _ discard binding)
397// ---------------------------------------------------------------------------
398
399impl Ctx<'_> {
400    fn check_surface_binding_usage(&mut self) {
401        for surface in self.blocks(BlockKind::Surface) {
402            let surface_name = match &surface.name {
403                Some(n) => &n.name,
404                None => continue,
405            };
406
407            // Only check facing bindings for unused if surface has provides
408            let has_provides = surface
409                .items
410                .iter()
411                .any(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "provides"));
412
413            let mut bindings: Vec<(&str, Span, bool)> = Vec::new(); // name, span, is_facing
414            for item in &surface.items {
415                let BlockItemKind::Clause { keyword, value } = &item.kind else {
416                    continue;
417                };
418                if keyword != "facing" && keyword != "context" {
419                    continue;
420                }
421                if let Expr::Binding { name, .. } = value {
422                    bindings.push((&name.name, name.span, keyword == "facing"));
423                }
424            }
425
426            for (name, span, is_facing) in &bindings {
427                if *name == "_" {
428                    continue;
429                }
430                // Facing bindings are only meaningful in surfaces with provides
431                if *is_facing && !has_provides {
432                    continue;
433                }
434                let used = surface.items.iter().any(|item| {
435                    let BlockItemKind::Clause { keyword, value } = &item.kind else {
436                        return item_contains_ident(&item.kind, name);
437                    };
438                    if keyword == "facing" || keyword == "context" {
439                        if let Expr::Binding {
440                            name: binding_name, ..
441                        } = value
442                        {
443                            if binding_name.name == *name {
444                                return false;
445                            }
446                        }
447                    }
448                    expr_contains_ident(value, name)
449                });
450
451                if !used {
452                    self.push(
453                        Diagnostic::warning(
454                            *span,
455                            format!(
456                                "Surface '{surface_name}' binding '{name}' is not used in the surface body.",
457                            ),
458                        )
459                        .with_code("allium.surface.unusedBinding"),
460                    );
461                }
462            }
463        }
464    }
465}
466
467// ---------------------------------------------------------------------------
468// 4. Status state machine (unreachable / noExit)
469// ---------------------------------------------------------------------------
470
471impl Ctx<'_> {
472    fn check_status_state_machine(&mut self) {
473        let mut status_by_entity: HashMap<&str, (Vec<&Ident>, HashSet<&str>)> = HashMap::new();
474        for entity in self.blocks(BlockKind::Entity) {
475            let entity_name = match &entity.name {
476                Some(n) => n.name.as_str(),
477                None => continue,
478            };
479            for item in &entity.items {
480                let BlockItemKind::Assignment { name, value } = &item.kind else {
481                    continue;
482                };
483                if name.name != "status" {
484                    continue;
485                }
486                let mut idents = Vec::new();
487                collect_pipe_idents(value, &mut idents);
488                if idents.len() < 2 {
489                    continue;
490                }
491                if idents.iter().any(|id| starts_uppercase(&id.name)) {
492                    continue;
493                }
494                let set: HashSet<&str> = idents.iter().map(|id| id.name.as_str()).collect();
495                status_by_entity.insert(entity_name, (idents, set));
496            }
497        }
498
499        if status_by_entity.is_empty() {
500            return;
501        }
502
503        let mut assigned_by_entity: HashMap<&str, HashSet<&str>> = HashMap::new();
504        let mut transitions_by_entity: HashMap<&str, HashMap<&str, HashSet<&str>>> =
505            HashMap::new();
506
507        for rule in self.blocks(BlockKind::Rule) {
508            let binding_types = collect_rule_binding_types(rule, &status_by_entity);
509            let mut requires_by_binding: HashMap<&str, HashSet<&str>> = HashMap::new();
510
511            for item in &rule.items {
512                let BlockItemKind::Clause { keyword, value } = &item.kind else {
513                    continue;
514                };
515                if keyword != "requires" {
516                    continue;
517                }
518                visit_status_comparisons(
519                    value,
520                    &binding_types,
521                    &status_by_entity,
522                    &mut |binding, status| {
523                        requires_by_binding
524                            .entry(binding)
525                            .or_default()
526                            .insert(status);
527                    },
528                );
529            }
530
531            for item in &rule.items {
532                let BlockItemKind::Clause { keyword, value } = &item.kind else {
533                    continue;
534                };
535                if keyword != "ensures" {
536                    continue;
537                }
538                visit_status_assignments(
539                    value,
540                    &binding_types,
541                    &status_by_entity,
542                    &mut |binding, target, entity| {
543                        assigned_by_entity
544                            .entry(entity)
545                            .or_default()
546                            .insert(target);
547
548                        if let Some(sources) = requires_by_binding.get(binding) {
549                            let entity_transitions =
550                                transitions_by_entity.entry(entity).or_default();
551                            for source in sources {
552                                entity_transitions
553                                    .entry(source)
554                                    .or_default()
555                                    .insert(target);
556                            }
557                        }
558                    },
559                );
560            }
561        }
562
563        for (entity_name, (idents, values)) in &status_by_entity {
564            let assigned = assigned_by_entity.get(entity_name);
565            let transitions = transitions_by_entity.get(entity_name);
566
567            if let Some(assigned) = assigned {
568                if assigned.iter().any(|v| !values.contains(v)) {
569                    continue;
570                }
571            }
572
573            let assigned_set = assigned.cloned().unwrap_or_default();
574            let transition_map = transitions.cloned().unwrap_or_default();
575
576            for id in idents {
577                if !assigned_set.contains(id.name.as_str()) {
578                    self.push(
579                        Diagnostic::warning(
580                            id.span,
581                            format!(
582                                "Status '{}' in entity '{entity_name}' is never assigned by any rule ensures clause.",
583                                id.name
584                            ),
585                        )
586                        .with_code("allium.status.unreachableValue"),
587                    );
588                }
589
590                if is_likely_terminal(&id.name) {
591                    continue;
592                }
593                let exits = transition_map.get(id.name.as_str());
594                if exits.is_some_and(|e| !e.is_empty()) {
595                    continue;
596                }
597                self.push(
598                    Diagnostic::warning(
599                        id.span,
600                        format!(
601                            "Status '{}' in entity '{entity_name}' has no observed transition to a different status.",
602                            id.name
603                        ),
604                    )
605                    .with_code("allium.status.noExit"),
606                );
607            }
608        }
609    }
610}
611
612fn collect_rule_binding_types<'a>(
613    rule: &'a BlockDecl,
614    status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
615) -> HashMap<&'a str, &'a str> {
616    let mut types = HashMap::new();
617    for item in &rule.items {
618        let BlockItemKind::Clause { keyword, value } = &item.kind else {
619            continue;
620        };
621        if keyword != "when" {
622            continue;
623        }
624        collect_binding_types_from_expr(value, status_by_entity, &mut types);
625    }
626    types
627}
628
629fn collect_binding_types_from_expr<'a>(
630    expr: &'a Expr,
631    status_by_entity: &HashMap<&str, (Vec<&Ident>, HashSet<&str>)>,
632    out: &mut HashMap<&'a str, &'a str>,
633) {
634    match expr {
635        Expr::Binding { name, value, .. } => {
636            if let Some(entity_name) = extract_entity_from_trigger(value) {
637                if status_by_entity.contains_key(entity_name) {
638                    out.insert(&name.name, entity_name);
639                }
640            }
641        }
642        Expr::Call { function, args, .. } => {
643            if let Expr::Ident(fn_name) = function.as_ref() {
644                for arg in args {
645                    if let CallArg::Positional(Expr::Ident(binding)) = arg {
646                        if status_by_entity.contains_key(fn_name.name.as_str()) {
647                            out.insert(&binding.name, &fn_name.name);
648                        }
649                    }
650                }
651            }
652        }
653        Expr::LogicalOp { left, right, .. } => {
654            collect_binding_types_from_expr(left, status_by_entity, out);
655            collect_binding_types_from_expr(right, status_by_entity, out);
656        }
657        _ => {}
658    }
659}
660
661fn extract_entity_from_trigger(expr: &Expr) -> Option<&str> {
662    match expr {
663        Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
664            extract_entity_from_member(subject)
665        }
666        Expr::MemberAccess { object, .. } => expr_as_ident(object),
667        _ => None,
668    }
669}
670
671fn extract_entity_from_member(expr: &Expr) -> Option<&str> {
672    match expr {
673        Expr::MemberAccess { object, .. } => expr_as_ident(object),
674        _ => None,
675    }
676}
677
678fn visit_status_assignments<'a>(
679    expr: &'a Expr,
680    binding_types: &HashMap<&'a str, &'a str>,
681    status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
682    cb: &mut impl FnMut(&'a str, &'a str, &'a str),
683) {
684    match expr {
685        Expr::Comparison {
686            left,
687            op: ComparisonOp::Eq,
688            right,
689            ..
690        } => {
691            if let (Some((binding, "status")), Some(target)) =
692                (expr_as_member_access(left), expr_as_ident(right))
693            {
694                let entity = binding_types.get(binding).copied().or_else(|| {
695                    status_by_entity
696                        .keys()
697                        .find(|name| name.eq_ignore_ascii_case(binding))
698                        .copied()
699                });
700                if let Some(entity) = entity {
701                    cb(binding, target, entity);
702                }
703            }
704        }
705        Expr::Block { items, .. } => {
706            for item in items {
707                visit_status_assignments(item, binding_types, status_by_entity, cb);
708            }
709        }
710        Expr::Conditional {
711            branches,
712            else_body,
713            ..
714        } => {
715            for branch in branches {
716                visit_status_assignments(&branch.body, binding_types, status_by_entity, cb);
717            }
718            if let Some(body) = else_body {
719                visit_status_assignments(body, binding_types, status_by_entity, cb);
720            }
721        }
722        _ => {}
723    }
724}
725
726fn visit_status_comparisons<'a>(
727    expr: &'a Expr,
728    binding_types: &HashMap<&'a str, &'a str>,
729    status_by_entity: &HashMap<&'a str, (Vec<&Ident>, HashSet<&'a str>)>,
730    cb: &mut impl FnMut(&'a str, &'a str),
731) {
732    match expr {
733        Expr::Comparison {
734            left,
735            op: ComparisonOp::Eq,
736            right,
737            ..
738        } => {
739            if let (Some((binding, "status")), Some(target)) =
740                (expr_as_member_access(left), expr_as_ident(right))
741            {
742                let known = binding_types.contains_key(binding)
743                    || status_by_entity
744                        .keys()
745                        .any(|name| name.eq_ignore_ascii_case(binding));
746                if known {
747                    cb(binding, target);
748                }
749            }
750        }
751        Expr::LogicalOp { left, right, .. } => {
752            visit_status_comparisons(left, binding_types, status_by_entity, cb);
753            visit_status_comparisons(right, binding_types, status_by_entity, cb);
754        }
755        Expr::Block { items, .. } => {
756            for item in items {
757                visit_status_comparisons(item, binding_types, status_by_entity, cb);
758            }
759        }
760        _ => {}
761    }
762}
763
764fn expr_as_member_access(expr: &Expr) -> Option<(&str, &str)> {
765    match expr {
766        Expr::MemberAccess { object, field, .. } => {
767            expr_as_ident(object).map(|obj| (obj, field.name.as_str()))
768        }
769        _ => None,
770    }
771}
772
773fn is_likely_terminal(status: &str) -> bool {
774    matches!(
775        status,
776        "completed"
777            | "cancelled"
778            | "canceled"
779            | "expired"
780            | "closed"
781            | "deleted"
782            | "archived"
783            | "failed"
784            | "rejected"
785            | "done"
786    )
787}
788
789// ---------------------------------------------------------------------------
790// 5. External entity source hints
791// ---------------------------------------------------------------------------
792
793impl Ctx<'_> {
794    fn check_external_entity_source_hints(&mut self) {
795        if self.has_use_imports() {
796            return;
797        }
798
799        let rule_blocks: Vec<&BlockDecl> = self.blocks(BlockKind::Rule).collect();
800
801        for entity in self.blocks(BlockKind::ExternalEntity) {
802            let name = match &entity.name {
803                Some(n) => n,
804                None => continue,
805            };
806
807            let referenced_in_rules = rule_blocks
808                .iter()
809                .any(|rule| rule.items.iter().any(|i| item_contains_ident(&i.kind, &name.name)));
810
811            let msg = format!(
812                "External entity '{}' has no obvious governing specification import in this module.",
813                name.name
814            );
815            if referenced_in_rules {
816                self.push(Diagnostic::info(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
817            } else {
818                self.push(Diagnostic::warning(name.span, msg).with_code("allium.externalEntity.missingSourceHint"));
819            }
820        }
821    }
822}
823
824// ---------------------------------------------------------------------------
825// 6. Type reference checks (undeclared types in entity/value fields)
826// ---------------------------------------------------------------------------
827
828impl Ctx<'_> {
829    fn check_type_references(&mut self) {
830        let known = self.declared_type_names();
831
832        for d in &self.module.declarations {
833            let block = match d {
834                Decl::Block(b)
835                    if matches!(
836                        b.kind,
837                        BlockKind::Entity
838                            | BlockKind::ExternalEntity
839                            | BlockKind::Value
840                    ) =>
841                {
842                    b
843                }
844                Decl::Variant(v) => {
845                    // Check variant items
846                    for item in &v.items {
847                        self.check_type_ref_in_item(item, &known);
848                    }
849                    continue;
850                }
851                _ => continue,
852            };
853
854            for item in &block.items {
855                self.check_type_ref_in_item(item, &known);
856            }
857        }
858
859        // Check rule type references (when clauses, ensures entity references)
860        for rule in self.blocks(BlockKind::Rule) {
861            for item in &rule.items {
862                let BlockItemKind::Clause { keyword, value } = &item.kind else {
863                    continue;
864                };
865                if keyword == "when" || keyword == "ensures" || keyword == "requires" {
866                    self.check_type_refs_in_rule_expr(value, &known);
867                }
868            }
869        }
870    }
871
872    fn check_type_ref_in_item(&mut self, item: &BlockItem, known: &HashSet<&str>) {
873        match &item.kind {
874            BlockItemKind::Assignment { value, .. }
875            | BlockItemKind::FieldWithWhen { value, .. } => {
876                self.check_type_refs_in_value(value, known);
877            }
878            _ => {}
879        }
880    }
881
882    fn check_type_refs_in_value(&mut self, expr: &Expr, known: &HashSet<&str>) {
883        match expr {
884            Expr::Ident(id) if starts_uppercase(&id.name) => {
885                if !known.contains(id.name.as_str()) {
886                    self.push(
887                        Diagnostic::error(
888                            id.span,
889                            format!(
890                                "Type reference '{}' is not declared locally or imported.",
891                                id.name
892                            ),
893                        )
894                        .with_code("allium.type.undefinedReference"),
895                    );
896                }
897            }
898            Expr::GenericType { name, args, .. } => {
899                self.check_type_refs_in_value(name, known);
900                for arg in args {
901                    self.check_type_refs_in_value(arg, known);
902                }
903            }
904            Expr::Pipe { left, right, .. } => {
905                self.check_type_refs_in_value(left, known);
906                self.check_type_refs_in_value(right, known);
907            }
908            Expr::TypeOptional { inner, .. } => {
909                self.check_type_refs_in_value(inner, known);
910            }
911            _ => {}
912        }
913    }
914
915    fn check_type_refs_in_rule_expr(&mut self, expr: &Expr, known: &HashSet<&str>) {
916        match expr {
917            // binding: Entity.field becomes ... — check Entity
918            Expr::Binding { value, .. } => {
919                self.check_type_refs_in_rule_expr(value, known);
920            }
921            Expr::Becomes { subject, .. } | Expr::TransitionsTo { subject, .. } => {
922                if let Expr::MemberAccess { object, .. } = subject.as_ref() {
923                    if let Expr::Ident(id) = object.as_ref() {
924                        if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
925                            self.push(
926                                Diagnostic::error(
927                                    id.span,
928                                    format!(
929                                        "Type reference '{}' is not declared locally or imported.",
930                                        id.name
931                                    ),
932                                )
933                                .with_code("allium.rule.undefinedTypeReference"),
934                            );
935                        }
936                    }
937                }
938            }
939            // Entity.created(...) or Entity.lookup(...)
940            Expr::Call { function, .. } => {
941                if let Expr::MemberAccess { object, .. } = function.as_ref() {
942                    if let Expr::Ident(id) = object.as_ref() {
943                        if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
944                            self.push(
945                                Diagnostic::error(
946                                    id.span,
947                                    format!(
948                                        "Type reference '{}' is not declared locally or imported.",
949                                        id.name
950                                    ),
951                                )
952                                .with_code("allium.rule.undefinedTypeReference"),
953                            );
954                        }
955                    }
956                }
957            }
958            // Entity.created or Entity.field (in binding triggers)
959            Expr::MemberAccess { object, .. } => {
960                if let Expr::Ident(id) = object.as_ref() {
961                    if starts_uppercase(&id.name) && !known.contains(id.name.as_str()) {
962                        self.push(
963                            Diagnostic::error(
964                                id.span,
965                                format!(
966                                    "Type reference '{}' is not declared locally or imported.",
967                                    id.name
968                                ),
969                            )
970                            .with_code("allium.rule.undefinedTypeReference"),
971                        );
972                    }
973                }
974            }
975            Expr::Block { items, .. } => {
976                for item in items {
977                    self.check_type_refs_in_rule_expr(item, known);
978                }
979            }
980            Expr::LogicalOp { left, right, .. } => {
981                self.check_type_refs_in_rule_expr(left, known);
982                self.check_type_refs_in_rule_expr(right, known);
983            }
984            _ => {}
985        }
986    }
987}
988
989// ---------------------------------------------------------------------------
990// 7. Unreachable triggers
991// ---------------------------------------------------------------------------
992
993impl Ctx<'_> {
994    fn check_unreachable_triggers(&mut self) {
995        // Collect triggers provided by surfaces
996        let mut provided: HashSet<&str> = HashSet::new();
997        for surface in self.blocks(BlockKind::Surface) {
998            for item in &surface.items {
999                let BlockItemKind::Clause { keyword, value } = &item.kind else {
1000                    continue;
1001                };
1002                if keyword != "provides" {
1003                    continue;
1004                }
1005                collect_call_names(value, &mut provided);
1006            }
1007        }
1008
1009        // Collect triggers emitted by rule ensures clauses.
1010        // Only collect the leading call in each ensures value, matching the
1011        // TS regex which captures only the first identifier after `ensures:`.
1012        let mut emitted: HashSet<&str> = HashSet::new();
1013        for rule in self.blocks(BlockKind::Rule) {
1014            for item in &rule.items {
1015                collect_emitted_trigger_from_item(&item.kind, &mut emitted);
1016            }
1017        }
1018
1019        for rule in self.blocks(BlockKind::Rule) {
1020            let rule_name = match &rule.name {
1021                Some(n) => &n.name,
1022                None => continue,
1023            };
1024            for item in &rule.items {
1025                let BlockItemKind::Clause { keyword, value } = &item.kind else {
1026                    continue;
1027                };
1028                if keyword != "when" {
1029                    continue;
1030                }
1031                let trigger_names = extract_trigger_names(value);
1032                for (name, span) in trigger_names {
1033                    if !provided.contains(name) && !emitted.contains(name) {
1034                        self.push(
1035                            Diagnostic::info(
1036                                span,
1037                                format!(
1038                                    "Rule '{rule_name}' listens for trigger '{name}' but no local surface provides or rule emits it.",
1039                                ),
1040                            )
1041                            .with_code("allium.rule.unreachableTrigger"),
1042                        );
1043                    }
1044                }
1045            }
1046        }
1047    }
1048}
1049
1050/// Collect emitted triggers from block items, only looking at ensures clauses
1051/// and recursing into for/if blocks for nested ensures.
1052fn collect_emitted_trigger_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1053    match kind {
1054        BlockItemKind::Clause { keyword, value } if keyword == "ensures" => {
1055            collect_leading_ensures_call(value, out);
1056        }
1057        BlockItemKind::ForBlock { items, .. } => {
1058            for item in items {
1059                collect_emitted_trigger_from_item(&item.kind, out);
1060            }
1061        }
1062        BlockItemKind::IfBlock { branches, else_items, .. } => {
1063            for b in branches {
1064                for item in &b.items {
1065                    collect_emitted_trigger_from_item(&item.kind, out);
1066                }
1067            }
1068            if let Some(items) = else_items {
1069                for item in items {
1070                    collect_emitted_trigger_from_item(&item.kind, out);
1071                }
1072            }
1073        }
1074        _ => {}
1075    }
1076}
1077
1078/// Extract only the leading PascalCase call from an ensures expression,
1079/// matching the TS regex which captures only the first identifier followed
1080/// by `(` after `ensures:`.
1081fn collect_leading_ensures_call<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1082    match expr {
1083        Expr::Call { function, .. } => {
1084            if let Expr::Ident(id) = function.as_ref() {
1085                if starts_uppercase(&id.name) {
1086                    out.insert(&id.name);
1087                }
1088            }
1089        }
1090        Expr::Block { items, .. } => {
1091            if let Some(first) = items.first() {
1092                collect_leading_ensures_call(first, out);
1093            }
1094        }
1095        _ => {}
1096    }
1097}
1098
1099fn collect_call_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1100    match expr {
1101        Expr::Call { function, .. } => {
1102            if let Expr::Ident(id) = function.as_ref() {
1103                if starts_uppercase(&id.name) {
1104                    out.insert(&id.name);
1105                }
1106            }
1107        }
1108        Expr::Block { items, .. } => {
1109            for item in items {
1110                collect_call_names(item, out);
1111            }
1112        }
1113        Expr::WhenGuard { action, .. } => {
1114            collect_call_names(action, out);
1115        }
1116        Expr::Conditional { branches, else_body, .. } => {
1117            for b in branches {
1118                collect_call_names(&b.body, out);
1119            }
1120            if let Some(body) = else_body {
1121                collect_call_names(body, out);
1122            }
1123        }
1124        _ => {}
1125    }
1126}
1127
1128fn extract_trigger_names(expr: &Expr) -> Vec<(&str, Span)> {
1129    match expr {
1130        Expr::Call { function, .. } => {
1131            if let Expr::Ident(id) = function.as_ref() {
1132                if starts_uppercase(&id.name) {
1133                    return vec![(&id.name, id.span)];
1134                }
1135            }
1136            vec![]
1137        }
1138        Expr::Binding { .. } => {
1139            // binding: Entity.field becomes ... — not a trigger call
1140            vec![]
1141        }
1142        Expr::LogicalOp { left, right, .. } => {
1143            let mut out = extract_trigger_names(left);
1144            out.extend(extract_trigger_names(right));
1145            out
1146        }
1147        _ => vec![],
1148    }
1149}
1150
1151// ---------------------------------------------------------------------------
1152// 8. Unused fields
1153// ---------------------------------------------------------------------------
1154
1155impl Ctx<'_> {
1156    fn check_unused_fields(&mut self) {
1157        let accessed = self.collect_all_accessed_field_names();
1158
1159        for d in &self.module.declarations {
1160            let block = match d {
1161                Decl::Block(b)
1162                    if matches!(
1163                        b.kind,
1164                        BlockKind::Entity | BlockKind::ExternalEntity
1165                    ) =>
1166                {
1167                    b
1168                }
1169                Decl::Variant(v) => {
1170                    let entity_name = &v.name.name;
1171                    for item in &v.items {
1172                        if let BlockItemKind::Assignment { name, .. }
1173                        | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
1174                        {
1175                            if !accessed.contains(name.name.as_str()) {
1176                                self.push(
1177                                    Diagnostic::info(
1178                                        name.span,
1179                                        format!(
1180                                            "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
1181                                            name.name
1182                                        ),
1183                                    )
1184                                    .with_code("allium.field.unused"),
1185                                );
1186                            }
1187                        }
1188                    }
1189                    continue;
1190                }
1191                _ => continue,
1192            };
1193
1194            let entity_name = match &block.name {
1195                Some(n) => &n.name,
1196                None => continue,
1197            };
1198
1199            for item in &block.items {
1200                if let BlockItemKind::Assignment { name, .. }
1201                | BlockItemKind::FieldWithWhen { name, .. } = &item.kind
1202                {
1203                    if !accessed.contains(name.name.as_str()) {
1204                        self.push(
1205                            Diagnostic::info(
1206                                name.span,
1207                                format!(
1208                                    "Field '{entity_name}.{}' is declared but not referenced elsewhere.",
1209                                    name.name
1210                                ),
1211                            )
1212                            .with_code("allium.field.unused"),
1213                        );
1214                    }
1215                }
1216            }
1217        }
1218    }
1219}
1220
1221fn collect_accessed_fields_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1222    match kind {
1223        BlockItemKind::Clause { value, .. }
1224        | BlockItemKind::Assignment { value, .. }
1225        | BlockItemKind::ParamAssignment { value, .. }
1226        | BlockItemKind::Let { value, .. }
1227        | BlockItemKind::PathAssignment { value, .. }
1228        | BlockItemKind::InvariantBlock { body: value, .. }
1229        | BlockItemKind::FieldWithWhen { value, .. } => {
1230            collect_accessed_fields_from_expr(value, out);
1231        }
1232        BlockItemKind::ForBlock {
1233            collection,
1234            filter,
1235            items,
1236            ..
1237        } => {
1238            collect_accessed_fields_from_expr(collection, out);
1239            if let Some(f) = filter {
1240                collect_accessed_fields_from_expr(f, out);
1241            }
1242            for item in items {
1243                collect_accessed_fields_from_item(&item.kind, out);
1244            }
1245        }
1246        BlockItemKind::IfBlock {
1247            branches,
1248            else_items,
1249        } => {
1250            for b in branches {
1251                collect_accessed_fields_from_expr(&b.condition, out);
1252                for item in &b.items {
1253                    collect_accessed_fields_from_item(&item.kind, out);
1254                }
1255            }
1256            if let Some(items) = else_items {
1257                for item in items {
1258                    collect_accessed_fields_from_item(&item.kind, out);
1259                }
1260            }
1261        }
1262        _ => {}
1263    }
1264}
1265
1266fn collect_accessed_fields_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1267    match expr {
1268        Expr::MemberAccess { object, field, .. } | Expr::OptionalAccess { object, field, .. } => {
1269            out.insert(&field.name);
1270            collect_accessed_fields_from_expr(object, out);
1271        }
1272        Expr::Call { function, args, .. } => {
1273            collect_accessed_fields_from_expr(function, out);
1274            for a in args {
1275                match a {
1276                    CallArg::Positional(e) => collect_accessed_fields_from_expr(e, out),
1277                    CallArg::Named(n) => collect_accessed_fields_from_expr(&n.value, out),
1278                }
1279            }
1280        }
1281        Expr::BinaryOp { left, right, .. }
1282        | Expr::Comparison { left, right, .. }
1283        | Expr::LogicalOp { left, right, .. }
1284        | Expr::Pipe { left, right, .. }
1285        | Expr::NullCoalesce { left, right, .. } => {
1286            collect_accessed_fields_from_expr(left, out);
1287            collect_accessed_fields_from_expr(right, out);
1288        }
1289        Expr::Not { operand, .. }
1290        | Expr::Exists { operand, .. }
1291        | Expr::NotExists { operand, .. }
1292        | Expr::TypeOptional { inner: operand, .. } => {
1293            collect_accessed_fields_from_expr(operand, out);
1294        }
1295        Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
1296            collect_accessed_fields_from_expr(element, out);
1297            collect_accessed_fields_from_expr(collection, out);
1298        }
1299        Expr::Where { source, condition, .. }
1300        | Expr::With {
1301            source,
1302            predicate: condition,
1303            ..
1304        } => {
1305            collect_accessed_fields_from_expr(source, out);
1306            collect_accessed_fields_from_expr(condition, out);
1307        }
1308        Expr::WhenGuard { action, condition, .. } => {
1309            collect_accessed_fields_from_expr(action, out);
1310            collect_accessed_fields_from_expr(condition, out);
1311        }
1312        Expr::Block { items, .. } => {
1313            for item in items {
1314                collect_accessed_fields_from_expr(item, out);
1315            }
1316        }
1317        Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
1318            collect_accessed_fields_from_expr(value, out);
1319        }
1320        Expr::Conditional { branches, else_body, .. } => {
1321            for b in branches {
1322                collect_accessed_fields_from_expr(&b.condition, out);
1323                collect_accessed_fields_from_expr(&b.body, out);
1324            }
1325            if let Some(body) = else_body {
1326                collect_accessed_fields_from_expr(body, out);
1327            }
1328        }
1329        Expr::For { collection, filter, body, .. } => {
1330            collect_accessed_fields_from_expr(collection, out);
1331            if let Some(f) = filter {
1332                collect_accessed_fields_from_expr(f, out);
1333            }
1334            collect_accessed_fields_from_expr(body, out);
1335        }
1336        Expr::Lambda { body, .. } => {
1337            collect_accessed_fields_from_expr(body, out);
1338        }
1339        Expr::JoinLookup { entity, fields, .. } => {
1340            collect_accessed_fields_from_expr(entity, out);
1341            for f in fields {
1342                out.insert(&f.field.name);
1343                if let Some(v) = &f.value {
1344                    collect_accessed_fields_from_expr(v, out);
1345                }
1346            }
1347        }
1348        Expr::TransitionsTo { subject, new_state, .. }
1349        | Expr::Becomes { subject, new_state, .. } => {
1350            collect_accessed_fields_from_expr(subject, out);
1351            collect_accessed_fields_from_expr(new_state, out);
1352        }
1353        Expr::SetLiteral { elements, .. } => {
1354            for e in elements {
1355                collect_accessed_fields_from_expr(e, out);
1356            }
1357        }
1358        Expr::ObjectLiteral { fields, .. } => {
1359            for f in fields {
1360                collect_accessed_fields_from_expr(&f.value, out);
1361            }
1362        }
1363        Expr::GenericType { name, args, .. } => {
1364            collect_accessed_fields_from_expr(name, out);
1365            for a in args {
1366                collect_accessed_fields_from_expr(a, out);
1367            }
1368        }
1369        Expr::ProjectionMap { source, .. } => {
1370            collect_accessed_fields_from_expr(source, out);
1371        }
1372        _ => {}
1373    }
1374}
1375
1376// ---------------------------------------------------------------------------
1377// 9. Unused entities
1378// ---------------------------------------------------------------------------
1379
1380impl Ctx<'_> {
1381    fn check_unused_entities(&mut self) {
1382        let mut all_idents = self.collect_all_referenced_idents();
1383        // Entities that serve as variant bases are "used"
1384        for v in self.variants() {
1385            let base = expr_as_ident(&v.base).or_else(|| {
1386                if let Expr::JoinLookup { entity, .. } = &v.base {
1387                    expr_as_ident(entity)
1388                } else {
1389                    None
1390                }
1391            });
1392            if let Some(name) = base {
1393                all_idents.insert(name);
1394            }
1395        }
1396        let mut findings = Vec::new();
1397
1398        for d in &self.module.declarations {
1399            let block = match d {
1400                Decl::Block(b)
1401                    if matches!(
1402                        b.kind,
1403                        BlockKind::Entity | BlockKind::ExternalEntity
1404                    ) =>
1405                {
1406                    b
1407                }
1408                _ => continue,
1409            };
1410            let name = match &block.name {
1411                Some(n) => n,
1412                None => continue,
1413            };
1414            if !all_idents.contains(name.name.as_str()) {
1415                findings.push(
1416                    Diagnostic::warning(
1417                        name.span,
1418                        format!(
1419                            "Entity '{}' is declared but not referenced elsewhere in this specification.",
1420                            name.name
1421                        ),
1422                    )
1423                    .with_code("allium.entity.unused"),
1424                );
1425            }
1426        }
1427        self.diagnostics.extend(findings);
1428    }
1429
1430    fn check_unused_definitions(&mut self) {
1431        let all_idents = self.collect_all_referenced_idents();
1432        let mut findings = Vec::new();
1433
1434        for d in &self.module.declarations {
1435            match d {
1436                Decl::Block(b) if b.kind == BlockKind::Value || b.kind == BlockKind::Enum => {
1437                    let name = match &b.name {
1438                        Some(n) => n,
1439                        None => continue,
1440                    };
1441                    if !all_idents.contains(name.name.as_str()) {
1442                        findings.push(
1443                            Diagnostic::warning(
1444                                name.span,
1445                                format!(
1446                                    "Value '{}' is declared but not referenced elsewhere.",
1447                                    name.name
1448                                ),
1449                            )
1450                            .with_code("allium.definition.unused"),
1451                        );
1452                    }
1453                }
1454                _ => {}
1455            }
1456        }
1457        self.diagnostics.extend(findings);
1458    }
1459
1460    /// Collect all capitalised identifiers referenced in expressions across the module,
1461    /// excluding the declaration name positions themselves.
1462    fn collect_all_referenced_idents(&self) -> HashSet<&str> {
1463        let mut names = HashSet::new();
1464        for d in &self.module.declarations {
1465            match d {
1466                Decl::Block(b) => {
1467                    for item in &b.items {
1468                        collect_uppercase_idents_from_item(&item.kind, &mut names);
1469                    }
1470                }
1471                Decl::Variant(v) => {
1472                    // The base type is a reference
1473                    if let Some(name) = expr_as_ident(&v.base) {
1474                        names.insert(name);
1475                    }
1476                    for item in &v.items {
1477                        collect_uppercase_idents_from_item(&item.kind, &mut names);
1478                    }
1479                }
1480                Decl::Invariant(inv) => {
1481                    collect_uppercase_idents_from_expr(&inv.body, &mut names);
1482                }
1483                Decl::Default(def) => {
1484                    if let Some(tn) = &def.type_name {
1485                        names.insert(tn.name.as_str());
1486                    }
1487                    collect_uppercase_idents_from_expr(&def.value, &mut names);
1488                }
1489                _ => {}
1490            }
1491        }
1492        names
1493    }
1494}
1495
1496fn collect_uppercase_idents_from_item<'a>(kind: &'a BlockItemKind, out: &mut HashSet<&'a str>) {
1497    match kind {
1498        BlockItemKind::Clause { value, .. }
1499        | BlockItemKind::Assignment { value, .. }
1500        | BlockItemKind::ParamAssignment { value, .. }
1501        | BlockItemKind::Let { value, .. }
1502        | BlockItemKind::PathAssignment { value, .. }
1503        | BlockItemKind::InvariantBlock { body: value, .. }
1504        | BlockItemKind::FieldWithWhen { value, .. } => {
1505            collect_uppercase_idents_from_expr(value, out);
1506        }
1507        BlockItemKind::ForBlock {
1508            collection,
1509            filter,
1510            items,
1511            ..
1512        } => {
1513            collect_uppercase_idents_from_expr(collection, out);
1514            if let Some(f) = filter {
1515                collect_uppercase_idents_from_expr(f, out);
1516            }
1517            for item in items {
1518                collect_uppercase_idents_from_item(&item.kind, out);
1519            }
1520        }
1521        BlockItemKind::IfBlock {
1522            branches,
1523            else_items,
1524        } => {
1525            for b in branches {
1526                collect_uppercase_idents_from_expr(&b.condition, out);
1527                for item in &b.items {
1528                    collect_uppercase_idents_from_item(&item.kind, out);
1529                }
1530            }
1531            if let Some(items) = else_items {
1532                for item in items {
1533                    collect_uppercase_idents_from_item(&item.kind, out);
1534                }
1535            }
1536        }
1537        BlockItemKind::ContractsClause { entries } => {
1538            for e in entries {
1539                out.insert(e.name.name.as_str());
1540            }
1541        }
1542        _ => {}
1543    }
1544}
1545
1546fn collect_uppercase_idents_from_expr<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1547    match expr {
1548        Expr::Ident(id) if starts_uppercase(&id.name) => {
1549            out.insert(&id.name);
1550        }
1551        Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
1552            collect_uppercase_idents_from_expr(object, out);
1553        }
1554        Expr::Call { function, args, .. } => {
1555            collect_uppercase_idents_from_expr(function, out);
1556            for a in args {
1557                match a {
1558                    CallArg::Positional(e) => collect_uppercase_idents_from_expr(e, out),
1559                    CallArg::Named(n) => collect_uppercase_idents_from_expr(&n.value, out),
1560                }
1561            }
1562        }
1563        Expr::JoinLookup { entity, fields, .. } => {
1564            collect_uppercase_idents_from_expr(entity, out);
1565            for f in fields {
1566                if let Some(v) = &f.value {
1567                    collect_uppercase_idents_from_expr(v, out);
1568                }
1569            }
1570        }
1571        Expr::BinaryOp { left, right, .. }
1572        | Expr::Comparison { left, right, .. }
1573        | Expr::LogicalOp { left, right, .. }
1574        | Expr::Pipe { left, right, .. }
1575        | Expr::NullCoalesce { left, right, .. } => {
1576            collect_uppercase_idents_from_expr(left, out);
1577            collect_uppercase_idents_from_expr(right, out);
1578        }
1579        Expr::Not { operand, .. }
1580        | Expr::Exists { operand, .. }
1581        | Expr::NotExists { operand, .. }
1582        | Expr::TypeOptional { inner: operand, .. } => {
1583            collect_uppercase_idents_from_expr(operand, out);
1584        }
1585        Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
1586            collect_uppercase_idents_from_expr(element, out);
1587            collect_uppercase_idents_from_expr(collection, out);
1588        }
1589        Expr::Where { source, condition, .. }
1590        | Expr::With {
1591            source,
1592            predicate: condition,
1593            ..
1594        } => {
1595            collect_uppercase_idents_from_expr(source, out);
1596            collect_uppercase_idents_from_expr(condition, out);
1597        }
1598        Expr::WhenGuard { action, condition, .. } => {
1599            collect_uppercase_idents_from_expr(action, out);
1600            collect_uppercase_idents_from_expr(condition, out);
1601        }
1602        Expr::Binding { value, .. } | Expr::LetExpr { value, .. } => {
1603            collect_uppercase_idents_from_expr(value, out);
1604        }
1605        Expr::Block { items, .. } => {
1606            for item in items {
1607                collect_uppercase_idents_from_expr(item, out);
1608            }
1609        }
1610        Expr::Conditional { branches, else_body, .. } => {
1611            for b in branches {
1612                collect_uppercase_idents_from_expr(&b.condition, out);
1613                collect_uppercase_idents_from_expr(&b.body, out);
1614            }
1615            if let Some(body) = else_body {
1616                collect_uppercase_idents_from_expr(body, out);
1617            }
1618        }
1619        Expr::For { collection, filter, body, .. } => {
1620            collect_uppercase_idents_from_expr(collection, out);
1621            if let Some(f) = filter {
1622                collect_uppercase_idents_from_expr(f, out);
1623            }
1624            collect_uppercase_idents_from_expr(body, out);
1625        }
1626        Expr::Lambda { body, .. } => {
1627            collect_uppercase_idents_from_expr(body, out);
1628        }
1629        Expr::TransitionsTo { subject, new_state, .. }
1630        | Expr::Becomes { subject, new_state, .. } => {
1631            collect_uppercase_idents_from_expr(subject, out);
1632            collect_uppercase_idents_from_expr(new_state, out);
1633        }
1634        Expr::GenericType { name, args, .. } => {
1635            collect_uppercase_idents_from_expr(name, out);
1636            for a in args {
1637                collect_uppercase_idents_from_expr(a, out);
1638            }
1639        }
1640        Expr::SetLiteral { elements, .. } => {
1641            for e in elements {
1642                collect_uppercase_idents_from_expr(e, out);
1643            }
1644        }
1645        Expr::ObjectLiteral { fields, .. } => {
1646            for f in fields {
1647                collect_uppercase_idents_from_expr(&f.value, out);
1648            }
1649        }
1650        Expr::ProjectionMap { source, .. } => {
1651            collect_uppercase_idents_from_expr(source, out);
1652        }
1653        Expr::QualifiedName(q) => {
1654            out.insert(&q.name);
1655        }
1656        _ => {}
1657    }
1658}
1659
1660// ---------------------------------------------------------------------------
1661// 10. Deferred location hints
1662// ---------------------------------------------------------------------------
1663
1664impl Ctx<'_> {
1665    fn check_deferred_location_hints(&mut self) {
1666        for d in &self.module.declarations {
1667            let Decl::Deferred(def) = d else {
1668                continue;
1669            };
1670            // The TypeScript check looks for a string literal or URL on the deferred line.
1671            // Since the Rust parser only stores the path expression, we emit a warning
1672            // if there's no additional hint (the parser doesn't capture comments/URLs).
1673            self.push(
1674                Diagnostic::warning(
1675                    def.span,
1676                    format!(
1677                        "Deferred specification '{}' should include a location hint.",
1678                        expr_to_dotpath(&def.path),
1679                    ),
1680                )
1681                .with_code("allium.deferred.missingLocationHint"),
1682            );
1683        }
1684    }
1685}
1686
1687fn expr_to_dotpath(expr: &Expr) -> String {
1688    match expr {
1689        Expr::Ident(id) => id.name.clone(),
1690        Expr::MemberAccess { object, field, .. } => {
1691            format!("{}.{}", expr_to_dotpath(object), field.name)
1692        }
1693        _ => "?".to_string(),
1694    }
1695}
1696
1697// ---------------------------------------------------------------------------
1698// 11. Invalid triggers
1699// ---------------------------------------------------------------------------
1700
1701impl Ctx<'_> {
1702    fn check_rule_invalid_triggers(&mut self) {
1703        for rule in self.blocks(BlockKind::Rule) {
1704            let rule_name = match &rule.name {
1705                Some(n) => &n.name,
1706                None => continue,
1707            };
1708
1709            for item in &rule.items {
1710                let BlockItemKind::Clause { keyword, value } = &item.kind else {
1711                    continue;
1712                };
1713                if keyword != "when" {
1714                    continue;
1715                }
1716                if !is_valid_trigger(value) {
1717                    self.push(
1718                        Diagnostic::error(
1719                            item.span,
1720                            format!(
1721                                "Rule '{rule_name}' uses an unsupported trigger form in 'when:'.",
1722                            ),
1723                        )
1724                        .with_code("allium.rule.invalidTrigger"),
1725                    );
1726                }
1727            }
1728        }
1729    }
1730}
1731
1732fn is_valid_trigger(expr: &Expr) -> bool {
1733    match expr {
1734        // EventName(params...) — external stimulus trigger
1735        Expr::Call { function, .. } => {
1736            matches!(function.as_ref(), Expr::Ident(_) | Expr::MemberAccess { .. })
1737        }
1738        // binding: Entity.field becomes/transitions_to/created/comparison
1739        Expr::Binding { value, .. } => {
1740            matches!(
1741                value.as_ref(),
1742                Expr::Becomes { .. }
1743                    | Expr::TransitionsTo { .. }
1744                    | Expr::MemberAccess { .. }
1745                    | Expr::Comparison { .. }
1746            )
1747        }
1748        // a or b — combined triggers
1749        Expr::LogicalOp {
1750            op: LogicalOp::Or,
1751            left,
1752            right,
1753            ..
1754        } => is_valid_trigger(left) && is_valid_trigger(right),
1755        // Temporal: Entity.field <= now, Entity.field comparison ...
1756        Expr::Comparison { left, .. } => {
1757            matches!(left.as_ref(), Expr::MemberAccess { .. })
1758        }
1759        _ => false,
1760    }
1761}
1762
1763// ---------------------------------------------------------------------------
1764// 12. Undefined rule bindings
1765// ---------------------------------------------------------------------------
1766
1767impl Ctx<'_> {
1768    fn check_rule_undefined_bindings(&mut self) {
1769        // Collect context bindings from given blocks
1770        let mut given_bindings: HashSet<&str> = HashSet::new();
1771        for given in self.blocks(BlockKind::Given) {
1772            for item in &given.items {
1773                if let BlockItemKind::Assignment { name, .. } = &item.kind {
1774                    given_bindings.insert(&name.name);
1775                }
1776            }
1777        }
1778
1779        // Collect default instance names
1780        let mut default_names: HashSet<&str> = HashSet::new();
1781        for d in &self.module.declarations {
1782            if let Decl::Default(def) = d {
1783                default_names.insert(&def.name.name);
1784            }
1785        }
1786
1787        for rule in self.blocks(BlockKind::Rule) {
1788            let rule_name = match &rule.name {
1789                Some(n) => &n.name,
1790                None => continue,
1791            };
1792
1793            let mut bound: HashSet<&str> = HashSet::new();
1794            bound.extend(&given_bindings);
1795            bound.extend(&default_names);
1796
1797            // Collect bindings from when clause
1798            for item in &rule.items {
1799                let BlockItemKind::Clause { keyword, value } = &item.kind else {
1800                    continue;
1801                };
1802                if keyword != "when" {
1803                    continue;
1804                }
1805                collect_bound_names(value, &mut bound);
1806            }
1807
1808            // Collect let bindings
1809            for item in &rule.items {
1810                if let BlockItemKind::Let { name, .. } = &item.kind {
1811                    bound.insert(&name.name);
1812                }
1813            }
1814
1815            // Check requires/ensures for unbound references
1816            for item in &rule.items {
1817                let BlockItemKind::Clause { keyword, value } = &item.kind else {
1818                    continue;
1819                };
1820                if keyword != "requires" && keyword != "ensures" {
1821                    continue;
1822                }
1823                check_unbound_roots(value, &bound, rule_name, &mut self.diagnostics);
1824            }
1825
1826            // Check for-block and if-block items
1827            for item in &rule.items {
1828                match &item.kind {
1829                    BlockItemKind::ForBlock {
1830                        binding,
1831                        items,
1832                        ..
1833                    } => {
1834                        let mut inner_bound = bound.clone();
1835                        match binding {
1836                            ForBinding::Single(id) => { inner_bound.insert(&id.name); }
1837                            ForBinding::Destructured(ids, _) => {
1838                                for id in ids {
1839                                    inner_bound.insert(&id.name);
1840                                }
1841                            }
1842                        }
1843                        for sub_item in items {
1844                            if let BlockItemKind::Clause { keyword, value } = &sub_item.kind {
1845                                if keyword == "ensures" || keyword == "requires" {
1846                                    check_unbound_roots(value, &inner_bound, rule_name, &mut self.diagnostics);
1847                                }
1848                            }
1849                        }
1850                    }
1851                    _ => {}
1852                }
1853            }
1854
1855            // Rules with bare entity bindings (e.g. `when: state: ClerkEventState`)
1856            // have an invalid trigger form. The binding name is syntactically present
1857            // but doesn't resolve to a meaningful type. Flag the first usage.
1858            for item in &rule.items {
1859                let BlockItemKind::Clause { keyword, value } = &item.kind else { continue };
1860                if keyword != "when" { continue }
1861                let Expr::Binding { name: binding_name, value: trigger_value, .. } = value else { continue };
1862                if !matches!(trigger_value.as_ref(), Expr::Ident(id) if starts_uppercase(&id.name)) {
1863                    continue;
1864                }
1865                // Find the first requires/ensures clause that references this binding
1866                let mut found = false;
1867                for check_item in &rule.items {
1868                    let BlockItemKind::Clause { keyword: kw, value: v } = &check_item.kind else { continue };
1869                    if kw != "requires" && kw != "ensures" { continue }
1870                    if expr_contains_ident(v, &binding_name.name) {
1871                        self.push(
1872                            Diagnostic::error(
1873                                check_item.span,
1874                                format!(
1875                                    "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
1876                                    binding_name.name
1877                                ),
1878                            )
1879                            .with_code("allium.rule.undefinedBinding"),
1880                        );
1881                        found = true;
1882                        break;
1883                    }
1884                }
1885                if found { break; }
1886            }
1887        }
1888    }
1889}
1890
1891fn collect_bound_names<'a>(expr: &'a Expr, out: &mut HashSet<&'a str>) {
1892    match expr {
1893        Expr::Binding { name, .. } => {
1894            out.insert(&name.name);
1895        }
1896        Expr::Call { args, .. } => {
1897            for arg in args {
1898                if let CallArg::Positional(Expr::Ident(id)) = arg {
1899                    out.insert(&id.name);
1900                }
1901            }
1902        }
1903        Expr::LogicalOp { left, right, .. } => {
1904            collect_bound_names(left, out);
1905            collect_bound_names(right, out);
1906        }
1907        _ => {}
1908    }
1909}
1910
1911fn check_unbound_roots(
1912    expr: &Expr,
1913    bound: &HashSet<&str>,
1914    rule_name: &str,
1915    diagnostics: &mut Vec<Diagnostic>,
1916) {
1917    match expr {
1918        Expr::MemberAccess { object, .. } => {
1919            if let Expr::Ident(id) = object.as_ref() {
1920                if !starts_uppercase(&id.name)
1921                    && !bound.contains(id.name.as_str())
1922                    && !is_builtin_name(&id.name)
1923                {
1924                    diagnostics.push(
1925                        Diagnostic::error(
1926                            id.span,
1927                            format!(
1928                                "Rule '{rule_name}' references '{}' but no matching binding exists in context, trigger params, default instances, or local lets.",
1929                                id.name
1930                            ),
1931                        )
1932                        .with_code("allium.rule.undefinedBinding"),
1933                    );
1934                }
1935            }
1936        }
1937        Expr::Comparison { left, right, .. } => {
1938            check_unbound_roots(left, bound, rule_name, diagnostics);
1939            check_unbound_roots(right, bound, rule_name, diagnostics);
1940        }
1941        Expr::LogicalOp { left, right, .. } => {
1942            check_unbound_roots(left, bound, rule_name, diagnostics);
1943            check_unbound_roots(right, bound, rule_name, diagnostics);
1944        }
1945        Expr::Block { items, .. } => {
1946            let mut block_bound = bound.clone();
1947            for item in items {
1948                if let Expr::LetExpr { name, value, .. } = item {
1949                    check_unbound_roots(value, &block_bound, rule_name, diagnostics);
1950                    block_bound.insert(name.name.as_str());
1951                } else {
1952                    check_unbound_roots(item, &block_bound, rule_name, diagnostics);
1953                }
1954            }
1955        }
1956        Expr::For { binding, collection, body, .. } => {
1957            check_unbound_roots(collection, bound, rule_name, diagnostics);
1958            // Skip filter (where clause) — fields are implicitly scoped to the binding
1959            let mut inner = bound.clone();
1960            match binding {
1961                ForBinding::Single(id) => { inner.insert(id.name.as_str()); }
1962                ForBinding::Destructured(ids, _) => {
1963                    for id in ids {
1964                        inner.insert(id.name.as_str());
1965                    }
1966                }
1967            }
1968            check_unbound_roots(body, &inner, rule_name, diagnostics);
1969        }
1970        Expr::BinaryOp { left, right, .. } => {
1971            check_unbound_roots(left, bound, rule_name, diagnostics);
1972            check_unbound_roots(right, bound, rule_name, diagnostics);
1973        }
1974        Expr::Call { function, args, .. } => {
1975            // Don't descend into function position for member access (Entity.method)
1976            if !matches!(function.as_ref(), Expr::MemberAccess { .. }) {
1977                check_unbound_roots(function, bound, rule_name, diagnostics);
1978            }
1979            // Collect lambda params from any arg — they scope over all args
1980            let mut call_bound = bound.clone();
1981            for a in args {
1982                if let CallArg::Positional(Expr::Lambda { param, .. }) = a {
1983                    if let Expr::Ident(id) = param.as_ref() {
1984                        call_bound.insert(id.name.as_str());
1985                    }
1986                }
1987            }
1988            for a in args {
1989                match a {
1990                    CallArg::Positional(Expr::Lambda { body, .. }) => {
1991                        check_unbound_roots(body, &call_bound, rule_name, diagnostics);
1992                    }
1993                    CallArg::Positional(e) => {
1994                        check_unbound_roots(e, &call_bound, rule_name, diagnostics);
1995                    }
1996                    CallArg::Named(n) => check_unbound_roots(&n.value, &call_bound, rule_name, diagnostics),
1997                }
1998            }
1999        }
2000        Expr::Not { operand, .. }
2001        | Expr::Exists { operand, .. }
2002        | Expr::NotExists { operand, .. } => {
2003            check_unbound_roots(operand, bound, rule_name, diagnostics);
2004        }
2005        Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2006            check_unbound_roots(element, bound, rule_name, diagnostics);
2007            check_unbound_roots(collection, bound, rule_name, diagnostics);
2008        }
2009        Expr::Conditional { branches, else_body, .. } => {
2010            for b in branches {
2011                check_unbound_roots(&b.condition, bound, rule_name, diagnostics);
2012                check_unbound_roots(&b.body, bound, rule_name, diagnostics);
2013            }
2014            if let Some(body) = else_body {
2015                check_unbound_roots(body, bound, rule_name, diagnostics);
2016            }
2017        }
2018        _ => {}
2019    }
2020}
2021
2022fn is_builtin_name(name: &str) -> bool {
2023    matches!(name, "config" | "now" | "this" | "within" | "true" | "false" | "null")
2024}
2025
2026// ---------------------------------------------------------------------------
2027// 13. Duplicate let bindings
2028// ---------------------------------------------------------------------------
2029
2030impl Ctx<'_> {
2031    fn check_duplicate_let_bindings(&mut self) {
2032        for rule in self.blocks(BlockKind::Rule) {
2033            let mut seen: HashMap<&str, Span> = HashMap::new();
2034            self.check_duplicate_lets_in_items(&rule.items, &mut seen);
2035        }
2036    }
2037
2038    fn check_duplicate_lets_in_items<'b>(
2039        &mut self,
2040        items: &'b [BlockItem],
2041        seen: &mut HashMap<&'b str, Span>,
2042    ) {
2043        for item in items {
2044            match &item.kind {
2045                BlockItemKind::Let { name, .. } => {
2046                    if seen.contains_key(name.name.as_str()) {
2047                        self.push(
2048                            Diagnostic::error(
2049                                name.span,
2050                                format!("Duplicate let binding '{}' in this rule.", name.name),
2051                            )
2052                            .with_code("allium.let.duplicateBinding"),
2053                        );
2054                    } else {
2055                        seen.insert(&name.name, name.span);
2056                    }
2057                }
2058                BlockItemKind::ForBlock { items, .. } => {
2059                    self.check_duplicate_lets_in_items(items, seen);
2060                }
2061                BlockItemKind::IfBlock {
2062                    branches,
2063                    else_items,
2064                } => {
2065                    for b in branches {
2066                        self.check_duplicate_lets_in_items(&b.items, seen);
2067                    }
2068                    if let Some(items) = else_items {
2069                        self.check_duplicate_lets_in_items(items, seen);
2070                    }
2071                }
2072                BlockItemKind::Clause { value, .. } => {
2073                    self.check_duplicate_lets_in_expr(value, seen);
2074                }
2075                _ => {}
2076            }
2077        }
2078    }
2079
2080    fn check_duplicate_lets_in_expr<'b>(
2081        &mut self,
2082        expr: &'b Expr,
2083        seen: &mut HashMap<&'b str, Span>,
2084    ) {
2085        match expr {
2086            Expr::LetExpr { name, value, .. } => {
2087                if seen.contains_key(name.name.as_str()) {
2088                    self.push(
2089                        Diagnostic::error(
2090                            name.span,
2091                            format!("Duplicate let binding '{}' in this rule.", name.name),
2092                        )
2093                        .with_code("allium.let.duplicateBinding"),
2094                    );
2095                } else {
2096                    seen.insert(&name.name, name.span);
2097                }
2098                self.check_duplicate_lets_in_expr(value, seen);
2099            }
2100            Expr::Block { items, .. } => {
2101                for item in items {
2102                    self.check_duplicate_lets_in_expr(item, seen);
2103                }
2104            }
2105            Expr::For { body, .. } => {
2106                self.check_duplicate_lets_in_expr(body, seen);
2107            }
2108            Expr::Conditional { branches, else_body, .. } => {
2109                for b in branches {
2110                    self.check_duplicate_lets_in_expr(&b.body, seen);
2111                }
2112                if let Some(body) = else_body {
2113                    self.check_duplicate_lets_in_expr(body, seen);
2114                }
2115            }
2116            _ => {}
2117        }
2118    }
2119}
2120
2121// ---------------------------------------------------------------------------
2122// 14. Config undefined references
2123// ---------------------------------------------------------------------------
2124
2125impl Ctx<'_> {
2126    fn check_config_undefined_references(&mut self) {
2127        let mut config_params: HashSet<&str> = HashSet::new();
2128        for config in self.blocks(BlockKind::Config) {
2129            for item in &config.items {
2130                if let BlockItemKind::Assignment { name, .. } = &item.kind {
2131                    config_params.insert(&name.name);
2132                }
2133            }
2134        }
2135
2136        // Walk all expressions looking for config.field references
2137        for d in &self.module.declarations {
2138            match d {
2139                Decl::Block(b) => {
2140                    if b.kind == BlockKind::Config {
2141                        continue;
2142                    }
2143                    for item in &b.items {
2144                        self.check_config_refs_in_item(&item.kind, &config_params);
2145                    }
2146                }
2147                Decl::Invariant(inv) => {
2148                    self.check_config_refs_in_expr(&inv.body, &config_params);
2149                }
2150                _ => {}
2151            }
2152        }
2153    }
2154
2155    fn check_config_refs_in_item(&mut self, kind: &BlockItemKind, params: &HashSet<&str>) {
2156        match kind {
2157            BlockItemKind::Clause { value, .. }
2158            | BlockItemKind::Assignment { value, .. }
2159            | BlockItemKind::ParamAssignment { value, .. }
2160            | BlockItemKind::Let { value, .. }
2161            | BlockItemKind::FieldWithWhen { value, .. } => {
2162                self.check_config_refs_in_expr(value, params);
2163            }
2164            BlockItemKind::ForBlock { collection, filter, items, .. } => {
2165                self.check_config_refs_in_expr(collection, params);
2166                if let Some(f) = filter {
2167                    self.check_config_refs_in_expr(f, params);
2168                }
2169                for item in items {
2170                    self.check_config_refs_in_item(&item.kind, params);
2171                }
2172            }
2173            BlockItemKind::IfBlock { branches, else_items } => {
2174                for b in branches {
2175                    self.check_config_refs_in_expr(&b.condition, params);
2176                    for item in &b.items {
2177                        self.check_config_refs_in_item(&item.kind, params);
2178                    }
2179                }
2180                if let Some(items) = else_items {
2181                    for item in items {
2182                        self.check_config_refs_in_item(&item.kind, params);
2183                    }
2184                }
2185            }
2186            _ => {}
2187        }
2188    }
2189
2190    fn check_config_refs_in_expr(&mut self, expr: &Expr, params: &HashSet<&str>) {
2191        match expr {
2192            Expr::MemberAccess { object, field, .. } => {
2193                if let Expr::Ident(id) = object.as_ref() {
2194                    if id.name == "config" && !params.contains(field.name.as_str()) {
2195                        self.push(
2196                            Diagnostic::warning(
2197                                field.span,
2198                                format!(
2199                                    "Config reference 'config.{}' is not declared in any config block.",
2200                                    field.name
2201                                ),
2202                            )
2203                            .with_code("allium.config.undefinedReference"),
2204                        );
2205                        return;
2206                    }
2207                }
2208                self.check_config_refs_in_expr(object, params);
2209            }
2210            Expr::Call { function, args, .. } => {
2211                self.check_config_refs_in_expr(function, params);
2212                for a in args {
2213                    match a {
2214                        CallArg::Positional(e) => self.check_config_refs_in_expr(e, params),
2215                        CallArg::Named(n) => self.check_config_refs_in_expr(&n.value, params),
2216                    }
2217                }
2218            }
2219            Expr::BinaryOp { left, right, .. }
2220            | Expr::Comparison { left, right, .. }
2221            | Expr::LogicalOp { left, right, .. }
2222            | Expr::Pipe { left, right, .. }
2223            | Expr::NullCoalesce { left, right, .. } => {
2224                self.check_config_refs_in_expr(left, params);
2225                self.check_config_refs_in_expr(right, params);
2226            }
2227            Expr::Not { operand, .. }
2228            | Expr::Exists { operand, .. }
2229            | Expr::NotExists { operand, .. } => {
2230                self.check_config_refs_in_expr(operand, params);
2231            }
2232            Expr::Block { items, .. } => {
2233                for item in items {
2234                    self.check_config_refs_in_expr(item, params);
2235                }
2236            }
2237            Expr::Conditional { branches, else_body, .. } => {
2238                for b in branches {
2239                    self.check_config_refs_in_expr(&b.condition, params);
2240                    self.check_config_refs_in_expr(&b.body, params);
2241                }
2242                if let Some(body) = else_body {
2243                    self.check_config_refs_in_expr(body, params);
2244                }
2245            }
2246            Expr::For { collection, filter, body, .. } => {
2247                self.check_config_refs_in_expr(collection, params);
2248                if let Some(f) = filter {
2249                    self.check_config_refs_in_expr(f, params);
2250                }
2251                self.check_config_refs_in_expr(body, params);
2252            }
2253            Expr::LetExpr { value, .. } => {
2254                self.check_config_refs_in_expr(value, params);
2255            }
2256            Expr::Lambda { body, .. } => {
2257                self.check_config_refs_in_expr(body, params);
2258            }
2259            _ => {}
2260        }
2261    }
2262}
2263
2264// ---------------------------------------------------------------------------
2265// 15. Surface unused paths
2266// ---------------------------------------------------------------------------
2267
2268impl Ctx<'_> {
2269    fn check_surface_unused_paths(&mut self) {
2270        // Collect all field access paths from rules
2271        let mut rule_paths: HashSet<String> = HashSet::new();
2272        for rule in self.blocks(BlockKind::Rule) {
2273            for item in &rule.items {
2274                collect_dotpaths_from_item(&item.kind, &mut rule_paths);
2275            }
2276        }
2277
2278        for surface in self.blocks(BlockKind::Surface) {
2279            let surface_name = match &surface.name {
2280                Some(n) => &n.name,
2281                None => continue,
2282            };
2283
2284            // Collect binding names
2285            let mut binding_names: HashSet<&str> = HashSet::new();
2286            for item in &surface.items {
2287                let BlockItemKind::Clause { keyword, value } = &item.kind else {
2288                    continue;
2289                };
2290                if keyword == "facing" || keyword == "context" {
2291                    if let Expr::Binding { name, .. } = value {
2292                        binding_names.insert(&name.name);
2293                    }
2294                }
2295            }
2296
2297            for item in &surface.items {
2298                let BlockItemKind::Clause { keyword, value } = &item.kind else {
2299                    continue;
2300                };
2301                if keyword != "exposes" {
2302                    continue;
2303                }
2304                check_unused_surface_paths(
2305                    value,
2306                    &binding_names,
2307                    &rule_paths,
2308                    surface_name,
2309                    &mut self.diagnostics,
2310                );
2311            }
2312        }
2313    }
2314}
2315
2316fn check_unused_surface_paths(
2317    expr: &Expr,
2318    binding_names: &HashSet<&str>,
2319    rule_paths: &HashSet<String>,
2320    surface_name: &str,
2321    diagnostics: &mut Vec<Diagnostic>,
2322) {
2323    match expr {
2324        Expr::MemberAccess { object, field, .. } => {
2325            // Check if root is a binding
2326            if let Expr::Ident(root) = object.as_ref() {
2327                if binding_names.contains(root.name.as_str()) {
2328                    let path = format!("{}.{}", root.name, field.name);
2329                    // Check if any rule references this field
2330                    let field_used = rule_paths.iter().any(|p| p.ends_with(&format!(".{}", field.name)));
2331                    if !field_used {
2332                        diagnostics.push(
2333                            Diagnostic::info(
2334                                expr.span(),
2335                                format!(
2336                                    "Surface '{surface_name}' path '{path}' is not observed in rule field references.",
2337                                ),
2338                            )
2339                            .with_code("allium.surface.unusedPath"),
2340                        );
2341                    }
2342                }
2343            }
2344        }
2345        Expr::Block { items, .. } => {
2346            for item in items {
2347                check_unused_surface_paths(item, binding_names, rule_paths, surface_name, diagnostics);
2348            }
2349        }
2350        _ => {}
2351    }
2352}
2353
2354fn collect_dotpaths_from_item(kind: &BlockItemKind, out: &mut HashSet<String>) {
2355    match kind {
2356        BlockItemKind::Clause { value, .. }
2357        | BlockItemKind::Assignment { value, .. }
2358        | BlockItemKind::Let { value, .. }
2359        | BlockItemKind::FieldWithWhen { value, .. } => {
2360            collect_dotpaths_from_expr(value, out);
2361        }
2362        BlockItemKind::ForBlock {
2363            collection,
2364            filter,
2365            items,
2366            ..
2367        } => {
2368            collect_dotpaths_from_expr(collection, out);
2369            if let Some(f) = filter {
2370                collect_dotpaths_from_expr(f, out);
2371            }
2372            for item in items {
2373                collect_dotpaths_from_item(&item.kind, out);
2374            }
2375        }
2376        BlockItemKind::IfBlock {
2377            branches,
2378            else_items,
2379        } => {
2380            for b in branches {
2381                collect_dotpaths_from_expr(&b.condition, out);
2382                for item in &b.items {
2383                    collect_dotpaths_from_item(&item.kind, out);
2384                }
2385            }
2386            if let Some(items) = else_items {
2387                for item in items {
2388                    collect_dotpaths_from_item(&item.kind, out);
2389                }
2390            }
2391        }
2392        _ => {}
2393    }
2394}
2395
2396fn collect_dotpaths_from_expr(expr: &Expr, out: &mut HashSet<String>) {
2397    match expr {
2398        Expr::MemberAccess { object, field, .. } => {
2399            out.insert(format!("{}.{}", expr_root_name(object).unwrap_or("?"), field.name));
2400            collect_dotpaths_from_expr(object, out);
2401        }
2402        Expr::Comparison { left, right, .. } => {
2403            collect_dotpaths_from_expr(left, out);
2404            collect_dotpaths_from_expr(right, out);
2405        }
2406        Expr::LogicalOp { left, right, .. } => {
2407            collect_dotpaths_from_expr(left, out);
2408            collect_dotpaths_from_expr(right, out);
2409        }
2410        Expr::Block { items, .. } => {
2411            for item in items {
2412                collect_dotpaths_from_expr(item, out);
2413            }
2414        }
2415        Expr::Call { function, args, .. } => {
2416            collect_dotpaths_from_expr(function, out);
2417            for a in args {
2418                match a {
2419                    CallArg::Positional(e) => collect_dotpaths_from_expr(e, out),
2420                    CallArg::Named(n) => {
2421                        // Named args in Entity.created(field: value) reference the field
2422                        out.insert(format!("_.{}", n.name.name));
2423                        collect_dotpaths_from_expr(&n.value, out);
2424                    }
2425                }
2426            }
2427        }
2428        Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2429            collect_dotpaths_from_expr(element, out);
2430            collect_dotpaths_from_expr(collection, out);
2431        }
2432        Expr::Conditional { branches, else_body, .. } => {
2433            for b in branches {
2434                collect_dotpaths_from_expr(&b.condition, out);
2435                collect_dotpaths_from_expr(&b.body, out);
2436            }
2437            if let Some(body) = else_body {
2438                collect_dotpaths_from_expr(body, out);
2439            }
2440        }
2441        _ => {}
2442    }
2443}
2444
2445fn expr_root_name(expr: &Expr) -> Option<&str> {
2446    match expr {
2447        Expr::Ident(id) => Some(&id.name),
2448        Expr::MemberAccess { object, .. } => expr_root_name(object),
2449        _ => None,
2450    }
2451}
2452
2453// ---------------------------------------------------------------------------
2454// Shared helpers: AST walking
2455// ---------------------------------------------------------------------------
2456
2457fn item_contains_ident(kind: &BlockItemKind, name: &str) -> bool {
2458    match kind {
2459        BlockItemKind::Clause { value, .. } => expr_contains_ident(value, name),
2460        BlockItemKind::Assignment { value, .. } => expr_contains_ident(value, name),
2461        BlockItemKind::ParamAssignment { value, .. } => expr_contains_ident(value, name),
2462        BlockItemKind::Let { value, .. } => expr_contains_ident(value, name),
2463        BlockItemKind::ForBlock {
2464            collection,
2465            filter,
2466            items,
2467            ..
2468        } => {
2469            expr_contains_ident(collection, name)
2470                || filter.as_ref().is_some_and(|f| expr_contains_ident(f, name))
2471                || items.iter().any(|i| item_contains_ident(&i.kind, name))
2472        }
2473        BlockItemKind::IfBlock {
2474            branches,
2475            else_items,
2476        } => {
2477            branches.iter().any(|b| {
2478                expr_contains_ident(&b.condition, name)
2479                    || b.items.iter().any(|i| item_contains_ident(&i.kind, name))
2480            }) || else_items
2481                .as_ref()
2482                .is_some_and(|items| items.iter().any(|i| item_contains_ident(&i.kind, name)))
2483        }
2484        BlockItemKind::PathAssignment { path, value } => {
2485            expr_contains_ident(path, name) || expr_contains_ident(value, name)
2486        }
2487        BlockItemKind::InvariantBlock { body, .. } => expr_contains_ident(body, name),
2488        BlockItemKind::FieldWithWhen { value, .. } => expr_contains_ident(value, name),
2489        BlockItemKind::ContractsClause { .. }
2490        | BlockItemKind::EnumVariant { .. }
2491        | BlockItemKind::OpenQuestion { .. }
2492        | BlockItemKind::Annotation(_)
2493        | BlockItemKind::TransitionsBlock(_) => false,
2494    }
2495}
2496
2497fn expr_contains_ident(expr: &Expr, name: &str) -> bool {
2498    match expr {
2499        Expr::Ident(id) => id.name == name,
2500        Expr::MemberAccess { object, .. } | Expr::OptionalAccess { object, .. } => {
2501            expr_contains_ident(object, name)
2502        }
2503        Expr::Call { function, args, .. } => {
2504            expr_contains_ident(function, name)
2505                || args.iter().any(|a| match a {
2506                    CallArg::Positional(e) => expr_contains_ident(e, name),
2507                    CallArg::Named(n) => expr_contains_ident(&n.value, name),
2508                })
2509        }
2510        Expr::JoinLookup { entity, fields, .. } => {
2511            expr_contains_ident(entity, name)
2512                || fields
2513                    .iter()
2514                    .any(|f| f.value.as_ref().is_some_and(|v| expr_contains_ident(v, name)))
2515        }
2516        Expr::BinaryOp { left, right, .. }
2517        | Expr::Comparison { left, right, .. }
2518        | Expr::LogicalOp { left, right, .. }
2519        | Expr::Pipe { left, right, .. }
2520        | Expr::NullCoalesce { left, right, .. } => {
2521            expr_contains_ident(left, name) || expr_contains_ident(right, name)
2522        }
2523        Expr::Not { operand, .. }
2524        | Expr::Exists { operand, .. }
2525        | Expr::NotExists { operand, .. }
2526        | Expr::TypeOptional { inner: operand, .. } => expr_contains_ident(operand, name),
2527        Expr::In { element, collection, .. } | Expr::NotIn { element, collection, .. } => {
2528            expr_contains_ident(element, name) || expr_contains_ident(collection, name)
2529        }
2530        Expr::Where {
2531            source, condition, ..
2532        }
2533        | Expr::With {
2534            source,
2535            predicate: condition,
2536            ..
2537        } => expr_contains_ident(source, name) || expr_contains_ident(condition, name),
2538        Expr::WhenGuard {
2539            action, condition, ..
2540        } => expr_contains_ident(action, name) || expr_contains_ident(condition, name),
2541        Expr::Lambda { param, body, .. } => {
2542            expr_contains_ident(param, name) || expr_contains_ident(body, name)
2543        }
2544        Expr::Binding { name: n, value, .. } => {
2545            n.name == name || expr_contains_ident(value, name)
2546        }
2547        Expr::SetLiteral { elements, .. } => {
2548            elements.iter().any(|e| expr_contains_ident(e, name))
2549        }
2550        Expr::ObjectLiteral { fields, .. } => {
2551            fields.iter().any(|f| expr_contains_ident(&f.value, name))
2552        }
2553        Expr::GenericType { name: n, args, .. } => {
2554            expr_contains_ident(n, name) || args.iter().any(|a| expr_contains_ident(a, name))
2555        }
2556        Expr::Conditional {
2557            branches,
2558            else_body,
2559            ..
2560        } => {
2561            branches.iter().any(|b| {
2562                expr_contains_ident(&b.condition, name) || expr_contains_ident(&b.body, name)
2563            }) || else_body
2564                .as_ref()
2565                .is_some_and(|e| expr_contains_ident(e, name))
2566        }
2567        Expr::For {
2568            collection,
2569            filter,
2570            body,
2571            ..
2572        } => {
2573            expr_contains_ident(collection, name)
2574                || filter
2575                    .as_ref()
2576                    .is_some_and(|f| expr_contains_ident(f, name))
2577                || expr_contains_ident(body, name)
2578        }
2579        Expr::TransitionsTo {
2580            subject, new_state, ..
2581        }
2582        | Expr::Becomes {
2583            subject, new_state, ..
2584        } => expr_contains_ident(subject, name) || expr_contains_ident(new_state, name),
2585        Expr::ProjectionMap { source, .. } => expr_contains_ident(source, name),
2586        Expr::LetExpr { value, .. } => expr_contains_ident(value, name),
2587        Expr::Block { items, .. } => items.iter().any(|e| expr_contains_ident(e, name)),
2588        Expr::QualifiedName(_)
2589        | Expr::StringLiteral(_)
2590        | Expr::BacktickLiteral { .. }
2591        | Expr::NumberLiteral { .. }
2592        | Expr::BoolLiteral { .. }
2593        | Expr::Null { .. }
2594        | Expr::Now { .. }
2595        | Expr::This { .. }
2596        | Expr::Within { .. }
2597        | Expr::DurationLiteral { .. } => false,
2598    }
2599}
2600
2601// ---------------------------------------------------------------------------
2602// Tests
2603// ---------------------------------------------------------------------------
2604
2605#[cfg(test)]
2606mod tests {
2607    use super::*;
2608    use crate::diagnostic::Severity;
2609    use crate::parser::parse;
2610
2611    fn analyze_src(src: &str) -> Vec<Diagnostic> {
2612        let input = if src.starts_with("-- allium:") {
2613            src.to_string()
2614        } else {
2615            format!("-- allium: 3\n{src}")
2616        };
2617        let result = parse(&input);
2618        analyze(&result.module, &input)
2619    }
2620
2621    fn has_code(diagnostics: &[Diagnostic], code: &str) -> bool {
2622        diagnostics.iter().any(|d| d.code == Some(code))
2623    }
2624
2625    fn count_code(diagnostics: &[Diagnostic], code: &str) -> usize {
2626        diagnostics.iter().filter(|d| d.code == Some(code)).count()
2627    }
2628
2629    // -- Suppression --
2630
2631    #[test]
2632    fn suppression_on_previous_line() {
2633        let ds = analyze_src("entity A {\n  -- allium-ignore allium.field.unused\n  x: String\n}\n");
2634        assert!(!has_code(&ds, "allium.field.unused"));
2635    }
2636
2637    #[test]
2638    fn suppression_all() {
2639        let ds = analyze_src("entity A {\n  -- allium-ignore all\n  x: String\n}\n");
2640        assert!(!has_code(&ds, "allium.field.unused"));
2641    }
2642
2643    // -- Related surface references --
2644
2645    #[test]
2646    fn related_clause_with_binding_and_guard() {
2647        let ds = analyze_src(
2648            "surface QuoteVersions {\n  facing user: User\n}\n\n\
2649             surface Dashboard {\n  facing user: User\n  related:\n    QuoteVersions(quote) when quote.version_count > 1\n}\n",
2650        );
2651        assert!(!has_code(&ds, "allium.surface.relatedUndefined"));
2652    }
2653
2654    #[test]
2655    fn related_clause_reports_unknown_surface() {
2656        let ds = analyze_src(
2657            "surface Dashboard {\n  facing user: User\n  related:\n    MissingSurface\n}\n",
2658        );
2659        assert!(has_code(&ds, "allium.surface.relatedUndefined"));
2660    }
2661
2662    // -- Discriminator --
2663
2664    #[test]
2665    fn v1_capitalised_inline_enum() {
2666        let ds = analyze_src("entity Quote {\n  status: Quoted | OrderSubmitted | Filled\n}\n");
2667        assert!(has_code(&ds, "allium.sum.v1InlineEnum"));
2668    }
2669
2670    // -- Unused bindings --
2671
2672    #[test]
2673    fn discard_binding_no_warning() {
2674        let ds = analyze_src(
2675            "surface QuoteFeed {\n  facing _: Service\n  exposes:\n    System.status\n}\n",
2676        );
2677        assert!(!has_code(&ds, "allium.surface.unusedBinding"));
2678    }
2679
2680    // -- Status state machine --
2681
2682    #[test]
2683    fn variable_status_assignment_suppresses_unreachable() {
2684        let ds = analyze_src(
2685            "entity Quote {\n  status: pending | quoted | filled\n}\n\n\
2686             rule ApplyStatusUpdate {\n  when: update: Quote.status becomes pending\n  \
2687             ensures: update.status = new_status\n}\n",
2688        );
2689        assert!(!has_code(&ds, "allium.status.unreachableValue"));
2690        assert!(!has_code(&ds, "allium.status.noExit"));
2691    }
2692
2693    // -- External entity --
2694
2695    #[test]
2696    fn external_entity_referenced_in_rules_info() {
2697        let ds = analyze_src(
2698            "external entity Client {\n  id: String\n}\n\n\
2699             rule IngestQuote {\n  when: RawQuoteReceived(data)\n  ensures:\n    Client.lookup(data.client_id)\n}\n",
2700        );
2701        let hint = ds.iter().find(|d| d.code == Some("allium.externalEntity.missingSourceHint"));
2702        assert!(hint.is_some());
2703        assert_eq!(hint.unwrap().severity, Severity::Info);
2704    }
2705
2706    // -- Type references --
2707
2708    #[test]
2709    fn undefined_type_reference() {
2710        let ds = analyze_src("entity Foo {\n  bar: MissingType\n}\n");
2711        assert!(has_code(&ds, "allium.type.undefinedReference"));
2712    }
2713
2714    #[test]
2715    fn known_type_reference_ok() {
2716        let ds = analyze_src("entity Foo {\n  bar: String\n}\n");
2717        assert!(!has_code(&ds, "allium.type.undefinedReference"));
2718    }
2719
2720    // -- Unreachable triggers --
2721
2722    #[test]
2723    fn unreachable_trigger_reported() {
2724        let ds = analyze_src(
2725            "rule A {\n  when: ExternalEvent(x)\n  ensures: Done()\n}\n",
2726        );
2727        assert!(has_code(&ds, "allium.rule.unreachableTrigger"));
2728    }
2729
2730    // -- Unused fields --
2731
2732    #[test]
2733    fn unused_field_reported() {
2734        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");
2735        assert!(has_code(&ds, "allium.field.unused"));
2736        // y is unused, x is used
2737        let unused: Vec<_> = ds.iter().filter(|d| d.code == Some("allium.field.unused")).collect();
2738        assert!(unused.iter().any(|d| d.message.contains("A.y")));
2739        assert!(!unused.iter().any(|d| d.message.contains("A.x")));
2740    }
2741
2742    // -- Unused entities --
2743
2744    #[test]
2745    fn unused_entity_reported() {
2746        let ds = analyze_src("entity Orphan {\n  x: String\n}\n");
2747        assert!(has_code(&ds, "allium.entity.unused"));
2748    }
2749
2750    // -- Deferred location hints --
2751
2752    #[test]
2753    fn deferred_missing_location_hint() {
2754        let ds = analyze_src("deferred Foo.bar\n");
2755        assert!(has_code(&ds, "allium.deferred.missingLocationHint"));
2756    }
2757
2758    // -- Invalid triggers --
2759
2760    #[test]
2761    fn valid_trigger_ok() {
2762        let ds = analyze_src("rule A {\n  when: Ping(x)\n  ensures: Done()\n}\n");
2763        assert!(!has_code(&ds, "allium.rule.invalidTrigger"));
2764    }
2765
2766    // -- Duplicate let --
2767
2768    #[test]
2769    fn duplicate_let_binding() {
2770        let ds = analyze_src(
2771            "rule A {\n  when: Ping(x)\n  let a = 1\n  let a = 2\n  ensures: Done()\n}\n",
2772        );
2773        assert!(has_code(&ds, "allium.let.duplicateBinding"));
2774    }
2775
2776    // -- Config references --
2777
2778    #[test]
2779    fn config_undefined_reference() {
2780        let ds = analyze_src(
2781            "config {\n  max_retries: 3\n}\n\nrule A {\n  when: Ping(x)\n  requires: config.missing_param > 0\n  ensures: Done()\n}\n",
2782        );
2783        assert!(has_code(&ds, "allium.config.undefinedReference"));
2784    }
2785
2786    #[test]
2787    fn config_valid_reference_ok() {
2788        let ds = analyze_src(
2789            "config {\n  max_retries: 3\n}\n\nrule A {\n  when: Ping(x)\n  requires: config.max_retries > 0\n  ensures: Done()\n}\n",
2790        );
2791        assert!(!has_code(&ds, "allium.config.undefinedReference"));
2792    }
2793}