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