Skip to main content

zen_engine/workspace/graph/
analysis.rs

1use std::collections::VecDeque;
2use std::rc::Rc;
3use std::sync::Arc;
4
5use ahash::{HashMap, HashMapExt, HashSet};
6use zen_expression::variable::VariableType;
7use zen_types::decision::{
8    DecisionNode, DecisionNodeContent, DecisionNodeKind, DecisionTableContent,
9    DecisionTableHitPolicy, DecisionTableOutputField, ExpressionNodeContent, FunctionNodeContent,
10    SwitchNodeContent, SwitchStatementHitPolicy, TransformAttributes, TransformExecutionMode,
11};
12
13use zen_expression::intellisense::ArmTest;
14
15use crate::model::GraphContent;
16use crate::policy::blocks::{
17    DecisionTableIr, DeclaredType, DictionaryCandidate, IntelliSenseSource, ReadFlattener,
18};
19use crate::policy::linter::{AstOps, RedundantParentheses};
20use crate::policy::queries::scope::VariableTypeScope;
21use crate::workspace::db::Db;
22use crate::workspace::graph::function::FunctionTypeOutcome;
23use crate::workspace::types::{
24    CursorTarget, Diagnostic, DiagnosticCode, DiagnosticLocation, ExpressionKind, Severity,
25};
26
27const NODES_KEY: &str = "$nodes";
28
29#[derive(Debug, Clone)]
30pub struct GraphSignature {
31    pub input: VariableType,
32    pub output: VariableType,
33}
34
35#[derive(Debug, Clone)]
36pub struct GraphNodeAnalysis {
37    pub input: VariableType,
38    pub handler_input: VariableType,
39    pub output: VariableType,
40    pub dollar: Option<VariableType>,
41    pub nodes_scope: VariableType,
42    pub branch_outputs: HashMap<Arc<str>, VariableType>,
43    pub opaque: bool,
44    pub unchecked: bool,
45    pub open: bool,
46}
47
48#[derive(Debug)]
49pub struct GraphAnalysis {
50    pub diagnostics: Vec<Diagnostic>,
51    pub signature: GraphSignature,
52    pub nodes: HashMap<Arc<str>, GraphNodeAnalysis>,
53    pub inferred_inputs: Vec<Arc<str>>,
54}
55
56pub(crate) enum SignatureResolution {
57    Found(GraphSignature),
58    Recursive,
59    Missing,
60}
61
62pub(crate) struct GraphExpressionSite {
63    pub(crate) target: CursorTarget,
64    pub(crate) expression_id: Option<Arc<str>>,
65    pub(crate) source: Arc<str>,
66    pub(crate) kind: ExpressionKind,
67}
68
69pub(crate) struct GraphAnalyzer<'a> {
70    db: &'a Db,
71    path: Arc<str>,
72    content: &'a GraphContent,
73    diagnostics: Vec<Diagnostic>,
74    validate: bool,
75    nodes_scope: VariableType,
76    dictionary_types: HashMap<Arc<str>, VariableType>,
77}
78
79type IncomingEdges = Vec<Vec<(usize, Option<Arc<str>>)>>;
80
81struct GraphTopology {
82    node_index: HashMap<Arc<str>, usize>,
83    incoming: IncomingEdges,
84    outgoing: Vec<Vec<usize>>,
85    order: Option<Vec<usize>>,
86}
87
88impl<'a> GraphAnalyzer<'a> {
89    pub(crate) fn new(db: &'a Db, path: Arc<str>, content: &'a GraphContent) -> Self {
90        let dictionary_types = db.graph_dictionary_types(&content.imports);
91        Self {
92            db,
93            path,
94            content,
95            diagnostics: Vec::new(),
96            validate: false,
97            nodes_scope: VariableType::Any,
98            dictionary_types,
99        }
100    }
101
102    pub(crate) fn analyze(mut self) -> GraphAnalysis {
103        self.check_imports();
104        let topology = self.build_topology();
105        let graph_input = self.graph_input_type();
106        let mut nodes: HashMap<Arc<str>, GraphNodeAnalysis> = HashMap::new();
107
108        if let Some(order) = &topology.order {
109            let descendants = Self::descendant_sets(&topology);
110            let mut ancestors: HashMap<usize, HashSet<usize>> = HashMap::new();
111            for &idx in order {
112                let mut ancestor_set: HashSet<usize> = HashSet::default();
113                for (pred, _) in &topology.incoming[idx] {
114                    ancestor_set.insert(*pred);
115                    if let Some(pred_ancestors) = ancestors.get(pred) {
116                        ancestor_set.extend(pred_ancestors.iter().copied());
117                    }
118                }
119                let node = &self.content.nodes[idx];
120                let (input, unchecked, open) =
121                    Self::merged_input(self.content, &topology, &nodes, idx);
122                self.nodes_scope = Self::nodes_scope_of(
123                    self.content,
124                    idx,
125                    &ancestor_set,
126                    &descendants[idx],
127                    &nodes,
128                );
129                let analysis = self.analyze_node(node, input, unchecked, open, &graph_input);
130                nodes.insert(node.id.clone(), analysis);
131                ancestors.insert(idx, ancestor_set);
132            }
133        }
134
135        let output = Self::terminal_output(self.content, &topology, &nodes);
136        let inferred_inputs = self.inferred_inputs(&topology, &nodes, &graph_input);
137        self.lint_output_any(&topology, &nodes, &graph_input);
138        self.lint_unreachable(&topology);
139        self.lint_expressions();
140        self.sort_diagnostics(&topology);
141
142        GraphAnalysis {
143            diagnostics: self.diagnostics,
144            signature: GraphSignature {
145                input: graph_input,
146                output,
147            },
148            nodes,
149            inferred_inputs,
150        }
151    }
152
153    fn check_imports(&mut self) {
154        let snap = self.db.snapshot();
155        let mut seen: HashSet<&str> = HashSet::default();
156        for import in &self.content.imports {
157            if !seen.insert(import.as_ref()) {
158                continue;
159            }
160            let message = if snap.all_parsed.contains_key(import) {
161                continue;
162            } else if snap.graphs.contains_key(import) {
163                format!("imported document '{import}' is a graph; only policies can be imported")
164            } else {
165                format!("imported policy '{import}' not found in workspace")
166            };
167            self.diagnostics.push(Diagnostic::error(
168                DiagnosticCode::ImportNotFound,
169                DiagnosticLocation::policy(self.path.clone()),
170                message,
171            ));
172        }
173    }
174
175    fn build_topology(&mut self) -> GraphTopology {
176        let content = self.content;
177        let mut node_index: HashMap<Arc<str>, usize> = HashMap::with_capacity(content.nodes.len());
178        for (idx, node) in content.nodes.iter().enumerate() {
179            if node_index.insert(node.id.clone(), idx).is_some() {
180                self.diagnostics.push(Diagnostic::error(
181                    DiagnosticCode::InvalidGraphStructure,
182                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
183                    format!("duplicate node id '{}'", node.id),
184                ));
185            }
186        }
187
188        let mut incoming: IncomingEdges = vec![Vec::new(); content.nodes.len()];
189        let mut outgoing: Vec<Vec<usize>> = vec![Vec::new(); content.nodes.len()];
190        for edge in &content.edges {
191            let (Some(&source), Some(&target)) = (
192                node_index.get(&edge.source_id),
193                node_index.get(&edge.target_id),
194            ) else {
195                let missing = if node_index.contains_key(&edge.source_id) {
196                    &edge.target_id
197                } else {
198                    &edge.source_id
199                };
200                self.diagnostics.push(Diagnostic::error(
201                    DiagnosticCode::InvalidGraphStructure,
202                    DiagnosticLocation::policy(self.path.clone()),
203                    format!("edge '{}' references unknown node '{}'", edge.id, missing),
204                ));
205                continue;
206            };
207            outgoing[source].push(target);
208            incoming[target].push((source, edge.source_handle.clone()));
209        }
210
211        let input_count = content
212            .nodes
213            .iter()
214            .filter(|n| matches!(n.kind, DecisionNodeKind::InputNode { .. }))
215            .count();
216        if input_count != 1 {
217            self.diagnostics.push(Diagnostic::error(
218                DiagnosticCode::InvalidGraphStructure,
219                DiagnosticLocation::policy(self.path.clone()),
220                format!("graph must have exactly one input node, found {input_count}"),
221            ));
222        }
223
224        let order = Self::topological_order(&incoming, &outgoing);
225        if order.is_none() {
226            self.diagnostics.push(Diagnostic::error(
227                DiagnosticCode::CyclicDependency,
228                DiagnosticLocation::policy(self.path.clone()),
229                "graph contains a cycle",
230            ));
231        }
232
233        GraphTopology {
234            node_index,
235            incoming,
236            outgoing,
237            order,
238        }
239    }
240
241    fn topological_order(incoming: &IncomingEdges, outgoing: &[Vec<usize>]) -> Option<Vec<usize>> {
242        let mut indegree: Vec<usize> = incoming.iter().map(Vec::len).collect();
243        let mut queue: VecDeque<usize> = indegree
244            .iter()
245            .enumerate()
246            .filter(|(_, &d)| d == 0)
247            .map(|(i, _)| i)
248            .collect();
249        let mut order = Vec::with_capacity(incoming.len());
250        while let Some(idx) = queue.pop_front() {
251            order.push(idx);
252            for &next in &outgoing[idx] {
253                indegree[next] -= 1;
254                if indegree[next] == 0 {
255                    queue.push_back(next);
256                }
257            }
258        }
259        (order.len() == incoming.len()).then_some(order)
260    }
261
262    fn descendant_sets(topology: &GraphTopology) -> Vec<HashSet<usize>> {
263        let count = topology.outgoing.len();
264        let mut descendants: Vec<HashSet<usize>> = vec![HashSet::default(); count];
265        for (start, reachable) in descendants.iter_mut().enumerate() {
266            let mut stack: Vec<usize> = topology.outgoing[start].clone();
267            while let Some(next) = stack.pop() {
268                if reachable.insert(next) {
269                    stack.extend(topology.outgoing[next].iter().copied());
270                }
271            }
272        }
273        descendants
274    }
275
276    fn nodes_scope_of(
277        content: &GraphContent,
278        current: usize,
279        ancestor_set: &HashSet<usize>,
280        descendant_set: &HashSet<usize>,
281        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
282    ) -> VariableType {
283        let scope = VariableType::empty_object();
284        let VariableType::Object(fields) = &scope else {
285            return scope;
286        };
287        let mut map = fields.borrow_mut();
288        for (idx, node) in content.nodes.iter().enumerate() {
289            if idx == current || descendant_set.contains(&idx) {
290                continue;
291            }
292            let resolved = if ancestor_set.contains(&idx) {
293                match nodes.get(&node.id) {
294                    Some(analysis) => analysis.output.shallow_clone(),
295                    None => VariableType::Any,
296                }
297            } else {
298                VariableType::Any
299            };
300            let merged = match map.get(node.name.as_ref()) {
301                Some(existing) => existing.merge(&resolved),
302                None => resolved,
303            };
304            map.insert(Rc::from(node.name.as_ref()), merged);
305        }
306        drop(map);
307        scope
308    }
309
310    fn merged_input(
311        content: &GraphContent,
312        topology: &GraphTopology,
313        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
314        idx: usize,
315    ) -> (VariableType, bool, bool) {
316        let mut unchecked = false;
317        let mut open = false;
318        let mut merged: Option<VariableType> = None;
319        for (pred, handle) in &topology.incoming[idx] {
320            let Some(analysis) = nodes.get(&content.nodes[*pred].id) else {
321                continue;
322            };
323            unchecked |= analysis.opaque || analysis.unchecked;
324            open |= analysis.open || matches!(analysis.output, VariableType::Any);
325            let branch = handle
326                .as_ref()
327                .and_then(|h| analysis.branch_outputs.get(h.as_ref()))
328                .unwrap_or(&analysis.output);
329            merged = Some(match merged {
330                None => branch.shallow_clone(),
331                Some(acc) => acc.merge(branch),
332            });
333        }
334        (
335            merged.unwrap_or_else(VariableType::empty_object),
336            unchecked,
337            open,
338        )
339    }
340
341    fn reachable_from_inputs(
342        content: &GraphContent,
343        topology: &GraphTopology,
344    ) -> Option<Vec<bool>> {
345        let input_indices: Vec<usize> = content
346            .nodes
347            .iter()
348            .enumerate()
349            .filter(|(_, node)| matches!(node.kind, DecisionNodeKind::InputNode { .. }))
350            .map(|(idx, _)| idx)
351            .collect();
352        if input_indices.is_empty() {
353            return None;
354        }
355        let mut reachable = vec![false; content.nodes.len()];
356        let mut stack = input_indices;
357        while let Some(idx) = stack.pop() {
358            if std::mem::replace(&mut reachable[idx], true) {
359                continue;
360            }
361            stack.extend(topology.outgoing[idx].iter().copied());
362        }
363        Some(reachable)
364    }
365
366    fn terminal_output(
367        content: &GraphContent,
368        topology: &GraphTopology,
369        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
370    ) -> VariableType {
371        let reachable = Self::reachable_from_inputs(content, topology);
372        let mut terminals: Vec<&GraphNodeAnalysis> = content
373            .nodes
374            .iter()
375            .enumerate()
376            .filter(|(idx, _)| topology.outgoing.get(*idx).is_some_and(Vec::is_empty))
377            .filter(|(idx, _)| reachable.as_ref().is_none_or(|r| r[*idx]))
378            .filter_map(|(_, node)| nodes.get(&node.id))
379            .collect();
380        let Some(first) = terminals.pop() else {
381            return VariableType::empty_object();
382        };
383        terminals
384            .into_iter()
385            .fold(first.output.shallow_clone(), |acc, t| acc.merge(&t.output))
386    }
387
388    fn graph_input_type(&self) -> VariableType {
389        self.content
390            .nodes
391            .iter()
392            .find_map(|node| match &node.kind {
393                DecisionNodeKind::InputNode { content } => content.schema.as_ref(),
394                _ => None,
395            })
396            .map(|schema| super::SchemaType::variable_type_with(schema, &self.dictionary_types))
397            .unwrap_or(VariableType::Any)
398    }
399
400    fn check_schema_dictionaries(&mut self, node: &DecisionNode, schema: &serde_json::Value) {
401        let mut names: Vec<Arc<str>> = Vec::new();
402        super::SchemaType::dictionary_names(schema, &mut names);
403        names.sort();
404        names.dedup();
405        for name in names {
406            if self.dictionary_types.contains_key(&name) {
407                continue;
408            }
409            self.diagnostics.push(Diagnostic::error(
410                DiagnosticCode::TypeMismatch,
411                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
412                format!(
413                    "unknown dictionary '{name}' in schema: no dictionary with that name is in scope — import the policy that defines it"
414                ),
415            ));
416        }
417    }
418
419    fn check_schema_enum_candidates(&mut self, node: &DecisionNode, schema: &serde_json::Value) {
420        let paths = super::SchemaType::inline_enum_paths(schema);
421        for path in paths.iter().take(8) {
422            self.diagnostics.push(Diagnostic::hint(
423                DiagnosticCode::PreferDictionary,
424                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
425                format!(
426                    "schema property `{path}` declares an inline enum — reference a dictionary instead ({{\"$dictionary\": \"<name>\"}}) so the value set is defined once, labeled, and membership-checked"
427                ),
428            ));
429        }
430    }
431
432    fn analyze_node(
433        &mut self,
434        node: &'a DecisionNode,
435        input: VariableType,
436        unchecked: bool,
437        open: bool,
438        graph_input: &VariableType,
439    ) -> GraphNodeAnalysis {
440        let scope_input = if unchecked || matches!(input, VariableType::Any) {
441            VariableType::empty_object()
442        } else {
443            input.shallow_clone()
444        };
445        self.validate = !unchecked && !open && !matches!(input, VariableType::Any);
446
447        let mut analysis = GraphNodeAnalysis {
448            input: scope_input.shallow_clone(),
449            handler_input: scope_input.shallow_clone(),
450            output: VariableType::Any,
451            dollar: None,
452            nodes_scope: self.nodes_scope.shallow_clone(),
453            branch_outputs: HashMap::default(),
454            opaque: false,
455            unchecked,
456            open,
457        };
458
459        match &node.kind {
460            DecisionNodeKind::InputNode { content } => {
461                if let Some(schema) = content.schema.as_ref() {
462                    self.check_schema_dictionaries(node, schema);
463                    self.check_schema_enum_candidates(node, schema);
464                }
465                analysis.output = graph_input.shallow_clone();
466                if matches!(graph_input, VariableType::Any) {
467                    analysis.opaque = true;
468                    analysis.open = true;
469                    self.diagnostics.push(Diagnostic::warning(
470                        DiagnosticCode::MissingInputSchema,
471                        DiagnosticLocation::block(self.path.clone(), node.id.clone()),
472                        "input node has no schema; input properties are unknown and downstream expressions cannot be strictly checked — define the request schema",
473                    ));
474                } else {
475                    let mut any_paths = Vec::new();
476                    Self::collect_any_paths(graph_input, String::new(), &mut any_paths);
477                    for path in any_paths.iter().take(8) {
478                        self.diagnostics.push(Diagnostic::error(
479                            DiagnosticCode::ImplicitAny,
480                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
481                            format!(
482                                "schema leaves `{path}` untyped (`any`) — everything computed from it degrades to `any`; declare its type in the request schema"
483                            ),
484                        ));
485                    }
486                    if let Some(schema) = content.schema.as_ref() {
487                        let divergent = super::SchemaType::nullability_divergences(schema);
488                        for path in divergent.iter().take(8) {
489                            self.diagnostics.push(Diagnostic::warning(
490                                DiagnosticCode::NullabilityDivergence,
491                                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
492                                format!(
493                                    "optional property `{path}` reads as nullable, but its schema does not allow null — a payload carrying `{path}: null` fails validation at runtime; add \"null\" to its type if null is a real value, or ignore this if the field is strictly absent-or-present"
494                                ),
495                            ));
496                        }
497                    }
498                }
499            }
500            DecisionNodeKind::OutputNode { content } => {
501                if let Some(schema) = content.schema.as_ref() {
502                    self.check_schema_dictionaries(node, schema);
503                    self.check_schema_enum_candidates(node, schema);
504                }
505                if let Some(schema) = content.schema.as_ref().filter(|_| self.validate) {
506                    let expected =
507                        super::SchemaType::variable_type_with(schema, &self.dictionary_types);
508                    self.check_output_schema(node, &scope_input, &expected);
509                }
510                analysis.output = scope_input;
511            }
512            DecisionNodeKind::SwitchNode { content } => {
513                analysis.branch_outputs = self.check_switch(node, content, &scope_input);
514                analysis.output = scope_input;
515            }
516            DecisionNodeKind::CustomNode { content } => {
517                self.diagnostics.push(Diagnostic::warning(
518                    DiagnosticCode::UncheckedNode,
519                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
520                    format!(
521                        "unknown node kind '{}' — this node is not type-checked and downstream nodes are unchecked",
522                        content.kind
523                    ),
524                ));
525                analysis.opaque = true;
526                analysis.open = true;
527            }
528            DecisionNodeKind::FunctionNode { content } => {
529                self.check_function(node, content, &scope_input, &mut analysis);
530            }
531            DecisionNodeKind::ExpressionNode { content } => {
532                let (handler_input, output) = self.transformed(
533                    node,
534                    &content.transform_attributes,
535                    &scope_input,
536                    |analyzer, scope| {
537                        let (output, dollar) = analyzer.check_expression_rows(node, content, scope);
538                        analysis.dollar = Some(dollar);
539                        output
540                    },
541                );
542                analysis.handler_input = handler_input;
543                analysis.output = output;
544                analysis.open = open && content.transform_attributes.pass_through;
545            }
546            DecisionNodeKind::DecisionTableNode { content } => {
547                let (handler_input, output) = self.transformed(
548                    node,
549                    &content.transform_attributes,
550                    &scope_input,
551                    |analyzer, scope| analyzer.check_decision_table(node, content, scope),
552                );
553                analysis.handler_input = handler_input;
554                analysis.output = output;
555                analysis.open = open && content.transform_attributes.pass_through;
556            }
557            DecisionNodeKind::DecisionNode { content } => {
558                let signature = self.resolve_decision_signature(node, content);
559                let resolved = signature
560                    .as_ref()
561                    .map(|s| s.output.shallow_clone())
562                    .unwrap_or(VariableType::Any);
563                let (handler_input, output) = self.transformed(
564                    node,
565                    &content.transform_attributes,
566                    &scope_input,
567                    |analyzer, scope| {
568                        if let Some(signature) = &signature {
569                            analyzer.check_decision_input(node, content, signature, scope);
570                        }
571                        resolved
572                    },
573                );
574                analysis.handler_input = handler_input;
575                analysis.output = output;
576                analysis.open = open && content.transform_attributes.pass_through;
577            }
578        }
579
580        analysis
581    }
582
583    fn check_function(
584        &mut self,
585        node: &DecisionNode,
586        content: &FunctionNodeContent,
587        scope_input: &VariableType,
588        analysis: &mut GraphNodeAnalysis,
589    ) {
590        let source = super::function_source(content);
591        match self.db.function_output_type(&source, scope_input) {
592            FunctionTypeOutcome::Typed(resolved) => {
593                if matches!(resolved, VariableType::Any) {
594                    self.diagnostics.push(Diagnostic::error(
595                        DiagnosticCode::ImplicitAny,
596                        DiagnosticLocation::block(self.path.clone(), node.id.clone()),
597                        "function handler type resolved to `any` — add explicit types to the handler",
598                    ));
599                    analysis.opaque = true;
600                    analysis.open = true;
601                } else {
602                    let mut any_paths = Vec::new();
603                    Self::collect_any_paths(&resolved, String::new(), &mut any_paths);
604                    for path in any_paths.iter().take(8) {
605                        self.diagnostics.push(Diagnostic::error(
606                            DiagnosticCode::ImplicitAny,
607                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
608                            format!("function output `{path}` is `any` — type it explicitly"),
609                        ));
610                    }
611                    analysis.output = resolved;
612                }
613            }
614            FunctionTypeOutcome::Unresolved => {
615                self.diagnostics.push(Diagnostic::warning(
616                    DiagnosticCode::UnresolvedFunctionType,
617                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
618                    "the type resolver could not determine the handler type; downstream nodes are unchecked",
619                ));
620                analysis.opaque = true;
621                analysis.open = true;
622            }
623            FunctionTypeOutcome::Unknown => {
624                self.diagnostics.push(Diagnostic::warning(
625                    DiagnosticCode::UnresolvedFunctionType,
626                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
627                    "function node types are unknown; register a function type resolver",
628                ));
629                analysis.opaque = true;
630                analysis.open = true;
631            }
632        }
633    }
634
635    fn collect_any_paths(variable_type: &VariableType, path: String, out: &mut Vec<String>) {
636        match variable_type {
637            VariableType::Any => {
638                if !path.is_empty() {
639                    out.push(path);
640                }
641            }
642            VariableType::Array(items) => {
643                Self::collect_any_paths(items, format!("{path}[]"), out);
644            }
645            VariableType::Nullable(inner) => {
646                Self::collect_any_paths(inner, path, out);
647            }
648            VariableType::Object(fields) => {
649                let map = fields.borrow();
650                let mut keys: Vec<_> = map.keys().cloned().collect();
651                keys.sort();
652                for key in keys {
653                    let Some(field) = map.get(key.as_ref()) else {
654                        continue;
655                    };
656                    let child = if path.is_empty() {
657                        key.to_string()
658                    } else {
659                        format!("{path}.{key}")
660                    };
661                    Self::collect_any_paths(field, child, out);
662                }
663            }
664            _ => {}
665        }
666    }
667
668    fn transformed(
669        &mut self,
670        node: &DecisionNode,
671        attributes: &TransformAttributes,
672        scope_input: &VariableType,
673        handler: impl FnOnce(&mut Self, &VariableType) -> VariableType,
674    ) -> (VariableType, VariableType) {
675        let base = match &attributes.input_field {
676            Some(field) => {
677                let field_scope =
678                    Self::scope_with_nodes(scope_input, &self.nodes_scope.shallow_clone());
679                self.check_expression(
680                    &node.id,
681                    None,
682                    Some(CursorTarget::TransformInput),
683                    field,
684                    ExpressionKind::Standard,
685                    &field_scope,
686                )
687            }
688            None => scope_input.shallow_clone(),
689        };
690        if attributes.input_field.is_some() && matches!(base, VariableType::Any) {
691            self.validate = false;
692        }
693
694        let (handler_scope, mut output) = match attributes.execution_mode {
695            TransformExecutionMode::Single => {
696                let output = handler(self, &base);
697                (base, output)
698            }
699            TransformExecutionMode::Loop => {
700                let element = match base.iterator() {
701                    Some(inner) => inner.as_ref().shallow_clone(),
702                    None => {
703                        if !matches!(base, VariableType::Any) {
704                            self.diagnostics.push(Diagnostic::error(
705                                DiagnosticCode::TypeMismatch,
706                                DiagnosticLocation::block(self.path.clone(), node.id.clone())
707                                    .maybe_target(
708                                        attributes
709                                            .input_field
710                                            .as_ref()
711                                            .map(|_| CursorTarget::TransformInput),
712                                    ),
713                                format!("loop execution expects an array input, got `{base}`"),
714                            ));
715                        }
716                        self.validate = false;
717                        VariableType::Any
718                    }
719                };
720                if matches!(element, VariableType::Any) {
721                    self.validate = false;
722                }
723                let mut output = handler(self, &element);
724                if attributes.pass_through {
725                    output = Self::merge_patch_type(&element, &output);
726                }
727                (element, output.array())
728            }
729        };
730
731        if let Some(output_path) = &attributes.output_path {
732            let wrapped = VariableType::empty_object();
733            wrapped.insert_at_path(output_path, &output, true);
734            output = wrapped;
735        }
736        if attributes.pass_through {
737            output = Self::merge_patch_type(scope_input, &output);
738        }
739
740        (handler_scope, output)
741    }
742
743    /// Type-level mirror of the runtime pass-through merge (`Variable::merge_clone`).
744    fn merge_patch_type(base: &VariableType, patch: &VariableType) -> VariableType {
745        match patch {
746            VariableType::Any => VariableType::Any,
747            VariableType::Array(_) => patch.shallow_clone(),
748            VariableType::Object(_) => base.merge(patch),
749            VariableType::Nullable(inner) => match inner.as_ref() {
750                VariableType::Object(fields) => {
751                    let optional = VariableType::empty_object();
752                    if let VariableType::Object(target) = &optional {
753                        let mut map = target.borrow_mut();
754                        for (key, value) in fields.borrow().iter() {
755                            map.insert(key.clone(), super::wrap_optional(value.shallow_clone()));
756                        }
757                    }
758                    base.merge(&optional)
759                }
760                _ => base.shallow_clone(),
761            },
762            _ => base.shallow_clone(),
763        }
764    }
765
766    fn check_expression_rows(
767        &mut self,
768        node: &DecisionNode,
769        content: &ExpressionNodeContent,
770        scope: &VariableType,
771    ) -> (VariableType, VariableType) {
772        let output = VariableType::empty_object();
773        let dollar = VariableType::empty_object();
774        for row in content.expressions.iter() {
775            if row.key.is_empty() || row.value.is_empty() {
776                continue;
777            }
778            let row_scope = Self::scope_with(
779                scope,
780                &[
781                    ("$", dollar.shallow_clone()),
782                    (NODES_KEY, self.nodes_scope.shallow_clone()),
783                ],
784            );
785            let resolved = self.check_expression(
786                &node.id,
787                Some(row.id.clone()),
788                None,
789                &row.value,
790                ExpressionKind::Standard,
791                &row_scope,
792            );
793            output.insert_at_path(&row.key, &resolved, true);
794            dollar.insert_at_path(&row.key, &resolved, true);
795        }
796        (output, dollar)
797    }
798
799    fn check_decision_table(
800        &mut self,
801        node: &DecisionNode,
802        content: &DecisionTableContent,
803        scope: &VariableType,
804    ) -> VariableType {
805        let base_scope = Self::scope_with_nodes(scope, &self.nodes_scope.shallow_clone());
806
807        let mut cell_scopes: HashMap<Arc<str>, VariableType> = HashMap::new();
808        let mut input_field_types: HashMap<Arc<str>, VariableType> = HashMap::new();
809        for col in content.inputs.iter() {
810            let Some(field) = &col.field else {
811                continue;
812            };
813            let field_type = self.check_expression(
814                &node.id,
815                Some(col.id.clone()),
816                Some(CursorTarget::DecisionTableHead {
817                    col: col.id.clone(),
818                }),
819                field,
820                ExpressionKind::Standard,
821                &base_scope,
822            );
823            cell_scopes.insert(col.id.clone(), base_scope.with_dollar(&field_type));
824            input_field_types.insert(col.id.clone(), field_type);
825        }
826
827        for (row_idx, rule) in content.rules.iter().enumerate() {
828            let row_key = Self::row_key(rule, row_idx);
829            for col in content.inputs.iter() {
830                let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
831                    continue;
832                };
833                let target = CursorTarget::DecisionTableCell {
834                    row: row_key.clone(),
835                    col: col.id.clone(),
836                };
837                match cell_scopes.get(&col.id) {
838                    Some(cell_scope) => {
839                        self.check_expression(
840                            &node.id,
841                            Some(col.id.clone()),
842                            Some(target),
843                            cell,
844                            ExpressionKind::Unary,
845                            &cell_scope.shallow_clone(),
846                        );
847                    }
848                    None => {
849                        let resolved = self.check_expression(
850                            &node.id,
851                            Some(col.id.clone()),
852                            Some(target.clone()),
853                            cell,
854                            ExpressionKind::Standard,
855                            &base_scope,
856                        );
857                        if !matches!(resolved, VariableType::Bool | VariableType::Any) {
858                            self.diagnostics.push(Diagnostic::error(
859                                DiagnosticCode::TypeMismatch,
860                                DiagnosticLocation::expression(
861                                    self.path.clone(),
862                                    node.id.clone(),
863                                    col.id.clone(),
864                                    None,
865                                )
866                                .with_target(target),
867                                format!("input condition must return a boolean, got `{resolved}`"),
868                            ));
869                        }
870                    }
871                }
872            }
873        }
874
875        for col in content.inputs.iter() {
876            let Some(field) = &col.field else {
877                continue;
878            };
879            let Some(field_type) = input_field_types.get(&col.id) else {
880                continue;
881            };
882            if !matches!(field_type.unwrap_nullable().0, VariableType::String) {
883                continue;
884            }
885            let intellisense = self.db.graph_intellisense();
886            let mut tests: Vec<ArmTest> = Vec::new();
887            for rule in content.rules.iter() {
888                let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
889                    continue;
890                };
891                if cell.trim() == "_" {
892                    continue;
893                }
894                tests.push(IntelliSenseSource::cell_test(
895                    &mut intellisense.borrow_mut(),
896                    cell,
897                ));
898            }
899            if let Some(values) = DictionaryCandidate::from_literal_tests(&tests) {
900                self.diagnostics.push(Diagnostic::hint(
901                    DiagnosticCode::PreferDictionary,
902                    DiagnosticLocation::expression(
903                        self.path.clone(),
904                        node.id.clone(),
905                        col.id.clone(),
906                        None,
907                    )
908                    .with_target(CursorTarget::DecisionTableHead {
909                        col: col.id.clone(),
910                    }),
911                    format!(
912                        "conditions on '{}' only test the fixed strings {} — define a dictionary in an imported policy and type the field with it for membership checking and labeled editing",
913                        field,
914                        DictionaryCandidate::format_values(&values)
915                    ),
916                ));
917            }
918        }
919
920        let output = VariableType::empty_object();
921        for col in content.outputs.iter() {
922            if col.field.is_empty() {
923                continue;
924            }
925            let (path, collect) = col.write_path();
926            if (collect && path.is_empty()) || path.contains("[]") {
927                let message = if path.is_empty() {
928                    "output field '[]' is missing a path before the collect marker".to_string()
929                } else {
930                    format!(
931                        "invalid write path '{}': `[]` may only appear at the end of an output field",
932                        col.field
933                    )
934                };
935                self.diagnostics.push(Diagnostic::error(
936                    DiagnosticCode::InvalidWritePath,
937                    DiagnosticLocation::expression(
938                        self.path.clone(),
939                        node.id.clone(),
940                        col.id.clone(),
941                        None,
942                    )
943                    .with_target(CursorTarget::DecisionTableHead {
944                        col: col.id.clone(),
945                    }),
946                    message,
947                ));
948                continue;
949            }
950            let declared = self.declared_output_type(node, col);
951            let mut cell_types: Vec<VariableType> = Vec::new();
952            let mut has_null_cell = false;
953            for (row_idx, rule) in content.rules.iter().enumerate() {
954                let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
955                    continue;
956                };
957                let target = CursorTarget::DecisionTableCell {
958                    row: Self::row_key(rule, row_idx),
959                    col: col.id.clone(),
960                };
961                let resolved = self.check_expression(
962                    &node.id,
963                    Some(col.id.clone()),
964                    Some(target.clone()),
965                    cell,
966                    ExpressionKind::Standard,
967                    &base_scope,
968                );
969                has_null_cell |= resolved.is_null();
970                match &declared {
971                    Some(expected) => {
972                        if !resolved.is_null() && !resolved.satisfies(expected) {
973                            self.diagnostics.push(Diagnostic::error(
974                                DiagnosticCode::TypeMismatch,
975                                DiagnosticLocation::expression(
976                                    self.path.clone(),
977                                    node.id.clone(),
978                                    col.id.clone(),
979                                    None,
980                                )
981                                .with_target(target),
982                                format!("output cell must be `{expected}`, got `{resolved}`"),
983                            ));
984                        }
985                    }
986                    None => cell_types.push(resolved),
987                }
988            }
989            if declared.is_none() {
990                if let Some(values) = DictionaryCandidate::from_const_cells(&cell_types) {
991                    self.diagnostics.push(Diagnostic::hint(
992                        DiagnosticCode::PreferDictionary,
993                        DiagnosticLocation::expression(
994                            self.path.clone(),
995                            node.id.clone(),
996                            col.id.clone(),
997                            None,
998                        )
999                        .with_target(CursorTarget::DecisionTableHead {
1000                            col: col.id.clone(),
1001                        }),
1002                        format!(
1003                            "output column '{}' only produces the fixed strings {} — define a dictionary with these values in an imported policy and type the column with it ('out {}: <dictionary>') for membership checking and labeled editing",
1004                            col.field,
1005                            DictionaryCandidate::format_values(&values),
1006                            col.field
1007                        ),
1008                    ));
1009                }
1010            }
1011            let has_empty_cell = content
1012                .rules
1013                .iter()
1014                .any(|rule| rule.get(&col.id).is_none_or(|c| c.is_empty()));
1015            let mut merged = match &declared {
1016                Some(expected) => expected.shallow_clone(),
1017                None => {
1018                    let merged = cell_types
1019                        .iter()
1020                        .map(VariableType::shallow_clone)
1021                        .reduce(|acc, t| acc.merge(&t));
1022                    match (merged, collect) {
1023                        (Some(merged), _) => merged,
1024                        (None, true) => VariableType::Any,
1025                        (None, false) => continue,
1026                    }
1027                }
1028            };
1029            if !collect && (has_empty_cell || (has_null_cell && declared.is_some())) {
1030                merged = super::wrap_optional(merged);
1031            }
1032            if declared.is_none()
1033                && matches!(merged, VariableType::Any)
1034                && cell_types.len() > 1
1035                && !cell_types.iter().any(|t| matches!(t, VariableType::Any))
1036            {
1037                self.diagnostics.push(Diagnostic::error(
1038                    DiagnosticCode::TypeMismatch,
1039                    DiagnosticLocation::expression(
1040                        self.path.clone(),
1041                        node.id.clone(),
1042                        col.id.clone(),
1043                        None,
1044                    )
1045                    .with_target(CursorTarget::DecisionTableHead {
1046                        col: col.id.clone(),
1047                    }),
1048                    format!(
1049                        "'{}' has incompatible types: {}",
1050                        col.field,
1051                        cell_types
1052                            .iter()
1053                            .map(|t| format!("`{t}`"))
1054                            .collect::<Vec<_>>()
1055                            .join(", ")
1056                    ),
1057                ));
1058            }
1059            if collect {
1060                merged = merged.array();
1061            }
1062            output.insert_at_path(path, &merged, true);
1063        }
1064
1065        match content.hit_policy {
1066            DecisionTableHitPolicy::First => {
1067                if self.table_covered(content, &input_field_types) {
1068                    output
1069                } else if content.transform_attributes.pass_through {
1070                    if let VariableType::Object(fields) = &output {
1071                        let mut map = fields.borrow_mut();
1072                        let keys: Vec<Rc<str>> = map.keys().cloned().collect();
1073                        for key in keys {
1074                            if let Some(current) = map.get(&key).map(VariableType::shallow_clone) {
1075                                map.insert(key, super::wrap_optional(current));
1076                            }
1077                        }
1078                    }
1079                    output
1080                } else {
1081                    VariableType::Nullable(Rc::new(output))
1082                }
1083            }
1084            DecisionTableHitPolicy::Collect => output.array(),
1085        }
1086    }
1087
1088    fn declared_output_type(
1089        &mut self,
1090        node: &DecisionNode,
1091        col: &DecisionTableOutputField,
1092    ) -> Option<VariableType> {
1093        let head = CursorTarget::DecisionTableHead {
1094            col: col.id.clone(),
1095        };
1096        let declared = match Self::parse_declared_column(col.column_type.as_deref()) {
1097            Ok(declared) => declared?,
1098            Err(message) => {
1099                self.diagnostics.push(Diagnostic::error(
1100                    DiagnosticCode::TypeMismatch,
1101                    DiagnosticLocation::expression(
1102                        self.path.clone(),
1103                        node.id.clone(),
1104                        col.id.clone(),
1105                        None,
1106                    )
1107                    .with_target(head),
1108                    message,
1109                ));
1110                return None;
1111            }
1112        };
1113        let resolved = declared.resolve(&self.dictionary_types);
1114        if resolved.is_none() {
1115            self.diagnostics.push(Diagnostic::error(
1116                DiagnosticCode::TypeMismatch,
1117                DiagnosticLocation::expression(
1118                    self.path.clone(),
1119                    node.id.clone(),
1120                    col.id.clone(),
1121                    None,
1122                )
1123                .with_target(head),
1124                format!(
1125                    "unknown output type '{declared}': no dictionary with that name is in scope"
1126                ),
1127            ));
1128        }
1129        resolved
1130    }
1131
1132    fn parse_declared_column(column_type: Option<&str>) -> Result<Option<DeclaredType>, String> {
1133        DeclaredType::parse(column_type.unwrap_or(""))
1134    }
1135
1136    pub(crate) fn output_expected(
1137        content: &DecisionTableContent,
1138        col_id: &str,
1139        dictionaries: &HashMap<Arc<str>, VariableType>,
1140    ) -> Option<VariableType> {
1141        let column = content.outputs.iter().find(|c| c.id.as_ref() == col_id)?;
1142        let declared = Self::parse_declared_column(column.column_type.as_deref()).ok()??;
1143        declared.resolve(dictionaries)
1144    }
1145
1146    fn table_covered(
1147        &self,
1148        content: &DecisionTableContent,
1149        input_field_types: &HashMap<Arc<str>, VariableType>,
1150    ) -> bool {
1151        if content.rules.is_empty() {
1152            return false;
1153        }
1154        let row_is_live = |rule: &ahash::HashMap<Arc<str>, Arc<str>>| {
1155            content.inputs.iter().all(|ic| rule.contains_key(&ic.id))
1156                && content.outputs.iter().all(|oc| rule.contains_key(&oc.id))
1157        };
1158        let row_is_catch_all = |rule: &ahash::HashMap<Arc<str>, Arc<str>>| {
1159            row_is_live(rule)
1160                && content
1161                    .inputs
1162                    .iter()
1163                    .all(|ic| rule.get(&ic.id).is_some_and(|c| c.is_empty()))
1164        };
1165        if content.rules.iter().any(row_is_catch_all) {
1166            return true;
1167        }
1168
1169        let intellisense = self.db.graph_intellisense();
1170        let mut groups: HashMap<Arc<str>, Vec<ArmTest>> = HashMap::new();
1171        for rule in content.rules.iter() {
1172            if !row_is_live(rule) {
1173                continue;
1174            }
1175            let mut constrained = content
1176                .inputs
1177                .iter()
1178                .filter(|ic| rule.get(&ic.id).is_some_and(|c| !c.is_empty()));
1179            let (Some(column), None) = (constrained.next(), constrained.next()) else {
1180                continue;
1181            };
1182            if column.field.is_none() {
1183                continue;
1184            }
1185            let Some(cell) = rule.get(&column.id) else {
1186                continue;
1187            };
1188            groups
1189                .entry(column.id.clone())
1190                .or_default()
1191                .push(IntelliSenseSource::cell_test(
1192                    &mut intellisense.borrow_mut(),
1193                    cell,
1194                ));
1195        }
1196        groups.iter().any(|(col_id, tests)| {
1197            input_field_types
1198                .get(col_id)
1199                .is_some_and(|t| DecisionTableIr::cells_cover(tests, t))
1200        })
1201    }
1202
1203    fn check_output_schema(
1204        &mut self,
1205        node: &DecisionNode,
1206        actual: &VariableType,
1207        expected: &VariableType,
1208    ) {
1209        let VariableType::Object(expected_fields) = expected else {
1210            return;
1211        };
1212        let (actual_base, _) = actual.unwrap_nullable();
1213        let VariableType::Object(actual_fields) = actual_base else {
1214            return;
1215        };
1216        let mut keys: Vec<Rc<str>> = expected_fields.borrow().keys().cloned().collect();
1217        keys.sort();
1218        for key in keys {
1219            let Some(expected_type) = expected_fields.borrow().get(&key).cloned() else {
1220                continue;
1221            };
1222            let actual_type = actual_fields.borrow().get(&key).cloned();
1223            match actual_type {
1224                None => {
1225                    let (inner, optional) = expected_type.unwrap_nullable();
1226                    if !optional && !matches!(inner, VariableType::Any | VariableType::Null) {
1227                        self.diagnostics.push(Diagnostic::error(
1228                            DiagnosticCode::TypeMismatch,
1229                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1230                            format!(
1231                                "output schema requires property '{key}' of type `{inner}`, but it is never produced"
1232                            ),
1233                        ));
1234                    }
1235                }
1236                Some(actual_type) => {
1237                    if !actual_type.satisfies(&expected_type) {
1238                        self.diagnostics.push(Diagnostic::error(
1239                            DiagnosticCode::TypeMismatch,
1240                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1241                            format!(
1242                                "output property '{key}' has type `{actual_type}`, but the output schema expects `{expected_type}`"
1243                            ),
1244                        ));
1245                    }
1246                }
1247            }
1248        }
1249    }
1250
1251    fn lint_output_any(
1252        &mut self,
1253        topology: &GraphTopology,
1254        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
1255        graph_input: &VariableType,
1256    ) {
1257        if matches!(graph_input, VariableType::Any) {
1258            return;
1259        }
1260        if self
1261            .diagnostics
1262            .iter()
1263            .any(|d| d.severity == Severity::Error)
1264        {
1265            return;
1266        }
1267        let Some(reachable) = Self::reachable_from_inputs(self.content, topology) else {
1268            return;
1269        };
1270        let Some(order) = &topology.order else {
1271            return;
1272        };
1273        let mut input_any = Vec::new();
1274        Self::collect_any_paths(graph_input, String::new(), &mut input_any);
1275        let mut seen: HashSet<String> = HashSet::default();
1276        for &idx in order {
1277            let node = &self.content.nodes[idx];
1278            if !reachable[idx] || matches!(node.kind, DecisionNodeKind::InputNode { .. }) {
1279                continue;
1280            }
1281            let Some(analysis) = nodes.get(&node.id) else {
1282                continue;
1283            };
1284            if analysis.unchecked || analysis.opaque || analysis.open {
1285                continue;
1286            }
1287            if matches!(analysis.output, VariableType::Any) {
1288                self.diagnostics.push(Diagnostic::error(
1289                    DiagnosticCode::ImplicitAny,
1290                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1291                    format!(
1292                        "output of node '{}' resolves to `any` — the graph's result type becomes unknown; type the producing expression or give the called sub-decision an input schema",
1293                        node.name
1294                    ),
1295                ));
1296                continue;
1297            }
1298            let mut any_paths = Vec::new();
1299            Self::collect_any_paths(&analysis.output, String::new(), &mut any_paths);
1300            any_paths.retain(|path| !input_any.contains(path) && !seen.contains(path));
1301            for path in any_paths.iter().take(8) {
1302                self.diagnostics.push(Diagnostic::error(
1303                    DiagnosticCode::ImplicitAny,
1304                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1305                    format!(
1306                        "output `{path}` resolves to `any` — everything reading it degrades to `any`; give it a concrete type where it is produced"
1307                    ),
1308                ));
1309            }
1310            seen.extend(any_paths);
1311        }
1312    }
1313
1314    fn lint_unreachable(&mut self, topology: &GraphTopology) {
1315        let input_indices: Vec<usize> = self
1316            .content
1317            .nodes
1318            .iter()
1319            .enumerate()
1320            .filter(|(_, node)| matches!(node.kind, DecisionNodeKind::InputNode { .. }))
1321            .map(|(idx, _)| idx)
1322            .collect();
1323        if input_indices.is_empty() {
1324            return;
1325        }
1326        let mut reachable = vec![false; self.content.nodes.len()];
1327        let mut stack = input_indices;
1328        while let Some(idx) = stack.pop() {
1329            if std::mem::replace(&mut reachable[idx], true) {
1330                continue;
1331            }
1332            stack.extend(topology.outgoing[idx].iter().copied());
1333        }
1334        for (idx, node) in self.content.nodes.iter().enumerate() {
1335            if !reachable[idx] {
1336                self.diagnostics.push(Diagnostic::hint(
1337                    DiagnosticCode::UnreachableNode,
1338                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1339                    format!("node '{}' is not reachable from the input node", node.name),
1340                ));
1341            }
1342        }
1343    }
1344
1345    fn lint_expressions(&mut self) {
1346        let intellisense = self.db.graph_intellisense();
1347        for node in &self.content.nodes {
1348            for site in Self::node_sites(node) {
1349                if !matches!(site.kind, ExpressionKind::Standard) {
1350                    continue;
1351                }
1352                let findings = intellisense
1353                    .borrow_mut()
1354                    .with_ast(&site.source, false, |root, metadata| {
1355                        RedundantParentheses::scan(root, metadata)
1356                    })
1357                    .unwrap_or_default();
1358                for (span, inner_span) in findings {
1359                    let message = match inner_span {
1360                        Some(inner) => format!(
1361                            "unnecessary parentheses around '{}'",
1362                            AstOps::display_snippet(&site.source, inner)
1363                        ),
1364                        None => "unnecessary parentheses".to_string(),
1365                    };
1366                    let location = DiagnosticLocation {
1367                        policy_path: self.path.clone(),
1368                        block_id: Some(node.id.clone()),
1369                        expression_id: site.expression_id.clone(),
1370                        span,
1371                        target: Some(site.target.clone()),
1372                    };
1373                    self.diagnostics.push(Diagnostic::hint(
1374                        DiagnosticCode::RedundantParentheses,
1375                        location,
1376                        message,
1377                    ));
1378                }
1379            }
1380        }
1381    }
1382
1383    fn check_switch(
1384        &mut self,
1385        node: &DecisionNode,
1386        content: &SwitchNodeContent,
1387        scope: &VariableType,
1388    ) -> HashMap<Arc<str>, VariableType> {
1389        let condition_scope = Self::scope_with_nodes(scope, &self.nodes_scope.shallow_clone());
1390        let first_hit = matches!(content.hit_policy, SwitchStatementHitPolicy::First);
1391        let mut branches: HashMap<Arc<str>, VariableType> = HashMap::new();
1392        let mut prior_tests: Vec<ArmTest> = Vec::new();
1393
1394        for statement in content.statements.iter() {
1395            let test = if statement.condition.is_empty() {
1396                ArmTest::Default
1397            } else {
1398                let resolved = self.check_expression(
1399                    &node.id,
1400                    Some(statement.id.clone()),
1401                    None,
1402                    &statement.condition,
1403                    ExpressionKind::Standard,
1404                    &condition_scope,
1405                );
1406                if !matches!(resolved, VariableType::Bool | VariableType::Any) {
1407                    self.diagnostics.push(Diagnostic::error(
1408                        DiagnosticCode::TypeMismatch,
1409                        DiagnosticLocation::expression(
1410                            self.path.clone(),
1411                            node.id.clone(),
1412                            statement.id.clone(),
1413                            None,
1414                        ),
1415                        format!("switch condition must return a boolean, got `{resolved}`"),
1416                    ));
1417                }
1418                let intellisense = self.db.graph_intellisense();
1419                let mut is = intellisense.borrow_mut();
1420                IntelliSenseSource::arm_test(&mut is, &statement.condition)
1421            };
1422
1423            let mut narrowed = scope.shallow_clone();
1424            if first_hit {
1425                for prior in &prior_tests {
1426                    narrowed = Self::narrow_negative(&narrowed, prior);
1427                }
1428            }
1429            narrowed = Self::narrow_positive(&narrowed, &test);
1430            branches.insert(statement.id.clone(), narrowed);
1431            if first_hit {
1432                prior_tests.push(test);
1433            }
1434        }
1435        branches
1436    }
1437
1438    fn narrow_positive(scope: &VariableType, test: &ArmTest) -> VariableType {
1439        match test {
1440            ArmTest::Enum { path, values } => Self::narrow_path(scope, path, |current| {
1441                let (base, _) = current.unwrap_nullable();
1442                match base {
1443                    VariableType::Enum(_, declared) => {
1444                        let retained: Vec<Rc<str>> = declared
1445                            .iter()
1446                            .filter(|d| values.iter().any(|v| v.as_ref() == d.as_ref()))
1447                            .cloned()
1448                            .collect();
1449                        match retained.len() {
1450                            0 => base.shallow_clone(),
1451                            1 => VariableType::Const(retained[0].clone()),
1452                            _ => VariableType::Enum(None, retained),
1453                        }
1454                    }
1455                    VariableType::String => match values.len() {
1456                        1 => VariableType::Const(Rc::from(values[0].as_ref())),
1457                        _ => VariableType::Enum(
1458                            None,
1459                            values.iter().map(|v| Rc::from(v.as_ref())).collect(),
1460                        ),
1461                    },
1462                    other => other.shallow_clone(),
1463                }
1464            }),
1465            ArmTest::Bool { path, .. } => Self::narrow_path(scope, path, |current| {
1466                current.unwrap_nullable().0.shallow_clone()
1467            }),
1468            ArmTest::Number { path, .. } => Self::narrow_path(scope, path, |current| {
1469                current.unwrap_nullable().0.shallow_clone()
1470            }),
1471            ArmTest::Default | ArmTest::Unrecognized => scope.shallow_clone(),
1472        }
1473    }
1474
1475    fn narrow_negative(scope: &VariableType, test: &ArmTest) -> VariableType {
1476        let ArmTest::Enum { path, values } = test else {
1477            return scope.shallow_clone();
1478        };
1479        Self::narrow_path(scope, path, |current| {
1480            let (base, nullable) = current.unwrap_nullable();
1481            let VariableType::Enum(_, declared) = base else {
1482                return current.shallow_clone();
1483            };
1484            let retained: Vec<Rc<str>> = declared
1485                .iter()
1486                .filter(|d| !values.iter().any(|v| v.as_ref() == d.as_ref()))
1487                .cloned()
1488                .collect();
1489            let narrowed = match retained.len() {
1490                0 => return current.shallow_clone(),
1491                1 => VariableType::Const(retained[0].clone()),
1492                _ => VariableType::Enum(None, retained),
1493            };
1494            if nullable {
1495                VariableType::Nullable(Rc::new(narrowed))
1496            } else {
1497                narrowed
1498            }
1499        })
1500    }
1501
1502    fn narrow_path(
1503        scope: &VariableType,
1504        path: &[Rc<str>],
1505        narrow: impl FnOnce(&VariableType) -> VariableType,
1506    ) -> VariableType {
1507        let Some(head) = path.first() else {
1508            return scope.shallow_clone();
1509        };
1510        let VariableType::Object(fields) = scope else {
1511            return scope.shallow_clone();
1512        };
1513        let map = fields.borrow();
1514        let Some(current) = map.get(head.as_ref()) else {
1515            return scope.shallow_clone();
1516        };
1517        let replaced = if path.len() == 1 {
1518            narrow(current)
1519        } else {
1520            Self::narrow_path(current, &path[1..], narrow)
1521        };
1522        let mut cloned = map.clone();
1523        drop(map);
1524        cloned.insert(head.clone(), replaced);
1525        VariableType::Object(Rc::new(std::cell::RefCell::new(cloned)))
1526    }
1527
1528    fn resolve_decision_signature(
1529        &mut self,
1530        node: &DecisionNode,
1531        content: &DecisionNodeContent,
1532    ) -> Option<GraphSignature> {
1533        match self.db.decision_signature(&content.key) {
1534            SignatureResolution::Found(signature) => Some(signature),
1535            SignatureResolution::Recursive => None,
1536            SignatureResolution::Missing => {
1537                self.diagnostics.push(Diagnostic::error(
1538                    DiagnosticCode::ImportNotFound,
1539                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1540                    format!(
1541                        "referenced decision '{}' was not found in the workspace",
1542                        content.key
1543                    ),
1544                ));
1545                None
1546            }
1547        }
1548    }
1549
1550    fn check_decision_input(
1551        &mut self,
1552        node: &DecisionNode,
1553        content: &DecisionNodeContent,
1554        signature: &GraphSignature,
1555        scope: &VariableType,
1556    ) {
1557        if !self.validate {
1558            return;
1559        }
1560        let VariableType::Object(expected) = &signature.input else {
1561            return;
1562        };
1563        let (scope_base, _) = scope.unwrap_nullable();
1564        let VariableType::Object(actual) = scope_base else {
1565            return;
1566        };
1567        let mut missing: Vec<(String, VariableType)> = Vec::new();
1568        let mut mismatched: Vec<(String, VariableType, VariableType)> = Vec::new();
1569        Self::diff_required(
1570            String::new(),
1571            &expected.borrow(),
1572            &actual.borrow(),
1573            &mut missing,
1574            &mut mismatched,
1575        );
1576        for (path, expected_type) in missing {
1577            self.diagnostics.push(Diagnostic::error(
1578                DiagnosticCode::TypeMismatch,
1579                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1580                format!(
1581                    "decision '{}' requires input '{path}' of type `{}`, but it is not provided",
1582                    content.key,
1583                    Self::type_sketch(&expected_type, 0)
1584                ),
1585            ));
1586        }
1587        for (path, actual_type, expected_type) in mismatched {
1588            let nullability_only = actual_type.is_nullable() && !expected_type.is_nullable() && {
1589                let (actual_inner, _) = actual_type.unwrap_nullable();
1590                actual_inner.satisfies(&expected_type)
1591            };
1592            let message = if nullability_only {
1593                format!(
1594                    "input '{path}' for decision '{}' may be null (`{actual_type}`), but a non-null `{expected_type}` is required",
1595                    content.key
1596                )
1597            } else {
1598                format!(
1599                    "input '{path}' for decision '{}' has type `{}`, but `{}` is expected",
1600                    content.key,
1601                    Self::type_sketch(&actual_type, 0),
1602                    Self::type_sketch(&expected_type, 0)
1603                )
1604            };
1605            self.diagnostics.push(Diagnostic::error(
1606                DiagnosticCode::TypeMismatch,
1607                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1608                message,
1609            ));
1610        }
1611    }
1612
1613    /// Unlike `Display`, expands object fields so two different types never print identically.
1614    fn type_sketch(variable_type: &VariableType, depth: usize) -> String {
1615        const MAX_DEPTH: usize = 3;
1616        const MAX_FIELDS: usize = 8;
1617        match variable_type {
1618            VariableType::Nullable(inner) => format!("{}?", Self::type_sketch(inner, depth)),
1619            VariableType::Array(items) => {
1620                let inner = Self::type_sketch(items, depth);
1621                if inner.ends_with('?') {
1622                    format!("({inner})[]")
1623                } else {
1624                    format!("{inner}[]")
1625                }
1626            }
1627            VariableType::Object(fields) => {
1628                let map = fields.borrow();
1629                if map.is_empty() {
1630                    return "{}".to_string();
1631                }
1632                if depth >= MAX_DEPTH {
1633                    return "object".to_string();
1634                }
1635                let mut keys: Vec<_> = map.keys().cloned().collect();
1636                keys.sort();
1637                let mut parts: Vec<String> = keys
1638                    .iter()
1639                    .take(MAX_FIELDS)
1640                    .filter_map(|key| {
1641                        map.get(key.as_ref())
1642                            .map(|field| format!("{key}: {}", Self::type_sketch(field, depth + 1)))
1643                    })
1644                    .collect();
1645                if keys.len() > MAX_FIELDS {
1646                    parts.push(format!("…+{} more", keys.len() - MAX_FIELDS));
1647                }
1648                format!("{{ {} }}", parts.join(", "))
1649            }
1650            other => other.to_string(),
1651        }
1652    }
1653
1654    fn diff_required(
1655        prefix: String,
1656        expected: &HashMap<Rc<str>, VariableType>,
1657        actual: &HashMap<Rc<str>, VariableType>,
1658        missing: &mut Vec<(String, VariableType)>,
1659        mismatched: &mut Vec<(String, VariableType, VariableType)>,
1660    ) {
1661        let mut keys: Vec<&Rc<str>> = expected.keys().collect();
1662        keys.sort();
1663        for key in keys {
1664            let expected_type = &expected[key];
1665            let path = if prefix.is_empty() {
1666                key.to_string()
1667            } else {
1668                format!("{prefix}.{key}")
1669            };
1670            let (expected_inner, optional) = expected_type.unwrap_nullable();
1671            match actual.get(key) {
1672                None => {
1673                    if !optional
1674                        && !matches!(expected_inner, VariableType::Any | VariableType::Null)
1675                    {
1676                        missing.push((path, expected_inner.shallow_clone()));
1677                    }
1678                }
1679                Some(actual_type) => {
1680                    let (actual_inner, actual_nullable) = actual_type.unwrap_nullable();
1681                    if matches!(actual_inner, VariableType::Any) {
1682                        continue;
1683                    }
1684                    if actual_nullable && !optional {
1685                        mismatched.push((
1686                            path,
1687                            actual_type.shallow_clone(),
1688                            expected_type.shallow_clone(),
1689                        ));
1690                        continue;
1691                    }
1692                    if let (VariableType::Object(e), VariableType::Object(a)) =
1693                        (expected_inner, actual_inner)
1694                    {
1695                        Self::diff_required(path, &e.borrow(), &a.borrow(), missing, mismatched);
1696                        continue;
1697                    }
1698                    if let (VariableType::Array(e_item), VariableType::Array(a_item)) =
1699                        (expected_inner, actual_inner)
1700                    {
1701                        let (e_it, item_optional) = e_item.unwrap_nullable();
1702                        let (a_it, item_nullable) = a_item.unwrap_nullable();
1703                        let item_path = format!("{path}[]");
1704                        if matches!(a_it, VariableType::Any) {
1705                            continue;
1706                        }
1707                        if item_nullable && !item_optional {
1708                            mismatched.push((
1709                                item_path,
1710                                a_item.shallow_clone(),
1711                                e_item.shallow_clone(),
1712                            ));
1713                            continue;
1714                        }
1715                        if let (VariableType::Object(e), VariableType::Object(a)) = (e_it, a_it) {
1716                            Self::diff_required(
1717                                item_path,
1718                                &e.borrow(),
1719                                &a.borrow(),
1720                                missing,
1721                                mismatched,
1722                            );
1723                            continue;
1724                        }
1725                        if !a_it.satisfies(e_it) {
1726                            mismatched.push((
1727                                item_path,
1728                                a_it.shallow_clone(),
1729                                e_it.shallow_clone(),
1730                            ));
1731                        }
1732                        continue;
1733                    }
1734                    if !actual_type.satisfies(expected_type) {
1735                        mismatched.push((
1736                            path,
1737                            actual_type.shallow_clone(),
1738                            expected_type.shallow_clone(),
1739                        ));
1740                    }
1741                }
1742            }
1743        }
1744    }
1745
1746    fn check_expression(
1747        &mut self,
1748        node_id: &Arc<str>,
1749        expression_id: Option<Arc<str>>,
1750        target: Option<CursorTarget>,
1751        source: &Arc<str>,
1752        kind: ExpressionKind,
1753        scope: &VariableType,
1754    ) -> VariableType {
1755        let intellisense = self.db.graph_intellisense();
1756        let analysis =
1757            IntelliSenseSource::analyze(&mut intellisense.borrow_mut(), source, kind, scope);
1758        for diagnostic in &analysis.diagnostics {
1759            if !self.validate
1760                && matches!(
1761                    diagnostic.source,
1762                    zen_expression::intellisense::diagnostic::DiagnosticSource::TypeCheck
1763                )
1764            {
1765                continue;
1766            }
1767            let location = DiagnosticLocation {
1768                policy_path: self.path.clone(),
1769                block_id: Some(node_id.clone()),
1770                expression_id: expression_id.clone(),
1771                span: Some(diagnostic.span),
1772                target: target.clone(),
1773            };
1774            self.diagnostics
1775                .push(Diagnostic::from_expression(diagnostic, location));
1776        }
1777        if self.validate {
1778            self.validate_read_paths(node_id, &expression_id, &target, &analysis.reads, scope);
1779        }
1780        analysis.return_type.shallow_clone()
1781    }
1782
1783    fn validate_read_paths(
1784        &mut self,
1785        node_id: &Arc<str>,
1786        expression_id: &Option<Arc<str>>,
1787        target: &Option<CursorTarget>,
1788        reads: &[zen_expression::intellisense::ReadDependency],
1789        scope: &VariableType,
1790    ) {
1791        let mut flattened = Vec::new();
1792        ReadFlattener::extend_from_deps(reads, expression_id, &mut flattened);
1793        for read in flattened {
1794            if read.unresolved || read.via_alias {
1795                continue;
1796            }
1797            let root = read.path.split('.').next().unwrap_or_default();
1798            if root.is_empty() || root.starts_with('$') {
1799                continue;
1800            }
1801            let Some(unknown) = Self::unknown_segment(scope, root) else {
1802                continue;
1803            };
1804            let location = DiagnosticLocation {
1805                policy_path: self.path.clone(),
1806                block_id: Some(node_id.clone()),
1807                expression_id: read.expression_id.clone(),
1808                span: read.span,
1809                target: target.clone(),
1810            };
1811            self.diagnostics.push(Diagnostic::error(
1812                DiagnosticCode::UndefinedVariable,
1813                location,
1814                format!("Unknown property '{unknown}'"),
1815            ));
1816        }
1817    }
1818
1819    fn unknown_segment(scope: &VariableType, path: &str) -> Option<String> {
1820        let mut current = scope.shallow_clone();
1821        let mut walked: Vec<&str> = Vec::new();
1822        for segment in path.split('.') {
1823            while let VariableType::Nullable(inner) = current {
1824                current = inner.as_ref().shallow_clone();
1825            }
1826            let VariableType::Object(fields) = &current else {
1827                return None;
1828            };
1829            walked.push(segment);
1830            let next = fields.borrow().get(segment).cloned();
1831            match next {
1832                Some(t) => current = t,
1833                None => return Some(walked.join(".")),
1834            }
1835        }
1836        None
1837    }
1838
1839    fn inferred_inputs(
1840        &self,
1841        topology: &GraphTopology,
1842        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
1843        graph_input: &VariableType,
1844    ) -> Vec<Arc<str>> {
1845        if !matches!(graph_input, VariableType::Any) {
1846            return Vec::new();
1847        }
1848        let Some(order) = &topology.order else {
1849            return Vec::new();
1850        };
1851
1852        let input_successors: HashSet<usize> = order
1853            .iter()
1854            .filter(|&&idx| {
1855                matches!(
1856                    self.content.nodes[idx].kind,
1857                    DecisionNodeKind::InputNode { .. }
1858                )
1859            })
1860            .flat_map(|&idx| topology.outgoing[idx].iter().copied())
1861            .collect();
1862
1863        let mut paths: Vec<Arc<str>> = Vec::new();
1864        for &idx in &input_successors {
1865            let node = &self.content.nodes[idx];
1866            let provided: HashSet<Rc<str>> = topology.incoming[idx]
1867                .iter()
1868                .filter_map(|(pred, _)| {
1869                    let pred_node = &self.content.nodes[*pred];
1870                    if matches!(pred_node.kind, DecisionNodeKind::InputNode { .. }) {
1871                        return None;
1872                    }
1873                    nodes.get(&pred_node.id)
1874                })
1875                .filter_map(|analysis| match &analysis.output {
1876                    VariableType::Object(fields) => {
1877                        Some(fields.borrow().keys().cloned().collect::<Vec<Rc<str>>>())
1878                    }
1879                    _ => None,
1880                })
1881                .flatten()
1882                .collect();
1883            paths.extend(self.node_read_paths(node, &provided));
1884        }
1885        paths.sort();
1886        paths.dedup();
1887        paths
1888    }
1889
1890    fn node_read_paths(&self, node: &DecisionNode, provided: &HashSet<Rc<str>>) -> Vec<Arc<str>> {
1891        let intellisense = self.db.graph_intellisense();
1892        let mut is = intellisense.borrow_mut();
1893        let mut reads = Vec::new();
1894        for site in Self::node_sites(node) {
1895            let deps = match site.kind {
1896                ExpressionKind::Standard => is.reads(&site.source),
1897                ExpressionKind::Unary => is.reads_unary(&site.source),
1898            };
1899            ReadFlattener::extend_from_deps(&deps, &None, &mut reads);
1900        }
1901        reads
1902            .into_iter()
1903            .filter(|read| !read.unresolved && !read.via_alias)
1904            .filter_map(|read| {
1905                let root = read
1906                    .path
1907                    .split_once('.')
1908                    .map_or(read.path.as_ref(), |(root, _)| root);
1909                let external = !root.starts_with('$') && !provided.contains(root);
1910                external.then_some(read.path)
1911            })
1912            .collect()
1913    }
1914
1915    pub(crate) fn node_sites(node: &DecisionNode) -> Vec<GraphExpressionSite> {
1916        let mut sites: Vec<GraphExpressionSite> = Vec::new();
1917        let mut push_input_field = |attributes: &TransformAttributes| {
1918            if let Some(field) = &attributes.input_field {
1919                sites.push(GraphExpressionSite {
1920                    target: CursorTarget::TransformInput,
1921                    expression_id: None,
1922                    source: field.clone(),
1923                    kind: ExpressionKind::Standard,
1924                });
1925            }
1926        };
1927        match &node.kind {
1928            DecisionNodeKind::ExpressionNode { content } => {
1929                push_input_field(&content.transform_attributes);
1930                for row in content.expressions.iter() {
1931                    if !row.key.is_empty() && !row.value.is_empty() {
1932                        sites.push(GraphExpressionSite {
1933                            target: CursorTarget::Expression { id: row.id.clone() },
1934                            expression_id: Some(row.id.clone()),
1935                            source: row.value.clone(),
1936                            kind: ExpressionKind::Standard,
1937                        });
1938                    }
1939                }
1940            }
1941            DecisionNodeKind::DecisionTableNode { content } => {
1942                push_input_field(&content.transform_attributes);
1943                for col in content.inputs.iter() {
1944                    if let Some(field) = &col.field {
1945                        sites.push(GraphExpressionSite {
1946                            target: CursorTarget::DecisionTableHead {
1947                                col: col.id.clone(),
1948                            },
1949                            expression_id: Some(col.id.clone()),
1950                            source: field.clone(),
1951                            kind: ExpressionKind::Standard,
1952                        });
1953                    }
1954                }
1955                for (row_idx, rule) in content.rules.iter().enumerate() {
1956                    let row_key = Self::row_key(rule, row_idx);
1957                    for col in content.inputs.iter() {
1958                        let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
1959                            continue;
1960                        };
1961                        let kind = if col.field.is_some() {
1962                            ExpressionKind::Unary
1963                        } else {
1964                            ExpressionKind::Standard
1965                        };
1966                        sites.push(GraphExpressionSite {
1967                            target: CursorTarget::DecisionTableCell {
1968                                row: row_key.clone(),
1969                                col: col.id.clone(),
1970                            },
1971                            expression_id: Some(col.id.clone()),
1972                            source: cell.clone(),
1973                            kind,
1974                        });
1975                    }
1976                    for col in content.outputs.iter() {
1977                        if let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) {
1978                            sites.push(GraphExpressionSite {
1979                                target: CursorTarget::DecisionTableCell {
1980                                    row: row_key.clone(),
1981                                    col: col.id.clone(),
1982                                },
1983                                expression_id: Some(col.id.clone()),
1984                                source: cell.clone(),
1985                                kind: ExpressionKind::Standard,
1986                            });
1987                        }
1988                    }
1989                }
1990            }
1991            DecisionNodeKind::SwitchNode { content } => {
1992                for statement in content.statements.iter() {
1993                    if !statement.condition.is_empty() {
1994                        sites.push(GraphExpressionSite {
1995                            target: CursorTarget::Expression {
1996                                id: statement.id.clone(),
1997                            },
1998                            expression_id: Some(statement.id.clone()),
1999                            source: statement.condition.clone(),
2000                            kind: ExpressionKind::Standard,
2001                        });
2002                    }
2003                }
2004            }
2005            DecisionNodeKind::DecisionNode { content } => {
2006                push_input_field(&content.transform_attributes);
2007            }
2008            _ => {}
2009        }
2010        sites
2011    }
2012
2013    pub(crate) fn row_key(rule: &ahash::HashMap<Arc<str>, Arc<str>>, row_idx: usize) -> Arc<str> {
2014        rule.get("_id")
2015            .cloned()
2016            .unwrap_or_else(|| Arc::from(row_idx.to_string()))
2017    }
2018
2019    pub(crate) fn scope_with(base: &VariableType, extras: &[(&str, VariableType)]) -> VariableType {
2020        let mut opened = base.shallow_clone();
2021        while let VariableType::Nullable(inner) = opened {
2022            opened = inner.as_ref().shallow_clone();
2023        }
2024        if matches!(opened, VariableType::Any) {
2025            opened = VariableType::empty_object();
2026        }
2027        let VariableType::Object(fields) = &opened else {
2028            return opened;
2029        };
2030        let mut extended = fields.borrow().clone();
2031        for (key, value) in extras {
2032            extended.insert(Rc::from(*key), value.shallow_clone());
2033        }
2034        VariableType::Object(Rc::new(std::cell::RefCell::new(extended)))
2035    }
2036
2037    pub(crate) fn scope_with_nodes(base: &VariableType, nodes: &VariableType) -> VariableType {
2038        Self::scope_with(base, &[(NODES_KEY, nodes.shallow_clone())])
2039    }
2040
2041    fn sort_diagnostics(&mut self, topology: &GraphTopology) {
2042        self.diagnostics.sort_by_key(|d| {
2043            d.location
2044                .block_id
2045                .as_ref()
2046                .and_then(|id| topology.node_index.get(id).copied())
2047                .map_or((0, 0), |idx| (1, idx))
2048        });
2049    }
2050}