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 declared = self.declared_output_type(node, col);
926            let mut cell_types: Vec<VariableType> = Vec::new();
927            let mut has_null_cell = false;
928            for (row_idx, rule) in content.rules.iter().enumerate() {
929                let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
930                    continue;
931                };
932                let target = CursorTarget::DecisionTableCell {
933                    row: Self::row_key(rule, row_idx),
934                    col: col.id.clone(),
935                };
936                let resolved = self.check_expression(
937                    &node.id,
938                    Some(col.id.clone()),
939                    Some(target.clone()),
940                    cell,
941                    ExpressionKind::Standard,
942                    &base_scope,
943                );
944                has_null_cell |= resolved.is_null();
945                match &declared {
946                    Some(expected) => {
947                        if !resolved.is_null() && !resolved.satisfies(expected) {
948                            self.diagnostics.push(Diagnostic::error(
949                                DiagnosticCode::TypeMismatch,
950                                DiagnosticLocation::expression(
951                                    self.path.clone(),
952                                    node.id.clone(),
953                                    col.id.clone(),
954                                    None,
955                                )
956                                .with_target(target),
957                                format!("output cell must be `{expected}`, got `{resolved}`"),
958                            ));
959                        }
960                    }
961                    None => cell_types.push(resolved),
962                }
963            }
964            if declared.is_none() {
965                if let Some(values) = DictionaryCandidate::from_const_cells(&cell_types) {
966                    self.diagnostics.push(Diagnostic::hint(
967                        DiagnosticCode::PreferDictionary,
968                        DiagnosticLocation::expression(
969                            self.path.clone(),
970                            node.id.clone(),
971                            col.id.clone(),
972                            None,
973                        )
974                        .with_target(CursorTarget::DecisionTableHead {
975                            col: col.id.clone(),
976                        }),
977                        format!(
978                            "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",
979                            col.field,
980                            DictionaryCandidate::format_values(&values),
981                            col.field
982                        ),
983                    ));
984                }
985            }
986            let has_empty_cell = content
987                .rules
988                .iter()
989                .any(|rule| rule.get(&col.id).is_none_or(|c| c.is_empty()));
990            let mut merged = match &declared {
991                Some(expected) => expected.shallow_clone(),
992                None => {
993                    let Some(merged) = cell_types
994                        .iter()
995                        .map(VariableType::shallow_clone)
996                        .reduce(|acc, t| acc.merge(&t))
997                    else {
998                        continue;
999                    };
1000                    merged
1001                }
1002            };
1003            if has_empty_cell || (has_null_cell && declared.is_some()) {
1004                merged = super::wrap_optional(merged);
1005            }
1006            if declared.is_none()
1007                && matches!(merged, VariableType::Any)
1008                && cell_types.len() > 1
1009                && !cell_types.iter().any(|t| matches!(t, VariableType::Any))
1010            {
1011                self.diagnostics.push(Diagnostic::error(
1012                    DiagnosticCode::TypeMismatch,
1013                    DiagnosticLocation::expression(
1014                        self.path.clone(),
1015                        node.id.clone(),
1016                        col.id.clone(),
1017                        None,
1018                    )
1019                    .with_target(CursorTarget::DecisionTableHead {
1020                        col: col.id.clone(),
1021                    }),
1022                    format!(
1023                        "'{}' has incompatible types: {}",
1024                        col.field,
1025                        cell_types
1026                            .iter()
1027                            .map(|t| format!("`{t}`"))
1028                            .collect::<Vec<_>>()
1029                            .join(", ")
1030                    ),
1031                ));
1032            }
1033            output.insert_at_path(&col.field, &merged, true);
1034        }
1035
1036        match content.hit_policy {
1037            DecisionTableHitPolicy::First => {
1038                if self.table_covered(content, &input_field_types) {
1039                    output
1040                } else if content.transform_attributes.pass_through {
1041                    if let VariableType::Object(fields) = &output {
1042                        let mut map = fields.borrow_mut();
1043                        let keys: Vec<Rc<str>> = map.keys().cloned().collect();
1044                        for key in keys {
1045                            if let Some(current) = map.get(&key).map(VariableType::shallow_clone) {
1046                                map.insert(key, super::wrap_optional(current));
1047                            }
1048                        }
1049                    }
1050                    output
1051                } else {
1052                    VariableType::Nullable(Rc::new(output))
1053                }
1054            }
1055            DecisionTableHitPolicy::Collect => output.array(),
1056        }
1057    }
1058
1059    fn declared_output_type(
1060        &mut self,
1061        node: &DecisionNode,
1062        col: &DecisionTableOutputField,
1063    ) -> Option<VariableType> {
1064        let head = CursorTarget::DecisionTableHead {
1065            col: col.id.clone(),
1066        };
1067        let declared = match Self::parse_declared_column(col.column_type.as_deref()) {
1068            Ok(declared) => declared?,
1069            Err(message) => {
1070                self.diagnostics.push(Diagnostic::error(
1071                    DiagnosticCode::TypeMismatch,
1072                    DiagnosticLocation::expression(
1073                        self.path.clone(),
1074                        node.id.clone(),
1075                        col.id.clone(),
1076                        None,
1077                    )
1078                    .with_target(head),
1079                    message,
1080                ));
1081                return None;
1082            }
1083        };
1084        let resolved = declared.resolve(&self.dictionary_types);
1085        if resolved.is_none() {
1086            self.diagnostics.push(Diagnostic::error(
1087                DiagnosticCode::TypeMismatch,
1088                DiagnosticLocation::expression(
1089                    self.path.clone(),
1090                    node.id.clone(),
1091                    col.id.clone(),
1092                    None,
1093                )
1094                .with_target(head),
1095                format!(
1096                    "unknown output type '{declared}': no dictionary with that name is in scope"
1097                ),
1098            ));
1099        }
1100        resolved
1101    }
1102
1103    fn parse_declared_column(column_type: Option<&str>) -> Result<Option<DeclaredType>, String> {
1104        DeclaredType::parse(column_type.unwrap_or(""))
1105    }
1106
1107    pub(crate) fn output_expected(
1108        content: &DecisionTableContent,
1109        col_id: &str,
1110        dictionaries: &HashMap<Arc<str>, VariableType>,
1111    ) -> Option<VariableType> {
1112        let column = content.outputs.iter().find(|c| c.id.as_ref() == col_id)?;
1113        let declared = Self::parse_declared_column(column.column_type.as_deref()).ok()??;
1114        declared.resolve(dictionaries)
1115    }
1116
1117    fn table_covered(
1118        &self,
1119        content: &DecisionTableContent,
1120        input_field_types: &HashMap<Arc<str>, VariableType>,
1121    ) -> bool {
1122        if content.rules.is_empty() {
1123            return false;
1124        }
1125        let row_is_live = |rule: &ahash::HashMap<Arc<str>, Arc<str>>| {
1126            content.inputs.iter().all(|ic| rule.contains_key(&ic.id))
1127                && content.outputs.iter().all(|oc| rule.contains_key(&oc.id))
1128        };
1129        let row_is_catch_all = |rule: &ahash::HashMap<Arc<str>, Arc<str>>| {
1130            row_is_live(rule)
1131                && content
1132                    .inputs
1133                    .iter()
1134                    .all(|ic| rule.get(&ic.id).is_some_and(|c| c.is_empty()))
1135        };
1136        if content.rules.iter().any(row_is_catch_all) {
1137            return true;
1138        }
1139
1140        let intellisense = self.db.graph_intellisense();
1141        let mut groups: HashMap<Arc<str>, Vec<ArmTest>> = HashMap::new();
1142        for rule in content.rules.iter() {
1143            if !row_is_live(rule) {
1144                continue;
1145            }
1146            let mut constrained = content
1147                .inputs
1148                .iter()
1149                .filter(|ic| rule.get(&ic.id).is_some_and(|c| !c.is_empty()));
1150            let (Some(column), None) = (constrained.next(), constrained.next()) else {
1151                continue;
1152            };
1153            if column.field.is_none() {
1154                continue;
1155            }
1156            let Some(cell) = rule.get(&column.id) else {
1157                continue;
1158            };
1159            groups
1160                .entry(column.id.clone())
1161                .or_default()
1162                .push(IntelliSenseSource::cell_test(
1163                    &mut intellisense.borrow_mut(),
1164                    cell,
1165                ));
1166        }
1167        groups.iter().any(|(col_id, tests)| {
1168            input_field_types
1169                .get(col_id)
1170                .is_some_and(|t| DecisionTableIr::cells_cover(tests, t))
1171        })
1172    }
1173
1174    fn check_output_schema(
1175        &mut self,
1176        node: &DecisionNode,
1177        actual: &VariableType,
1178        expected: &VariableType,
1179    ) {
1180        let VariableType::Object(expected_fields) = expected else {
1181            return;
1182        };
1183        let (actual_base, _) = actual.unwrap_nullable();
1184        let VariableType::Object(actual_fields) = actual_base else {
1185            return;
1186        };
1187        let mut keys: Vec<Rc<str>> = expected_fields.borrow().keys().cloned().collect();
1188        keys.sort();
1189        for key in keys {
1190            let Some(expected_type) = expected_fields.borrow().get(&key).cloned() else {
1191                continue;
1192            };
1193            let actual_type = actual_fields.borrow().get(&key).cloned();
1194            match actual_type {
1195                None => {
1196                    let (inner, optional) = expected_type.unwrap_nullable();
1197                    if !optional && !matches!(inner, VariableType::Any | VariableType::Null) {
1198                        self.diagnostics.push(Diagnostic::error(
1199                            DiagnosticCode::TypeMismatch,
1200                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1201                            format!(
1202                                "output schema requires property '{key}' of type `{inner}`, but it is never produced"
1203                            ),
1204                        ));
1205                    }
1206                }
1207                Some(actual_type) => {
1208                    if !actual_type.satisfies(&expected_type) {
1209                        self.diagnostics.push(Diagnostic::error(
1210                            DiagnosticCode::TypeMismatch,
1211                            DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1212                            format!(
1213                                "output property '{key}' has type `{actual_type}`, but the output schema expects `{expected_type}`"
1214                            ),
1215                        ));
1216                    }
1217                }
1218            }
1219        }
1220    }
1221
1222    fn lint_output_any(
1223        &mut self,
1224        topology: &GraphTopology,
1225        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
1226        graph_input: &VariableType,
1227    ) {
1228        if matches!(graph_input, VariableType::Any) {
1229            return;
1230        }
1231        if self
1232            .diagnostics
1233            .iter()
1234            .any(|d| d.severity == Severity::Error)
1235        {
1236            return;
1237        }
1238        let Some(reachable) = Self::reachable_from_inputs(self.content, topology) else {
1239            return;
1240        };
1241        let Some(order) = &topology.order else {
1242            return;
1243        };
1244        let mut input_any = Vec::new();
1245        Self::collect_any_paths(graph_input, String::new(), &mut input_any);
1246        let mut seen: HashSet<String> = HashSet::default();
1247        for &idx in order {
1248            let node = &self.content.nodes[idx];
1249            if !reachable[idx] || matches!(node.kind, DecisionNodeKind::InputNode { .. }) {
1250                continue;
1251            }
1252            let Some(analysis) = nodes.get(&node.id) else {
1253                continue;
1254            };
1255            if analysis.unchecked || analysis.opaque || analysis.open {
1256                continue;
1257            }
1258            if matches!(analysis.output, VariableType::Any) {
1259                self.diagnostics.push(Diagnostic::error(
1260                    DiagnosticCode::ImplicitAny,
1261                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1262                    format!(
1263                        "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",
1264                        node.name
1265                    ),
1266                ));
1267                continue;
1268            }
1269            let mut any_paths = Vec::new();
1270            Self::collect_any_paths(&analysis.output, String::new(), &mut any_paths);
1271            any_paths.retain(|path| !input_any.contains(path) && !seen.contains(path));
1272            for path in any_paths.iter().take(8) {
1273                self.diagnostics.push(Diagnostic::error(
1274                    DiagnosticCode::ImplicitAny,
1275                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1276                    format!(
1277                        "output `{path}` resolves to `any` — everything reading it degrades to `any`; give it a concrete type where it is produced"
1278                    ),
1279                ));
1280            }
1281            seen.extend(any_paths);
1282        }
1283    }
1284
1285    fn lint_unreachable(&mut self, topology: &GraphTopology) {
1286        let input_indices: Vec<usize> = self
1287            .content
1288            .nodes
1289            .iter()
1290            .enumerate()
1291            .filter(|(_, node)| matches!(node.kind, DecisionNodeKind::InputNode { .. }))
1292            .map(|(idx, _)| idx)
1293            .collect();
1294        if input_indices.is_empty() {
1295            return;
1296        }
1297        let mut reachable = vec![false; self.content.nodes.len()];
1298        let mut stack = input_indices;
1299        while let Some(idx) = stack.pop() {
1300            if std::mem::replace(&mut reachable[idx], true) {
1301                continue;
1302            }
1303            stack.extend(topology.outgoing[idx].iter().copied());
1304        }
1305        for (idx, node) in self.content.nodes.iter().enumerate() {
1306            if !reachable[idx] {
1307                self.diagnostics.push(Diagnostic::hint(
1308                    DiagnosticCode::UnreachableNode,
1309                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1310                    format!("node '{}' is not reachable from the input node", node.name),
1311                ));
1312            }
1313        }
1314    }
1315
1316    fn lint_expressions(&mut self) {
1317        let intellisense = self.db.graph_intellisense();
1318        for node in &self.content.nodes {
1319            for site in Self::node_sites(node) {
1320                if !matches!(site.kind, ExpressionKind::Standard) {
1321                    continue;
1322                }
1323                let findings = intellisense
1324                    .borrow_mut()
1325                    .with_ast(&site.source, false, |root, metadata| {
1326                        RedundantParentheses::scan(root, metadata)
1327                    })
1328                    .unwrap_or_default();
1329                for (span, inner_span) in findings {
1330                    let message = match inner_span {
1331                        Some(inner) => format!(
1332                            "unnecessary parentheses around '{}'",
1333                            AstOps::display_snippet(&site.source, inner)
1334                        ),
1335                        None => "unnecessary parentheses".to_string(),
1336                    };
1337                    let location = DiagnosticLocation {
1338                        policy_path: self.path.clone(),
1339                        block_id: Some(node.id.clone()),
1340                        expression_id: site.expression_id.clone(),
1341                        span,
1342                        target: Some(site.target.clone()),
1343                    };
1344                    self.diagnostics.push(Diagnostic::hint(
1345                        DiagnosticCode::RedundantParentheses,
1346                        location,
1347                        message,
1348                    ));
1349                }
1350            }
1351        }
1352    }
1353
1354    fn check_switch(
1355        &mut self,
1356        node: &DecisionNode,
1357        content: &SwitchNodeContent,
1358        scope: &VariableType,
1359    ) -> HashMap<Arc<str>, VariableType> {
1360        let condition_scope = Self::scope_with_nodes(scope, &self.nodes_scope.shallow_clone());
1361        let first_hit = matches!(content.hit_policy, SwitchStatementHitPolicy::First);
1362        let mut branches: HashMap<Arc<str>, VariableType> = HashMap::new();
1363        let mut prior_tests: Vec<ArmTest> = Vec::new();
1364
1365        for statement in content.statements.iter() {
1366            let test = if statement.condition.is_empty() {
1367                ArmTest::Default
1368            } else {
1369                let resolved = self.check_expression(
1370                    &node.id,
1371                    Some(statement.id.clone()),
1372                    None,
1373                    &statement.condition,
1374                    ExpressionKind::Standard,
1375                    &condition_scope,
1376                );
1377                if !matches!(resolved, VariableType::Bool | VariableType::Any) {
1378                    self.diagnostics.push(Diagnostic::error(
1379                        DiagnosticCode::TypeMismatch,
1380                        DiagnosticLocation::expression(
1381                            self.path.clone(),
1382                            node.id.clone(),
1383                            statement.id.clone(),
1384                            None,
1385                        ),
1386                        format!("switch condition must return a boolean, got `{resolved}`"),
1387                    ));
1388                }
1389                let intellisense = self.db.graph_intellisense();
1390                let mut is = intellisense.borrow_mut();
1391                IntelliSenseSource::arm_test(&mut is, &statement.condition)
1392            };
1393
1394            let mut narrowed = scope.shallow_clone();
1395            if first_hit {
1396                for prior in &prior_tests {
1397                    narrowed = Self::narrow_negative(&narrowed, prior);
1398                }
1399            }
1400            narrowed = Self::narrow_positive(&narrowed, &test);
1401            branches.insert(statement.id.clone(), narrowed);
1402            if first_hit {
1403                prior_tests.push(test);
1404            }
1405        }
1406        branches
1407    }
1408
1409    fn narrow_positive(scope: &VariableType, test: &ArmTest) -> VariableType {
1410        match test {
1411            ArmTest::Enum { path, values } => Self::narrow_path(scope, path, |current| {
1412                let (base, _) = current.unwrap_nullable();
1413                match base {
1414                    VariableType::Enum(_, declared) => {
1415                        let retained: Vec<Rc<str>> = declared
1416                            .iter()
1417                            .filter(|d| values.iter().any(|v| v.as_ref() == d.as_ref()))
1418                            .cloned()
1419                            .collect();
1420                        match retained.len() {
1421                            0 => base.shallow_clone(),
1422                            1 => VariableType::Const(retained[0].clone()),
1423                            _ => VariableType::Enum(None, retained),
1424                        }
1425                    }
1426                    VariableType::String => match values.len() {
1427                        1 => VariableType::Const(Rc::from(values[0].as_ref())),
1428                        _ => VariableType::Enum(
1429                            None,
1430                            values.iter().map(|v| Rc::from(v.as_ref())).collect(),
1431                        ),
1432                    },
1433                    other => other.shallow_clone(),
1434                }
1435            }),
1436            ArmTest::Bool { path, .. } => Self::narrow_path(scope, path, |current| {
1437                current.unwrap_nullable().0.shallow_clone()
1438            }),
1439            ArmTest::Number { path, .. } => Self::narrow_path(scope, path, |current| {
1440                current.unwrap_nullable().0.shallow_clone()
1441            }),
1442            ArmTest::Default | ArmTest::Unrecognized => scope.shallow_clone(),
1443        }
1444    }
1445
1446    fn narrow_negative(scope: &VariableType, test: &ArmTest) -> VariableType {
1447        let ArmTest::Enum { path, values } = test else {
1448            return scope.shallow_clone();
1449        };
1450        Self::narrow_path(scope, path, |current| {
1451            let (base, nullable) = current.unwrap_nullable();
1452            let VariableType::Enum(_, declared) = base else {
1453                return current.shallow_clone();
1454            };
1455            let retained: Vec<Rc<str>> = declared
1456                .iter()
1457                .filter(|d| !values.iter().any(|v| v.as_ref() == d.as_ref()))
1458                .cloned()
1459                .collect();
1460            let narrowed = match retained.len() {
1461                0 => return current.shallow_clone(),
1462                1 => VariableType::Const(retained[0].clone()),
1463                _ => VariableType::Enum(None, retained),
1464            };
1465            if nullable {
1466                VariableType::Nullable(Rc::new(narrowed))
1467            } else {
1468                narrowed
1469            }
1470        })
1471    }
1472
1473    fn narrow_path(
1474        scope: &VariableType,
1475        path: &[Rc<str>],
1476        narrow: impl FnOnce(&VariableType) -> VariableType,
1477    ) -> VariableType {
1478        let Some(head) = path.first() else {
1479            return scope.shallow_clone();
1480        };
1481        let VariableType::Object(fields) = scope else {
1482            return scope.shallow_clone();
1483        };
1484        let map = fields.borrow();
1485        let Some(current) = map.get(head.as_ref()) else {
1486            return scope.shallow_clone();
1487        };
1488        let replaced = if path.len() == 1 {
1489            narrow(current)
1490        } else {
1491            Self::narrow_path(current, &path[1..], narrow)
1492        };
1493        let mut cloned = map.clone();
1494        drop(map);
1495        cloned.insert(head.clone(), replaced);
1496        VariableType::Object(Rc::new(std::cell::RefCell::new(cloned)))
1497    }
1498
1499    fn resolve_decision_signature(
1500        &mut self,
1501        node: &DecisionNode,
1502        content: &DecisionNodeContent,
1503    ) -> Option<GraphSignature> {
1504        match self.db.decision_signature(&content.key) {
1505            SignatureResolution::Found(signature) => Some(signature),
1506            SignatureResolution::Recursive => None,
1507            SignatureResolution::Missing => {
1508                self.diagnostics.push(Diagnostic::error(
1509                    DiagnosticCode::ImportNotFound,
1510                    DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1511                    format!(
1512                        "referenced decision '{}' was not found in the workspace",
1513                        content.key
1514                    ),
1515                ));
1516                None
1517            }
1518        }
1519    }
1520
1521    fn check_decision_input(
1522        &mut self,
1523        node: &DecisionNode,
1524        content: &DecisionNodeContent,
1525        signature: &GraphSignature,
1526        scope: &VariableType,
1527    ) {
1528        if !self.validate {
1529            return;
1530        }
1531        let VariableType::Object(expected) = &signature.input else {
1532            return;
1533        };
1534        let (scope_base, _) = scope.unwrap_nullable();
1535        let VariableType::Object(actual) = scope_base else {
1536            return;
1537        };
1538        let mut missing: Vec<(String, VariableType)> = Vec::new();
1539        let mut mismatched: Vec<(String, VariableType, VariableType)> = Vec::new();
1540        Self::diff_required(
1541            String::new(),
1542            &expected.borrow(),
1543            &actual.borrow(),
1544            &mut missing,
1545            &mut mismatched,
1546        );
1547        for (path, expected_type) in missing {
1548            self.diagnostics.push(Diagnostic::error(
1549                DiagnosticCode::TypeMismatch,
1550                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1551                format!(
1552                    "decision '{}' requires input '{path}' of type `{}`, but it is not provided",
1553                    content.key,
1554                    Self::type_sketch(&expected_type, 0)
1555                ),
1556            ));
1557        }
1558        for (path, actual_type, expected_type) in mismatched {
1559            let nullability_only = actual_type.is_nullable() && !expected_type.is_nullable() && {
1560                let (actual_inner, _) = actual_type.unwrap_nullable();
1561                actual_inner.satisfies(&expected_type)
1562            };
1563            let message = if nullability_only {
1564                format!(
1565                    "input '{path}' for decision '{}' may be null (`{actual_type}`), but a non-null `{expected_type}` is required",
1566                    content.key
1567                )
1568            } else {
1569                format!(
1570                    "input '{path}' for decision '{}' has type `{}`, but `{}` is expected",
1571                    content.key,
1572                    Self::type_sketch(&actual_type, 0),
1573                    Self::type_sketch(&expected_type, 0)
1574                )
1575            };
1576            self.diagnostics.push(Diagnostic::error(
1577                DiagnosticCode::TypeMismatch,
1578                DiagnosticLocation::block(self.path.clone(), node.id.clone()),
1579                message,
1580            ));
1581        }
1582    }
1583
1584    /// Unlike `Display`, expands object fields so two different types never print identically.
1585    fn type_sketch(variable_type: &VariableType, depth: usize) -> String {
1586        const MAX_DEPTH: usize = 3;
1587        const MAX_FIELDS: usize = 8;
1588        match variable_type {
1589            VariableType::Nullable(inner) => format!("{}?", Self::type_sketch(inner, depth)),
1590            VariableType::Array(items) => {
1591                let inner = Self::type_sketch(items, depth);
1592                if inner.ends_with('?') {
1593                    format!("({inner})[]")
1594                } else {
1595                    format!("{inner}[]")
1596                }
1597            }
1598            VariableType::Object(fields) => {
1599                let map = fields.borrow();
1600                if map.is_empty() {
1601                    return "{}".to_string();
1602                }
1603                if depth >= MAX_DEPTH {
1604                    return "object".to_string();
1605                }
1606                let mut keys: Vec<_> = map.keys().cloned().collect();
1607                keys.sort();
1608                let mut parts: Vec<String> = keys
1609                    .iter()
1610                    .take(MAX_FIELDS)
1611                    .filter_map(|key| {
1612                        map.get(key.as_ref())
1613                            .map(|field| format!("{key}: {}", Self::type_sketch(field, depth + 1)))
1614                    })
1615                    .collect();
1616                if keys.len() > MAX_FIELDS {
1617                    parts.push(format!("…+{} more", keys.len() - MAX_FIELDS));
1618                }
1619                format!("{{ {} }}", parts.join(", "))
1620            }
1621            other => other.to_string(),
1622        }
1623    }
1624
1625    fn diff_required(
1626        prefix: String,
1627        expected: &HashMap<Rc<str>, VariableType>,
1628        actual: &HashMap<Rc<str>, VariableType>,
1629        missing: &mut Vec<(String, VariableType)>,
1630        mismatched: &mut Vec<(String, VariableType, VariableType)>,
1631    ) {
1632        let mut keys: Vec<&Rc<str>> = expected.keys().collect();
1633        keys.sort();
1634        for key in keys {
1635            let expected_type = &expected[key];
1636            let path = if prefix.is_empty() {
1637                key.to_string()
1638            } else {
1639                format!("{prefix}.{key}")
1640            };
1641            let (expected_inner, optional) = expected_type.unwrap_nullable();
1642            match actual.get(key) {
1643                None => {
1644                    if !optional
1645                        && !matches!(expected_inner, VariableType::Any | VariableType::Null)
1646                    {
1647                        missing.push((path, expected_inner.shallow_clone()));
1648                    }
1649                }
1650                Some(actual_type) => {
1651                    let (actual_inner, actual_nullable) = actual_type.unwrap_nullable();
1652                    if matches!(actual_inner, VariableType::Any) {
1653                        continue;
1654                    }
1655                    if actual_nullable && !optional {
1656                        mismatched.push((
1657                            path,
1658                            actual_type.shallow_clone(),
1659                            expected_type.shallow_clone(),
1660                        ));
1661                        continue;
1662                    }
1663                    if let (VariableType::Object(e), VariableType::Object(a)) =
1664                        (expected_inner, actual_inner)
1665                    {
1666                        Self::diff_required(path, &e.borrow(), &a.borrow(), missing, mismatched);
1667                        continue;
1668                    }
1669                    if let (VariableType::Array(e_item), VariableType::Array(a_item)) =
1670                        (expected_inner, actual_inner)
1671                    {
1672                        let (e_it, item_optional) = e_item.unwrap_nullable();
1673                        let (a_it, item_nullable) = a_item.unwrap_nullable();
1674                        let item_path = format!("{path}[]");
1675                        if matches!(a_it, VariableType::Any) {
1676                            continue;
1677                        }
1678                        if item_nullable && !item_optional {
1679                            mismatched.push((
1680                                item_path,
1681                                a_item.shallow_clone(),
1682                                e_item.shallow_clone(),
1683                            ));
1684                            continue;
1685                        }
1686                        if let (VariableType::Object(e), VariableType::Object(a)) = (e_it, a_it) {
1687                            Self::diff_required(
1688                                item_path,
1689                                &e.borrow(),
1690                                &a.borrow(),
1691                                missing,
1692                                mismatched,
1693                            );
1694                            continue;
1695                        }
1696                        if !a_it.satisfies(e_it) {
1697                            mismatched.push((
1698                                item_path,
1699                                a_it.shallow_clone(),
1700                                e_it.shallow_clone(),
1701                            ));
1702                        }
1703                        continue;
1704                    }
1705                    if !actual_type.satisfies(expected_type) {
1706                        mismatched.push((
1707                            path,
1708                            actual_type.shallow_clone(),
1709                            expected_type.shallow_clone(),
1710                        ));
1711                    }
1712                }
1713            }
1714        }
1715    }
1716
1717    fn check_expression(
1718        &mut self,
1719        node_id: &Arc<str>,
1720        expression_id: Option<Arc<str>>,
1721        target: Option<CursorTarget>,
1722        source: &Arc<str>,
1723        kind: ExpressionKind,
1724        scope: &VariableType,
1725    ) -> VariableType {
1726        let intellisense = self.db.graph_intellisense();
1727        let analysis =
1728            IntelliSenseSource::analyze(&mut intellisense.borrow_mut(), source, kind, scope);
1729        for diagnostic in &analysis.diagnostics {
1730            if !self.validate
1731                && matches!(
1732                    diagnostic.source,
1733                    zen_expression::intellisense::diagnostic::DiagnosticSource::TypeCheck
1734                )
1735            {
1736                continue;
1737            }
1738            let location = DiagnosticLocation {
1739                policy_path: self.path.clone(),
1740                block_id: Some(node_id.clone()),
1741                expression_id: expression_id.clone(),
1742                span: Some(diagnostic.span),
1743                target: target.clone(),
1744            };
1745            self.diagnostics
1746                .push(Diagnostic::from_expression(diagnostic, location));
1747        }
1748        if self.validate {
1749            self.validate_read_paths(node_id, &expression_id, &target, &analysis.reads, scope);
1750        }
1751        analysis.return_type.shallow_clone()
1752    }
1753
1754    fn validate_read_paths(
1755        &mut self,
1756        node_id: &Arc<str>,
1757        expression_id: &Option<Arc<str>>,
1758        target: &Option<CursorTarget>,
1759        reads: &[zen_expression::intellisense::ReadDependency],
1760        scope: &VariableType,
1761    ) {
1762        let mut flattened = Vec::new();
1763        ReadFlattener::extend_from_deps(reads, expression_id, &mut flattened);
1764        for read in flattened {
1765            if read.unresolved || read.via_alias {
1766                continue;
1767            }
1768            let root = read.path.split('.').next().unwrap_or_default();
1769            if root.is_empty() || root.starts_with('$') {
1770                continue;
1771            }
1772            let Some(unknown) = Self::unknown_segment(scope, root) else {
1773                continue;
1774            };
1775            let location = DiagnosticLocation {
1776                policy_path: self.path.clone(),
1777                block_id: Some(node_id.clone()),
1778                expression_id: read.expression_id.clone(),
1779                span: read.span,
1780                target: target.clone(),
1781            };
1782            self.diagnostics.push(Diagnostic::error(
1783                DiagnosticCode::UndefinedVariable,
1784                location,
1785                format!("Unknown property '{unknown}'"),
1786            ));
1787        }
1788    }
1789
1790    fn unknown_segment(scope: &VariableType, path: &str) -> Option<String> {
1791        let mut current = scope.shallow_clone();
1792        let mut walked: Vec<&str> = Vec::new();
1793        for segment in path.split('.') {
1794            while let VariableType::Nullable(inner) = current {
1795                current = inner.as_ref().shallow_clone();
1796            }
1797            let VariableType::Object(fields) = &current else {
1798                return None;
1799            };
1800            walked.push(segment);
1801            let next = fields.borrow().get(segment).cloned();
1802            match next {
1803                Some(t) => current = t,
1804                None => return Some(walked.join(".")),
1805            }
1806        }
1807        None
1808    }
1809
1810    fn inferred_inputs(
1811        &self,
1812        topology: &GraphTopology,
1813        nodes: &HashMap<Arc<str>, GraphNodeAnalysis>,
1814        graph_input: &VariableType,
1815    ) -> Vec<Arc<str>> {
1816        if !matches!(graph_input, VariableType::Any) {
1817            return Vec::new();
1818        }
1819        let Some(order) = &topology.order else {
1820            return Vec::new();
1821        };
1822
1823        let input_successors: HashSet<usize> = order
1824            .iter()
1825            .filter(|&&idx| {
1826                matches!(
1827                    self.content.nodes[idx].kind,
1828                    DecisionNodeKind::InputNode { .. }
1829                )
1830            })
1831            .flat_map(|&idx| topology.outgoing[idx].iter().copied())
1832            .collect();
1833
1834        let mut paths: Vec<Arc<str>> = Vec::new();
1835        for &idx in &input_successors {
1836            let node = &self.content.nodes[idx];
1837            let provided: HashSet<Rc<str>> = topology.incoming[idx]
1838                .iter()
1839                .filter_map(|(pred, _)| {
1840                    let pred_node = &self.content.nodes[*pred];
1841                    if matches!(pred_node.kind, DecisionNodeKind::InputNode { .. }) {
1842                        return None;
1843                    }
1844                    nodes.get(&pred_node.id)
1845                })
1846                .filter_map(|analysis| match &analysis.output {
1847                    VariableType::Object(fields) => {
1848                        Some(fields.borrow().keys().cloned().collect::<Vec<Rc<str>>>())
1849                    }
1850                    _ => None,
1851                })
1852                .flatten()
1853                .collect();
1854            paths.extend(self.node_read_paths(node, &provided));
1855        }
1856        paths.sort();
1857        paths.dedup();
1858        paths
1859    }
1860
1861    fn node_read_paths(&self, node: &DecisionNode, provided: &HashSet<Rc<str>>) -> Vec<Arc<str>> {
1862        let intellisense = self.db.graph_intellisense();
1863        let mut is = intellisense.borrow_mut();
1864        let mut reads = Vec::new();
1865        for site in Self::node_sites(node) {
1866            let deps = match site.kind {
1867                ExpressionKind::Standard => is.reads(&site.source),
1868                ExpressionKind::Unary => is.reads_unary(&site.source),
1869            };
1870            ReadFlattener::extend_from_deps(&deps, &None, &mut reads);
1871        }
1872        reads
1873            .into_iter()
1874            .filter(|read| !read.unresolved && !read.via_alias)
1875            .filter_map(|read| {
1876                let root = read
1877                    .path
1878                    .split_once('.')
1879                    .map_or(read.path.as_ref(), |(root, _)| root);
1880                let external = !root.starts_with('$') && !provided.contains(root);
1881                external.then_some(read.path)
1882            })
1883            .collect()
1884    }
1885
1886    pub(crate) fn node_sites(node: &DecisionNode) -> Vec<GraphExpressionSite> {
1887        let mut sites: Vec<GraphExpressionSite> = Vec::new();
1888        let mut push_input_field = |attributes: &TransformAttributes| {
1889            if let Some(field) = &attributes.input_field {
1890                sites.push(GraphExpressionSite {
1891                    target: CursorTarget::TransformInput,
1892                    expression_id: None,
1893                    source: field.clone(),
1894                    kind: ExpressionKind::Standard,
1895                });
1896            }
1897        };
1898        match &node.kind {
1899            DecisionNodeKind::ExpressionNode { content } => {
1900                push_input_field(&content.transform_attributes);
1901                for row in content.expressions.iter() {
1902                    if !row.key.is_empty() && !row.value.is_empty() {
1903                        sites.push(GraphExpressionSite {
1904                            target: CursorTarget::Expression { id: row.id.clone() },
1905                            expression_id: Some(row.id.clone()),
1906                            source: row.value.clone(),
1907                            kind: ExpressionKind::Standard,
1908                        });
1909                    }
1910                }
1911            }
1912            DecisionNodeKind::DecisionTableNode { content } => {
1913                push_input_field(&content.transform_attributes);
1914                for col in content.inputs.iter() {
1915                    if let Some(field) = &col.field {
1916                        sites.push(GraphExpressionSite {
1917                            target: CursorTarget::DecisionTableHead {
1918                                col: col.id.clone(),
1919                            },
1920                            expression_id: Some(col.id.clone()),
1921                            source: field.clone(),
1922                            kind: ExpressionKind::Standard,
1923                        });
1924                    }
1925                }
1926                for (row_idx, rule) in content.rules.iter().enumerate() {
1927                    let row_key = Self::row_key(rule, row_idx);
1928                    for col in content.inputs.iter() {
1929                        let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) else {
1930                            continue;
1931                        };
1932                        let kind = if col.field.is_some() {
1933                            ExpressionKind::Unary
1934                        } else {
1935                            ExpressionKind::Standard
1936                        };
1937                        sites.push(GraphExpressionSite {
1938                            target: CursorTarget::DecisionTableCell {
1939                                row: row_key.clone(),
1940                                col: col.id.clone(),
1941                            },
1942                            expression_id: Some(col.id.clone()),
1943                            source: cell.clone(),
1944                            kind,
1945                        });
1946                    }
1947                    for col in content.outputs.iter() {
1948                        if let Some(cell) = rule.get(&col.id).filter(|c| !c.is_empty()) {
1949                            sites.push(GraphExpressionSite {
1950                                target: CursorTarget::DecisionTableCell {
1951                                    row: row_key.clone(),
1952                                    col: col.id.clone(),
1953                                },
1954                                expression_id: Some(col.id.clone()),
1955                                source: cell.clone(),
1956                                kind: ExpressionKind::Standard,
1957                            });
1958                        }
1959                    }
1960                }
1961            }
1962            DecisionNodeKind::SwitchNode { content } => {
1963                for statement in content.statements.iter() {
1964                    if !statement.condition.is_empty() {
1965                        sites.push(GraphExpressionSite {
1966                            target: CursorTarget::Expression {
1967                                id: statement.id.clone(),
1968                            },
1969                            expression_id: Some(statement.id.clone()),
1970                            source: statement.condition.clone(),
1971                            kind: ExpressionKind::Standard,
1972                        });
1973                    }
1974                }
1975            }
1976            DecisionNodeKind::DecisionNode { content } => {
1977                push_input_field(&content.transform_attributes);
1978            }
1979            _ => {}
1980        }
1981        sites
1982    }
1983
1984    pub(crate) fn row_key(rule: &ahash::HashMap<Arc<str>, Arc<str>>, row_idx: usize) -> Arc<str> {
1985        rule.get("_id")
1986            .cloned()
1987            .unwrap_or_else(|| Arc::from(row_idx.to_string()))
1988    }
1989
1990    pub(crate) fn scope_with(base: &VariableType, extras: &[(&str, VariableType)]) -> VariableType {
1991        let mut opened = base.shallow_clone();
1992        while let VariableType::Nullable(inner) = opened {
1993            opened = inner.as_ref().shallow_clone();
1994        }
1995        if matches!(opened, VariableType::Any) {
1996            opened = VariableType::empty_object();
1997        }
1998        let VariableType::Object(fields) = &opened else {
1999            return opened;
2000        };
2001        let mut extended = fields.borrow().clone();
2002        for (key, value) in extras {
2003            extended.insert(Rc::from(*key), value.shallow_clone());
2004        }
2005        VariableType::Object(Rc::new(std::cell::RefCell::new(extended)))
2006    }
2007
2008    pub(crate) fn scope_with_nodes(base: &VariableType, nodes: &VariableType) -> VariableType {
2009        Self::scope_with(base, &[(NODES_KEY, nodes.shallow_clone())])
2010    }
2011
2012    fn sort_diagnostics(&mut self, topology: &GraphTopology) {
2013        self.diagnostics.sort_by_key(|d| {
2014            d.location
2015                .block_id
2016                .as_ref()
2017                .and_then(|id| topology.node_index.get(id).copied())
2018                .map_or((0, 0), |idx| (1, idx))
2019        });
2020    }
2021}