1use std::collections::HashMap;
12
13use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
14use crate::eval::{
15 eval_binary, eval_unary, measure_value, value_kind_name, Accountant, BudgetCx, BudgetKind,
16 Engine, EvalError,
17};
18use crate::heal::{best_key_match, coerce_string_to, normalized_levenshtein, sentinel_meaning};
19use crate::ir::{AnnotationKind, Module, Node, NodeId, SchemaField, Value};
20use crate::parser::PathSeg;
21use crate::types::ActiveType;
22
23impl Engine {
24 pub(crate) fn validate_schema(
29 &self,
30 module: &Module,
31 values: &HashMap<NodeId, Value>,
32 result: &mut Value,
33 diagnostics: &mut Vec<Diagnostic>,
34 cx: BudgetCx<'_>,
35 ) -> Result<(), EvalError> {
36 let Some(schema_id) = module.schema else {
37 return Ok(());
38 };
39 let mut validator = Validator {
40 module,
41 values,
42 diagnostics: Vec::new(),
43 regexes: HashMap::new(),
44 schema_bugs: std::collections::HashSet::new(),
45 resilient: self.resilient,
46 fills: Vec::new(),
47 renames: Vec::new(),
48 coercions: Vec::new(),
49 deadline: cx.deadline,
50 wall_limit: cx.wall_limit,
51 ticks: 0,
52 account: cx.account,
53 };
54 validator.validate_node(schema_id, module.root, result, &mut Vec::new())?;
55 if self.resilient {
56 for _ in 0..1024 {
65 if validator.renames.is_empty()
66 && validator.coercions.is_empty()
67 && validator.fills.is_empty()
68 {
69 break;
70 }
71 validator.apply_renames(result)?;
72 validator.apply_coercions(result)?;
73 validator.apply_fills(result)?;
74 }
75 for req in std::mem::take(&mut validator.renames) {
76 validator.rename_fallback(&req);
77 }
78 for req in std::mem::take(&mut validator.coercions) {
79 validator.coercion_fallback(&req);
80 }
81 for fill in std::mem::take(&mut validator.fills) {
82 let path = render_path(&fill.segments);
83 validator.push(
84 "T004",
85 format!(
86 "schema violation at '{}': missing required key (schema default could not be applied)",
87 display_path(&path)
88 ),
89 &path,
90 (fill.span, fill.file),
91 );
92 }
93 }
94 if !validator.diagnostics.is_empty() && !self.resilient {
95 if let Some(end) = cx.deadline {
99 if std::time::Instant::now() >= end {
100 return Err(EvalError::budget_exceeded(
101 BudgetKind::WallClock,
102 cx.wall_limit,
103 module,
104 None,
105 ));
106 }
107 }
108 return Err(EvalError::ValidationFailed {
109 diagnostics: validator.diagnostics,
110 });
111 }
112 diagnostics.extend(validator.diagnostics);
113 Ok(())
114 }
115
116 pub(crate) fn validate_annotations(
127 &self,
128 module: &Module,
129 values: &HashMap<NodeId, Value>,
130 result: &Value,
131 diagnostics: &mut Vec<Diagnostic>,
132 deadline: Option<std::time::Instant>,
133 wall_limit: u64,
134 ) -> Result<(), EvalError> {
135 let mut items: Vec<(NodeId, &crate::ir::Annotation)> = module
136 .annotations
137 .iter()
138 .flat_map(|(id, anns)| anns.iter().map(|a| (*id, a)))
139 .collect();
140 items.sort_by(|a, b| a.1.path.cmp(&b.1.path).then_with(|| a.0 .0.cmp(&b.0 .0)));
141 let bindings: std::collections::HashSet<NodeId> =
145 module.bindings.values().copied().collect();
146 let mut overlay = values.clone();
151 if let Some(root) = module.root {
152 overlay_values(module, root, result, &mut overlay);
153 }
154 let mut local: Vec<Diagnostic> = Vec::new();
155 for (node_id, ann) in items {
156 if let Some(end) = deadline {
158 if std::time::Instant::now() >= end {
159 return Err(EvalError::budget_exceeded(
160 BudgetKind::WallClock,
161 wall_limit,
162 module,
163 Some(node_id),
164 ));
165 }
166 }
167 let AnnotationKind::Constraint { source, expr } = &ann.kind else {
168 continue;
169 };
170 let value = lookup_result_path(result, &ann.path).or_else(|| {
171 bindings
172 .contains(&node_id)
173 .then(|| values.get(&node_id))
174 .flatten()
175 });
176 let Some(value) = value else {
177 continue;
178 };
179 let span = module
180 .node_spans
181 .get(&node_id)
182 .cloned()
183 .or(Some(ann.span.clone()));
184 let file = module.node_files.get(&node_id).cloned();
185 let shown_source = render_source(module, source, *expr);
186 match predicate_value(module, &overlay, values, *expr) {
187 Some(Value::Bool(true)) => {}
188 Some(Value::Bool(false)) => local.push(Diagnostic {
189 severity: Severity::Error,
190 code: "T006".into(),
191 message: format!(
192 "constraint violation at '{}': {shown_source} does not hold (actual: {})",
193 display_path(&ann.path),
194 render_constrained(value),
195 ),
196 path: Some(ann.path.clone()),
197 span,
198 repair: None,
199 confidence: 1.0,
200 file,
201 }),
202 Some(other) => local.push(Diagnostic {
203 severity: Severity::Error,
204 code: "T006".into(),
205 message: format!(
206 "constraint violation at '{}': {shown_source} must evaluate to a boolean, got {}",
207 display_path(&ann.path),
208 value_kind_name(&other),
209 ),
210 path: Some(ann.path.clone()),
211 span,
212 repair: None,
213 confidence: 1.0,
214 file,
215 }),
216 None => {}
220 }
221 }
222 if !local.is_empty() && !self.resilient {
223 if let Some(end) = deadline {
224 if std::time::Instant::now() >= end {
225 return Err(EvalError::budget_exceeded(
226 BudgetKind::WallClock,
227 wall_limit,
228 module,
229 None,
230 ));
231 }
232 }
233 return Err(EvalError::ValidationFailed { diagnostics: local });
234 }
235 diagnostics.extend(local);
236 Ok(())
237 }
238}
239
240struct FillRequest {
245 segments: Vec<PathSeg>,
246 field_id: NodeId,
247 default_id: NodeId,
248 span: Option<Span>,
249 file: Option<String>,
250}
251
252struct RenameRequest {
258 segments: Vec<PathSeg>,
259 from: String,
260 to: String,
261 field_id: NodeId,
262 value_id: Option<NodeId>,
263 span: Option<Span>,
264 file: Option<String>,
265 confidence: f64,
266}
267
268struct CoerceRequest {
272 segments: Vec<PathSeg>,
273 field_id: NodeId,
274 value_id: Option<NodeId>,
275 coerced: Value,
276 span: Option<Span>,
277 file: Option<String>,
278}
279
280struct Validator<'a> {
281 module: &'a Module,
282 values: &'a HashMap<NodeId, Value>,
283 diagnostics: Vec<Diagnostic>,
284 regexes: HashMap<NodeId, Option<regex::Regex>>,
288 schema_bugs: std::collections::HashSet<(NodeId, String)>,
292 resilient: bool,
293 fills: Vec<FillRequest>,
296 renames: Vec<RenameRequest>,
301 coercions: Vec<CoerceRequest>,
302 deadline: Option<std::time::Instant>,
306 wall_limit: u64,
307 ticks: u64,
308 account: &'a mut Accountant,
312}
313
314impl<'a> Validator<'a> {
315 fn validate_node(
322 &mut self,
323 schema_id: NodeId,
324 value_id: Option<NodeId>,
325 value: &Value,
326 segments: &mut Vec<PathSeg>,
327 ) -> Result<(), EvalError> {
328 self.tick(schema_id)?;
329 match self.module.get(schema_id).cloned() {
330 Some(Node::SchemaField { field }) => {
331 self.validate_field(schema_id, &field, value_id, value, segments);
332 }
333 Some(Node::SchemaTable { fields, closed }) => {
334 self.validate_table(schema_id, &fields, closed, value_id, value, segments)?;
335 }
336 Some(Node::SchemaArray { element }) => {
337 self.validate_array(schema_id, element, value_id, value, segments)?;
338 }
339 _ => {}
341 }
342 Ok(())
343 }
344
345 fn tick(&mut self, node: NodeId) -> Result<(), EvalError> {
348 self.ticks += 1;
349 if self.ticks % 64 != 0 {
350 return Ok(());
351 }
352 if let Some(end) = self.deadline {
353 if std::time::Instant::now() >= end {
354 return Err(EvalError::budget_exceeded(
355 BudgetKind::WallClock,
356 self.wall_limit,
357 self.module,
358 Some(node),
359 ));
360 }
361 }
362 Ok(())
363 }
364
365 fn validate_table(
366 &mut self,
367 schema_id: NodeId,
368 fields: &indexmap::IndexMap<String, NodeId>,
369 closed: bool,
370 value_id: Option<NodeId>,
371 value: &Value,
372 segments: &mut Vec<PathSeg>,
373 ) -> Result<(), EvalError> {
374 let Value::Table(map) = value else {
375 let path = render_path(segments);
376 self.push(
377 "T002",
378 format!(
379 "schema violation at '{}': expected Table, got {}",
380 display_path(&path),
381 value_kind_name(value)
382 ),
383 &path,
384 self.spans(value_id, schema_id),
385 );
386 return Ok(());
387 };
388 let arena_children = match value_id.and_then(|id| self.module.get(id)) {
392 Some(Node::Table { fields }) => Some(fields.clone()),
393 _ => None,
394 };
395 for (name, field_id) in fields {
396 segments.push(PathSeg::Key(name.clone()));
397 match map.get(name) {
398 None => self.check_missing(*field_id, segments),
399 Some(v) => {
400 let child_id = arena_children
401 .as_ref()
402 .and_then(|children| children.get(name).copied());
403 self.validate_node(*field_id, child_id, v, segments)?;
404 }
405 }
406 segments.pop();
407 }
408 if closed {
416 let legal: Vec<String> = fields.keys().cloned().collect();
417 for (key, value) in map {
418 if fields.contains_key(key) {
419 continue;
420 }
421 segments.push(PathSeg::Key(key.clone()));
422 let key_path = render_path(segments);
423 let key_id = arena_children
424 .as_ref()
425 .and_then(|children| children.get(key).copied());
426 let spans = self.spans(key_id, schema_id);
427 let suggestion = best_key_match(key, &legal, thresholds::MEDIUM);
432 let candidate: Option<(NodeId, bool)> = match &suggestion {
440 Some((suggested, score))
441 if *score >= thresholds::HIGH && map.get(suggested).is_none() =>
442 {
443 match fields.get(suggested) {
444 Some(field_id) => match self.module.get(*field_id) {
445 Some(Node::SchemaField { field }) => {
450 Some((*field_id, type_accepts(&field.typ, value)))
451 }
452 Some(Node::SchemaTable { .. }) => {
453 Some((*field_id, matches!(value, Value::Table(_))))
454 }
455 Some(Node::SchemaArray { .. }) => {
456 Some((*field_id, matches!(value, Value::Array(_))))
457 }
458 _ => None,
459 },
460 None => None,
461 }
462 }
463 _ => None,
464 };
465 let mut renamed = false;
466 if self.resilient {
467 if let (Some((field_id, true)), Some((suggested, score))) =
468 (candidate, &suggestion)
469 {
470 self.renames.push(RenameRequest {
471 segments: segments.clone(),
472 from: key.clone(),
473 to: suggested.clone(),
474 field_id,
475 value_id: key_id,
476 span: spans.0.clone(),
477 file: spans.1.clone(),
478 confidence: *score,
479 });
480 renamed = true;
481 }
482 }
483 if !renamed {
484 let mut message = format!(
485 "schema violation at '{}': unexpected key '{key}' in closed table",
486 display_path(&key_path)
487 );
488 let mut repair = None;
489 let mut confidence = 1.0;
493 if let Some((suggested, score)) = &suggestion {
494 message.push_str(&format!("; did you mean '{suggested}'?"));
495 repair = Some(Repair {
498 original: key.clone(),
499 suggested: suggested.clone(),
500 kind: RepairKind::KeyTypo,
501 safe: normalized_levenshtein(key, suggested) >= thresholds::AUTO_APPLY,
502 });
503 confidence = *score;
504 }
505 self.push_diag(
506 "T005",
507 message,
508 &key_path,
509 spans,
510 DiagMeta {
511 severity: Severity::Error,
512 repair,
513 confidence,
514 },
515 );
516 }
517 segments.pop();
518 }
519 }
520 Ok(())
521 }
522
523 fn validate_array(
524 &mut self,
525 schema_id: NodeId,
526 element: NodeId,
527 value_id: Option<NodeId>,
528 value: &Value,
529 segments: &mut Vec<PathSeg>,
530 ) -> Result<(), EvalError> {
531 let Value::Array(elements) = value else {
532 let path = render_path(segments);
533 self.push(
534 "T002",
535 format!(
536 "schema violation at '{}': expected Array, got {}",
537 display_path(&path),
538 value_kind_name(value)
539 ),
540 &path,
541 self.spans(value_id, schema_id),
542 );
543 return Ok(());
544 };
545 let arena_elements: Option<Vec<NodeId>> = match value_id.and_then(|id| self.module.get(id))
546 {
547 Some(Node::Array { elements: arena }) if arena.len() == elements.len() => {
552 Some(arena.clone())
553 }
554 _ => None,
555 };
556 for (i, v) in elements.iter().enumerate() {
557 let elem_id = arena_elements.as_ref().and_then(|els| els.get(i).copied());
562 segments.push(PathSeg::Index(i));
563 self.validate_node(element, elem_id, v, segments)?;
564 segments.pop();
565 }
566 Ok(())
567 }
568
569 fn validate_field(
570 &mut self,
571 schema_id: NodeId,
572 field: &SchemaField,
573 value_id: Option<NodeId>,
574 value: &Value,
575 segments: &mut [PathSeg],
576 ) {
577 let path = render_path(segments);
578 if !type_accepts(&field.typ, value) {
579 if let Value::String(text) = value {
587 if let Some(coerced) = coerce_string_to(text, &field.typ) {
588 let spans = self.spans(value_id, schema_id);
589 if self.resilient {
590 self.coercions.push(CoerceRequest {
591 segments: segments.to_vec(),
592 field_id: schema_id,
593 value_id,
594 coerced,
595 span: spans.0,
596 file: spans.1,
597 });
598 } else {
599 self.push(
600 "T002",
601 format!(
602 "schema violation at '{}': expected {}, got {} (coercible under --resilient)",
603 display_path(&path),
604 field.typ.name(),
605 value_kind_name(value)
606 ),
607 &path,
608 spans,
609 );
610 }
611 return;
612 }
613 if let Some(intent) = sentinel_meaning(text) {
614 self.push(
618 "T002",
619 format!(
620 "schema violation at '{}': expected {}, got {} (sentinel '{}' signals {} but the schema designates no value — set an explicit value or a schema default)",
621 display_path(&path),
622 field.typ.name(),
623 value_kind_name(value),
624 text.to_lowercase(),
625 intent,
626 ),
627 &path,
628 self.spans(value_id, schema_id),
629 );
630 return;
631 }
632 }
633 self.push(
634 "T002",
635 format!(
636 "schema violation at '{}': expected {}, got {}",
637 display_path(&path),
638 field.typ.name(),
639 value_kind_name(value)
640 ),
641 &path,
642 self.spans(value_id, schema_id),
643 );
644 return;
645 }
646 self.check_constraints(schema_id, field, value, &path, value_id);
647 }
648 fn check_missing(&mut self, field_id: NodeId, segments: &[PathSeg]) {
655 let (default_arg, is_null, field_span) = match self.module.get(field_id) {
656 Some(Node::SchemaField { field }) => (
657 field.default,
658 matches!(field.typ, ActiveType::Null),
659 field.span.clone(),
660 ),
661 _ => (None, false, None),
662 };
663 let path = render_path(segments);
664 let (span, file) =
665 match field_span.or_else(|| self.module.node_spans.get(&field_id).cloned()) {
666 Some(s) => (Some(s), self.module.node_files.get(&field_id).cloned()),
667 None => (None, None),
668 };
669 if let Some(default_id) = default_arg {
670 if self.resilient {
671 self.fills.push(FillRequest {
672 segments: segments.to_vec(),
673 field_id,
674 default_id,
675 span,
676 file,
677 });
678 return;
679 }
680 self.push(
681 "T004",
682 format!(
683 "schema violation at '{}': missing required key (strict mode requires explicit values despite a schema default)",
684 display_path(&path)
685 ),
686 &path,
687 (span, file),
688 );
689 return;
690 }
691 if is_null {
692 return;
693 }
694 self.push(
695 "T004",
696 format!(
697 "schema violation at '{}': missing required key (no default)",
698 display_path(&path)
699 ),
700 &path,
701 (span, file),
702 );
703 }
704
705 fn apply_fills(&mut self, result: &mut Value) -> Result<(), EvalError> {
713 let fills = std::mem::take(&mut self.fills);
714 for mut fill in fills {
715 if value_at(result, &fill.segments).is_some() {
719 continue;
720 }
721 let Some(default_value) = self.values.get(&fill.default_id).cloned() else {
726 self.push(
727 "T004",
728 format!(
729 "schema violation at '{}': missing required key (schema default could not be applied)",
730 display_path(&render_path(&fill.segments))
731 ),
732 &render_path(&fill.segments),
733 (fill.span.clone(), fill.file.clone()),
734 );
735 continue;
736 };
737 let fill_size = measure_value(&default_value);
740 self.account.charge(self.module, None, fill_size)?;
741 if !insert_at(result, &fill.segments, default_value.clone()) {
742 self.push(
743 "T004",
744 format!(
745 "schema violation at '{}': missing required key (schema default could not be applied)",
746 display_path(&render_path(&fill.segments))
747 ),
748 &render_path(&fill.segments),
749 (fill.span.clone(), fill.file.clone()),
750 );
751 continue;
752 };
753 let path = render_path(&fill.segments);
754 self.diagnostics.push(Diagnostic {
755 severity: Severity::Warning,
756 code: "W005".into(),
757 message: format!(
758 "filled missing key '{}' from schema default",
759 display_path(&path)
760 ),
761 path: Some(path),
762 span: fill.span,
763 repair: None,
764 confidence: 1.0,
765 file: fill.file,
766 });
767 self.validate_node(
768 fill.field_id,
769 Some(fill.default_id),
770 &default_value,
771 &mut fill.segments,
772 )?;
773 }
774 Ok(())
775 }
776
777 fn apply_renames(&mut self, result: &mut Value) -> Result<(), EvalError> {
789 let mut passes = 0u32;
790 while !self.renames.is_empty() {
791 passes += 1;
792 if passes > 1024 {
793 let pending = std::mem::take(&mut self.renames);
794 for req in pending {
795 let RenameRequest {
796 segments,
797 from,
798 to,
799 span,
800 file,
801 confidence,
802 ..
803 } = req;
804 let path = render_path(&segments);
805 let safe = normalized_levenshtein(&from, &to) >= thresholds::AUTO_APPLY;
806 self.push_diag(
807 "T005",
808 format!(
809 "schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
810 display_path(&path),
811 from,
812 to
813 ),
814 &path,
815 (span, file),
816 DiagMeta {
817 severity: Severity::Error,
818 repair: Some(Repair {
819 original: from,
820 suggested: to,
821 kind: RepairKind::KeyTypo,
822 safe,
823 }),
824 confidence,
825 },
826 );
827 }
828 break;
829 }
830 let pending = std::mem::take(&mut self.renames);
831 for req in pending {
832 let Some((last, parent_segs)) = req.segments.split_last() else {
833 continue;
834 };
835 if !matches!(last, PathSeg::Key(key) if key == &req.from) {
836 continue;
837 }
838 let Some(table) = value_at_mut(result, parent_segs) else {
839 self.rename_fallback(&req);
840 continue;
841 };
842 let Value::Table(map) = table else {
843 self.rename_fallback(&req);
844 continue;
845 };
846 if map.contains_key(&req.to) {
850 self.rename_fallback(&req);
851 continue;
852 }
853 let Some(moved) = map.shift_remove(&req.from) else {
854 self.rename_fallback(&req);
855 continue;
856 };
857 map.insert(req.to.clone(), moved);
858 let mut fixed: Vec<PathSeg> = parent_segs
859 .iter()
860 .cloned()
861 .chain(std::iter::once(PathSeg::Key(req.to.clone())))
862 .collect();
863 let fixed_path = render_path(&fixed);
864 self.diagnostics.retain(|d| {
871 !(d.code == "T004" && d.path.as_deref() == Some(fixed_path.as_str()))
872 });
873 self.fills
874 .retain(|f| render_path(&f.segments) != fixed_path);
875 self.push_diag(
876 "H005",
877 format!(
878 "renamed key '{}' to '{}' (confidence {:.2})",
879 req.from, req.to, req.confidence
880 ),
881 &fixed_path,
882 (req.span.clone(), req.file.clone()),
883 DiagMeta {
884 severity: Severity::Warning,
885 repair: Some(Repair {
886 original: req.from.clone(),
887 suggested: req.to.clone(),
888 kind: RepairKind::KeyTypo,
889 safe: normalized_levenshtein(&req.from, &req.to)
890 >= thresholds::AUTO_APPLY,
891 }),
892 confidence: req.confidence,
893 },
894 );
895 if let Some(current) = value_at(result, &fixed).cloned() {
896 self.validate_node(req.field_id, req.value_id, ¤t, &mut fixed)?;
897 }
898 }
899 }
900 Ok(())
901 }
902
903 fn rename_fallback(&mut self, req: &RenameRequest) {
907 let path = render_path(&req.segments);
908 self.push_diag(
909 "T005",
910 format!(
911 "schema violation at '{}': unexpected key '{}' in closed table; did you mean '{}'?",
912 display_path(&path),
913 req.from,
914 req.to
915 ),
916 &path,
917 (req.span.clone(), req.file.clone()),
918 DiagMeta {
919 severity: Severity::Error,
920 repair: Some(Repair {
921 original: req.from.clone(),
922 suggested: req.to.clone(),
923 kind: RepairKind::KeyTypo,
924 safe: normalized_levenshtein(&req.from, &req.to) >= thresholds::AUTO_APPLY,
925 }),
926 confidence: req.confidence,
927 },
928 );
929 }
930
931 fn coercion_fallback(&mut self, req: &CoerceRequest) {
934 let path = render_path(&req.segments);
935 let expected = match self.module.get(req.field_id) {
936 Some(Node::SchemaField { field }) => field.typ.name().to_string(),
937 _ => "schema type".to_string(),
938 };
939 self.push(
940 "T002",
941 format!(
942 "schema violation at '{}': expected {}, got string (coercion could not be applied)",
943 display_path(&path),
944 expected
945 ),
946 &path,
947 (req.span.clone(), req.file.clone()),
948 );
949 }
950
951 fn apply_coercions(&mut self, result: &mut Value) -> Result<(), EvalError> {
957 let pending = std::mem::take(&mut self.coercions);
958 for req in pending {
959 if !insert_at(result, &req.segments, req.coerced.clone()) {
960 self.coercion_fallback(&req);
963 continue;
964 }
965 let path = render_path(&req.segments);
966 self.push_diag(
967 "H007",
968 format!(
969 "resilient: coerced value at '{}' from string to {}",
970 display_path(&path),
971 value_kind_name(&req.coerced)
972 ),
973 &path,
974 (req.span.clone(), req.file.clone()),
975 DiagMeta {
976 severity: Severity::Warning,
977 repair: None,
978 confidence: 0.90,
979 },
980 );
981 if let Some(current) = value_at(result, &req.segments).cloned() {
982 let mut segs = req.segments.clone();
983 self.validate_node(req.field_id, req.value_id, ¤t, &mut segs)?;
984 }
985 }
986 Ok(())
987 }
988
989 fn check_constraints(
990 &mut self,
991 schema_id: NodeId,
992 field: &SchemaField,
993 value: &Value,
994 path: &str,
995 value_id: Option<NodeId>,
996 ) {
997 let spans = self.spans(value_id, schema_id);
998 match (&field.typ, value) {
999 (ActiveType::Int, Value::Int(n)) => {
1000 self.check_int_bounds(field, schema_id, *n, path, spans, true);
1001 }
1002 (ActiveType::Float, Value::Float(f)) => {
1003 self.check_float_bounds(field, schema_id, *f, path, spans);
1004 }
1005 (ActiveType::Float, Value::Int(n)) => {
1006 self.check_float_bounds(field, schema_id, *n as f64, path, spans);
1007 }
1008 (ActiveType::Duration, Value::DurationMs(ms)) => {
1009 self.check_duration_bounds(field, schema_id, *ms, path, spans);
1010 }
1011 (ActiveType::Bytes, Value::Int(n)) => {
1016 self.check_int_bounds(field, schema_id, *n, path, spans, false);
1017 }
1018 (ActiveType::String, Value::String(s)) => {
1019 self.check_string_bounds(field, schema_id, s, path, spans);
1020 }
1021 (ActiveType::Enum(members), Value::String(s)) if !members.iter().any(|m| m == s) => {
1022 let quoted: Vec<String> = members.iter().map(|m| format!("\"{m}\"")).collect();
1023 self.push(
1024 "T003",
1025 format!(
1026 "schema violation at '{}': not a member of Enum({})",
1027 display_path(path),
1028 quoted.join(", ")
1029 ),
1030 path,
1031 spans,
1032 );
1033 }
1034 _ => {}
1035 }
1036 }
1037
1038 fn check_int_bounds(
1039 &mut self,
1040 field: &SchemaField,
1041 field_id: NodeId,
1042 n: i64,
1043 path: &str,
1044 spans: (Option<Span>, Option<String>),
1045 allow_float_bounds: bool,
1046 ) {
1047 for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1048 let Some(arg_id) = arg else { continue };
1049 let Some(bound) = self.values.get(&arg_id) else {
1050 continue;
1051 };
1052 let (numerically_less, numerically_greater) = match bound {
1053 Value::Int(b) => (n < *b, n > *b),
1054 Value::Float(b) if allow_float_bounds => ((n as f64) < *b, (n as f64) > *b),
1055 _ => {
1056 if self.bug_once(field_id, name) {
1059 self.violation(
1060 path,
1061 &format!("incomparable {name} bound for {} fields", field.typ.name()),
1062 spans.clone(),
1063 );
1064 }
1065 continue;
1066 }
1067 };
1068 if (name == "min" && numerically_less) || (name == "max" && numerically_greater) {
1069 self.violation(
1070 path,
1071 if name == "min" {
1072 "below minimum"
1073 } else {
1074 "above maximum"
1075 },
1076 spans.clone(),
1077 );
1078 }
1079 }
1080 }
1081
1082 fn bug_once(&mut self, field_id: NodeId, arg: &str) -> bool {
1086 self.schema_bugs.insert((field_id, arg.to_string()))
1087 }
1088
1089 fn check_float_bounds(
1090 &mut self,
1091 field: &SchemaField,
1092 field_id: NodeId,
1093 f: f64,
1094 path: &str,
1095 spans: (Option<Span>, Option<String>),
1096 ) {
1097 for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1098 let Some(arg_id) = arg else { continue };
1099 let Some(bound) = self.values.get(&arg_id) else {
1100 continue;
1101 };
1102 let bound = match bound {
1103 Value::Int(n) => *n as f64,
1104 Value::Float(x) => *x,
1105 _ => {
1106 if self.bug_once(field_id, name) {
1107 self.violation(
1108 path,
1109 &format!("incomparable {name} bound for Float fields"),
1110 spans.clone(),
1111 );
1112 }
1113 continue;
1114 }
1115 };
1116 if (name == "min" && f < bound) || (name == "max" && f > bound) {
1117 self.violation(
1118 path,
1119 if name == "min" {
1120 "below minimum"
1121 } else {
1122 "above maximum"
1123 },
1124 spans.clone(),
1125 );
1126 }
1127 }
1128 }
1129
1130 fn check_duration_bounds(
1131 &mut self,
1132 field: &SchemaField,
1133 field_id: NodeId,
1134 ms: u64,
1135 path: &str,
1136 spans: (Option<Span>, Option<String>),
1137 ) {
1138 for (arg, name) in [(field.min, "min"), (field.max, "max")] {
1139 let Some(arg_id) = arg else { continue };
1140 let Some(bound) = self.values.get(&arg_id) else {
1141 continue;
1142 };
1143 let bound = match bound {
1144 Value::DurationMs(b) => *b,
1145 Value::Int(b) if *b >= 0 => *b as u64,
1147 _ => {
1148 if self.bug_once(field_id, name) {
1149 self.violation(
1150 path,
1151 &format!("incomparable {name} bound for Duration fields"),
1152 spans.clone(),
1153 );
1154 }
1155 continue;
1156 }
1157 };
1158 if (name == "min" && ms < bound) || (name == "max" && ms > bound) {
1159 self.violation(
1160 path,
1161 if name == "min" {
1162 "below minimum"
1163 } else {
1164 "above maximum"
1165 },
1166 spans.clone(),
1167 );
1168 }
1169 }
1170 }
1171
1172 fn check_string_bounds(
1173 &mut self,
1174 field: &SchemaField,
1175 field_id: NodeId,
1176 s: &str,
1177 path: &str,
1178 spans: (Option<Span>, Option<String>),
1179 ) {
1180 let len = s.chars().count();
1181 for (arg, name, detail) in [
1182 (field.min_len, "min_len", "too short"),
1183 (field.max_len, "max_len", "too long"),
1184 ] {
1185 let Some(arg_id) = arg else { continue };
1186 let Some(bound) = self.values.get(&arg_id) else {
1187 continue;
1188 };
1189 match bound {
1190 Value::Int(n) => {
1191 let limit = (*n).max(0) as usize;
1194 if (name == "min_len" && len < limit) || (name == "max_len" && len > limit) {
1195 self.violation(path, detail, spans.clone());
1196 }
1197 }
1198 _ => {
1199 if self.bug_once(field_id, name) {
1200 self.violation(
1201 path,
1202 &format!("incomparable {name} bound for String fields"),
1203 spans.clone(),
1204 );
1205 }
1206 }
1207 }
1208 }
1209 if let Some(pattern_id) = field.pattern {
1210 let owned_pattern: Option<String> = match self.values.get(&pattern_id) {
1211 Some(Value::String(pattern)) => Some(pattern.clone()),
1212 Some(_) => {
1213 if self.bug_once(field_id, "pattern") {
1214 self.violation(
1215 path,
1216 "incomparable pattern bound for String fields",
1217 spans.clone(),
1218 );
1219 }
1220 None
1221 }
1222 None => None,
1223 };
1224 if let Some(pattern) = owned_pattern {
1225 let cached = self.regexes.get(&pattern_id).cloned();
1228 let compiled = match cached {
1229 Some(result) => result,
1230 None => {
1231 let result = regex::Regex::new(&pattern).ok();
1232 if result.is_none() && self.bug_once(field_id, "pattern") {
1233 self.violation(path, "invalid pattern in schema", spans.clone());
1234 }
1235 self.regexes.insert(pattern_id, result.clone());
1236 result
1237 }
1238 };
1239 if let Some(re) = compiled {
1240 if !re.is_match(s) {
1241 self.violation(path, "does not match pattern", spans.clone());
1242 }
1243 }
1244 }
1245 }
1246 }
1247
1248 fn spans(&self, value_id: Option<NodeId>, schema_id: NodeId) -> (Option<Span>, Option<String>) {
1252 if let Some(id) = value_id {
1253 if let Some(span) = self.module.node_spans.get(&id).cloned() {
1254 return (Some(span), self.module.node_files.get(&id).cloned());
1255 }
1256 }
1257 (
1258 self.module.node_spans.get(&schema_id).cloned(),
1259 self.module.node_files.get(&schema_id).cloned(),
1260 )
1261 }
1262
1263 fn violation(&mut self, path: &str, detail: &str, spans: (Option<Span>, Option<String>)) {
1264 self.push(
1265 "T003",
1266 format!("schema violation at '{}': {detail}", display_path(path)),
1267 path,
1268 spans,
1269 );
1270 }
1271
1272 fn push(
1273 &mut self,
1274 code: &str,
1275 message: String,
1276 path: &str,
1277 spans: (Option<Span>, Option<String>),
1278 ) {
1279 self.push_diag(
1280 code,
1281 message,
1282 path,
1283 spans,
1284 DiagMeta {
1285 severity: Severity::Error,
1286 repair: None,
1287 confidence: 1.0,
1288 },
1289 );
1290 }
1291
1292 fn push_diag(
1298 &mut self,
1299 code: &str,
1300 message: String,
1301 path: &str,
1302 spans: (Option<Span>, Option<String>),
1303 meta: DiagMeta,
1304 ) {
1305 let (span, file) = spans;
1306 self.diagnostics.push(Diagnostic {
1307 severity: meta.severity,
1308 code: code.into(),
1309 message,
1310 path: Some(path.to_string()),
1311 span,
1312 repair: meta.repair,
1313 confidence: meta.confidence,
1314 file,
1315 });
1316 }
1317}
1318
1319struct DiagMeta {
1321 severity: Severity,
1322 repair: Option<Repair>,
1323 confidence: f64,
1324}
1325
1326fn display_path(path: &str) -> &str {
1328 if path.is_empty() {
1329 "<root>"
1330 } else {
1331 path
1332 }
1333}
1334
1335fn lookup_result_path<'v>(result: &'v Value, path: &str) -> Option<&'v Value> {
1340 let mut cursor = result;
1341 for segment in path.split('.') {
1342 match cursor {
1343 Value::Table(map) => cursor = map.get(segment)?,
1344 _ => return None,
1345 }
1346 }
1347 Some(cursor)
1348}
1349
1350fn overlay_values(
1358 module: &Module,
1359 node_id: NodeId,
1360 value: &Value,
1361 overlay: &mut HashMap<NodeId, Value>,
1362) {
1363 overlay.insert(node_id, value.clone());
1364 match (module.get(node_id), value) {
1365 (Some(Node::Table { fields }), Value::Table(map)) => {
1366 for (key, child_id) in fields {
1367 match map.get(key) {
1368 Some(child_value) => overlay_values(module, *child_id, child_value, overlay),
1369 None => {
1370 remove_subtree(module, *child_id, overlay);
1371 }
1372 }
1373 }
1374 }
1375 (Some(Node::Array { elements }), Value::Array(values))
1376 if elements.len() == values.len() =>
1377 {
1378 for (child_id, child_value) in elements.iter().zip(values.iter()) {
1379 overlay_values(module, *child_id, child_value, overlay);
1380 }
1381 }
1382 (Some(Node::Array { .. }), Value::Array(_)) => {
1383 remove_subtree(module, node_id, overlay);
1384 overlay.insert(node_id, value.clone());
1385 }
1386 _ => {}
1387 }
1388}
1389
1390fn remove_subtree(module: &Module, node_id: NodeId, overlay: &mut HashMap<NodeId, Value>) {
1395 overlay.remove(&node_id);
1396 match module.get(node_id) {
1397 Some(Node::Table { fields }) => {
1398 for child_id in fields.values() {
1399 remove_subtree(module, *child_id, overlay);
1400 }
1401 }
1402 Some(Node::Array { elements }) => {
1403 for child_id in elements {
1404 remove_subtree(module, *child_id, overlay);
1405 }
1406 }
1407 _ => {}
1408 }
1409}
1410
1411fn predicate_value(
1418 module: &Module,
1419 overlay: &HashMap<NodeId, Value>,
1420 base: &HashMap<NodeId, Value>,
1421 expr: NodeId,
1422) -> Option<Value> {
1423 match module.get(expr) {
1424 Some(Node::Literal(v)) => Some(v.clone()),
1425 Some(Node::Ref(target)) => overlay.get(target).cloned(),
1426 Some(Node::Ident(name)) => module
1427 .bindings
1428 .get(name)
1429 .and_then(|target| overlay.get(target))
1430 .cloned(),
1431 Some(Node::Binary { op, lhs, rhs }) => {
1432 let l = predicate_value(module, overlay, base, *lhs)?;
1433 let r = predicate_value(module, overlay, base, *rhs)?;
1434 eval_binary(*op, &l, &r).ok()
1435 }
1436 Some(Node::Unary { op, expr }) => {
1437 let v = predicate_value(module, overlay, base, *expr)?;
1438 eval_unary(*op, &v).ok()
1439 }
1440 _ => overlay
1443 .get(&expr)
1444 .cloned()
1445 .or_else(|| base.get(&expr).cloned()),
1446 }
1447}
1448
1449fn render_source(module: &Module, source: &str, expr: NodeId) -> String {
1456 if subtree_has_secret(module, expr) {
1457 return "constraint".to_string();
1458 }
1459 truncate_120(source)
1460}
1461
1462fn subtree_has_secret(module: &Module, expr: NodeId) -> bool {
1466 let mut stack = vec![expr];
1467 let mut seen = std::collections::HashSet::new();
1468 while let Some(id) = stack.pop() {
1469 if !seen.insert(id) {
1470 continue;
1471 }
1472 match module.get(id) {
1473 None => {}
1474 Some(Node::Secret { .. }) => return true,
1475 Some(Node::Ref(target)) => stack.push(*target),
1476 Some(Node::Ident(name)) => {
1477 if let Some(target) = module.bindings.get(name) {
1478 stack.push(*target);
1479 }
1480 }
1481 Some(Node::Binary { lhs, rhs, .. }) => stack.extend([*lhs, *rhs]),
1482 Some(Node::Unary { expr, .. }) => stack.push(*expr),
1483 Some(Node::Array { elements }) => stack.extend(elements.iter().copied()),
1484 Some(Node::Table { fields }) => stack.extend(fields.values().copied()),
1485 Some(Node::Interpolate { parts }) => {
1486 for part in parts {
1487 if let crate::ir::InterpPart::Expr(e) = part {
1488 stack.push(*e);
1489 }
1490 }
1491 }
1492 Some(Node::Semantic { inner, .. }) => stack.push(*inner),
1493 Some(Node::Env {
1494 default: Some(def), ..
1495 }) => stack.push(*def),
1496 _ => {}
1497 }
1498 }
1499 false
1500}
1501
1502fn render_constrained(value: &Value) -> String {
1507 let text = match value {
1508 Value::Secret(_) => "secret".to_string(),
1509 Value::String(s) => format!("{s:?}"),
1510 Value::Int(i) => i.to_string(),
1511 Value::Float(f) => f.to_string(),
1512 Value::Bool(b) => b.to_string(),
1513 Value::Null => "null".to_string(),
1514 Value::DurationMs(ms) => format!("{ms}ms"),
1515 Value::Quantity(n, unit) => unit.format_amount(*n),
1516 Value::Array(_) => "array".to_string(),
1517 Value::Table(_) => "table".to_string(),
1518 };
1519 truncate_120(&text)
1520}
1521
1522fn truncate_120(text: &str) -> String {
1525 const LIMIT: usize = 120;
1526 if text.chars().count() > LIMIT {
1527 format!("{}…", text.chars().take(LIMIT - 1).collect::<String>())
1528 } else {
1529 text.to_string()
1530 }
1531}
1532
1533fn render_path(segments: &[PathSeg]) -> String {
1536 let mut out = String::new();
1537 for segment in segments {
1538 match segment {
1539 PathSeg::Key(key) => {
1540 if !out.is_empty() {
1541 out.push('.');
1542 }
1543 out.push_str(key);
1544 }
1545 PathSeg::Index(i) => {
1546 out.push_str(&format!("[{i}]"));
1547 }
1548 }
1549 }
1550 out
1551}
1552
1553fn value_at<'v>(result: &'v Value, segments: &[PathSeg]) -> Option<&'v Value> {
1557 let mut cursor = result;
1558 for segment in segments {
1559 match (segment, cursor) {
1560 (PathSeg::Key(key), Value::Table(map)) => cursor = map.get(key)?,
1561 (PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get(*i)?,
1562 _ => return None,
1563 }
1564 }
1565 Some(cursor)
1566}
1567
1568fn value_at_mut<'v>(result: &'v mut Value, segments: &[PathSeg]) -> Option<&'v mut Value> {
1571 let mut cursor = result;
1572 for segment in segments {
1573 match (segment, cursor) {
1574 (PathSeg::Key(key), Value::Table(map)) => cursor = map.get_mut(key)?,
1575 (PathSeg::Index(i), Value::Array(elements)) => cursor = elements.get_mut(*i)?,
1576 _ => return None,
1577 }
1578 }
1579 Some(cursor)
1580}
1581
1582fn insert_at(result: &mut Value, segments: &[PathSeg], value: Value) -> bool {
1588 let Some((first, rest)) = segments.split_first() else {
1589 return false;
1590 };
1591 if rest.is_empty() {
1592 if let (PathSeg::Key(key), Value::Table(map)) = (first, result) {
1593 map.insert(key.clone(), value);
1594 return true;
1595 }
1596 return false;
1597 }
1598 match (first, result) {
1599 (PathSeg::Key(key), Value::Table(map)) => match map.get_mut(key) {
1600 Some(next) => insert_at(next, rest, value),
1601 None => false,
1602 },
1603 (PathSeg::Index(i), Value::Array(elements)) => match elements.get_mut(*i) {
1604 Some(next) => insert_at(next, rest, value),
1605 None => false,
1606 },
1607 _ => false,
1608 }
1609}
1610
1611fn type_accepts(typ: &ActiveType, value: &Value) -> bool {
1616 use ActiveType::*;
1617 use Value::Table as Tab;
1618 matches!(
1619 (typ, value),
1620 (Null, Value::Null)
1621 | (Bool, Value::Bool(_))
1622 | (Int, Value::Int(_))
1623 | (Float, Value::Float(_))
1624 | (Float, Value::Int(_))
1625 | (String, Value::String(_))
1626 | (Duration, Value::DurationMs(_))
1627 | (Bytes, Value::Int(_))
1628 | (Secret, Value::Secret(_))
1629 | (Any, _)
1630 | (Table { .. }, Tab(_))
1631 | (Enum(_), Value::String(_))
1632 )
1633}