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 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 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) = ¤t 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}