1use std::{
2 collections::{BTreeMap, BTreeSet, VecDeque},
3 path::Path,
4};
5
6use regex::Regex;
7use serde_json::Value;
8
9use crate::{
10 Document, ValidationReport, graph_effective_edges, load, object, parse_expression, strings,
11};
12
13const SCHEMA: &str = include_str!("../schema/agentic-graph-1.0.schema.json");
14
15pub fn validate(document: &Document) -> ValidationReport {
17 let mut report = ValidationReport::new(document.clone());
18 let value = Value::Object(document.clone());
19 match serde_json::from_str(SCHEMA)
20 .ok()
21 .and_then(|schema| jsonschema::validator_for(&schema).ok())
22 {
23 Some(validator) => {
24 for error in validator.iter_errors(&value) {
25 let pointer = error.instance_path().to_string();
26 let text = error.to_string();
27 let code = if text.contains("additional properties") {
28 "AG003"
29 } else if text.contains("not one of") || text.contains("enum") {
30 "AG004"
31 } else if pointer.contains("/edges/") && text.contains("valid") {
32 "AG103"
33 } else if pointer.contains("/inputs/") && text.contains("valid") {
34 "AG104"
35 } else {
36 "AG001"
37 };
38 report.add(code, "error", text, pointer);
39 }
40 }
41 None => report.add(
42 "AG001",
43 "error",
44 "embedded schema could not be compiled",
45 "",
46 ),
47 }
48 let version = document
49 .get("ags_version")
50 .and_then(Value::as_str)
51 .unwrap_or_default();
52 let pieces: Vec<&str> = version.split('.').collect();
53 if pieces.len() != 2 || pieces.iter().any(|piece| piece.parse::<u64>().is_err()) {
54 report.add(
55 "AG002",
56 "error",
57 format!("unparsable ags_version {version:?}"),
58 "",
59 );
60 } else if pieces != ["1", "0"] {
61 report.add(
62 "AG002",
63 "error",
64 format!("unsupported AGS version {version}"),
65 "",
66 );
67 }
68 if report.errors.is_empty() {
69 semantic(document, &mut report);
70 }
71 report.ok = report.errors.is_empty();
72 report
73}
74
75pub fn validate_path(path: impl AsRef<Path>) -> ValidationReport {
77 match load(path) {
78 Ok(document) => validate(&document),
79 Err(error) => {
80 let mut report = ValidationReport {
81 document: None,
82 findings: vec![],
83 errors: vec![],
84 warnings: vec![],
85 ok: false,
86 };
87 report.add(error.code, "error", error.to_string(), "");
88 report
89 }
90 }
91}
92
93#[derive(Clone)]
94struct Scope {
95 pointer: String,
96 nodes: Document,
97 edges: Vec<Value>,
98 entrypoints: Vec<String>,
99 param_names: BTreeSet<String>,
100 root: bool,
101}
102
103fn scopes(document: &Document) -> Vec<Scope> {
104 fn collect(
105 nodes: &Document,
106 base: &str,
107 inherited_params: &BTreeSet<String>,
108 result: &mut Vec<Scope>,
109 ) {
110 for (id, raw) in nodes {
111 let node = object(Some(raw));
112 let kind = node.get("type").and_then(Value::as_str).unwrap_or("task");
113 if !matches!(kind, "loop" | "map" | "subgraph") {
114 continue;
115 }
116 let block = object(node.get(kind));
117 let key = if kind == "subgraph" { "inline" } else { "body" };
118 if let Some(fragment) = block.get(key).and_then(Value::as_object) {
119 let pointer = format!("{base}/nodes/{id}/{kind}/{key}");
120 let declared: BTreeSet<String> =
121 object(fragment.get("params")).keys().cloned().collect();
122 let child = Scope {
123 pointer: pointer.clone(),
124 nodes: object(fragment.get("nodes")).clone(),
125 edges: fragment
126 .get("edges")
127 .and_then(Value::as_array)
128 .cloned()
129 .unwrap_or_default(),
130 entrypoints: strings(fragment.get("entrypoints")),
131 param_names: if declared.is_empty() {
132 inherited_params.clone()
133 } else {
134 declared
135 },
136 root: false,
137 };
138 collect(&child.nodes, &pointer, &child.param_names, result);
139 result.push(child);
140 }
141 }
142 }
143 let root_params: BTreeSet<String> = object(document.get("params")).keys().cloned().collect();
144 let mut result = vec![Scope {
145 pointer: String::new(),
146 nodes: object(document.get("nodes")).clone(),
147 edges: document
148 .get("edges")
149 .and_then(Value::as_array)
150 .cloned()
151 .unwrap_or_default(),
152 entrypoints: strings(document.get("entrypoints")),
153 param_names: root_params.clone(),
154 root: true,
155 }];
156 collect(&result[0].nodes.clone(), "", &root_params, &mut result);
157 for (name, raw) in object(document.get("subgraphs")) {
158 let fragment = object(Some(raw));
159 let pointer = format!("/subgraphs/{name}");
160 let declared: BTreeSet<String> = object(fragment.get("params")).keys().cloned().collect();
161 let child = Scope {
162 pointer: pointer.clone(),
163 nodes: object(fragment.get("nodes")).clone(),
164 edges: fragment
165 .get("edges")
166 .and_then(Value::as_array)
167 .cloned()
168 .unwrap_or_default(),
169 entrypoints: strings(fragment.get("entrypoints")),
170 param_names: if declared.is_empty() {
171 root_params.clone()
172 } else {
173 declared
174 },
175 root: false,
176 };
177 collect(&child.nodes, &pointer, &child.param_names, &mut result);
178 result.push(child);
179 }
180 result
181}
182
183fn scope_edges(scope: &Scope) -> Vec<(String, String)> {
184 let mut edges = vec![];
185 for (id, raw) in &scope.nodes {
186 for dependency in strings(raw.as_object().and_then(|node| node.get("depends_on"))) {
187 edges.push((dependency, id.clone()));
188 }
189 }
190 for raw in &scope.edges {
191 let edge = object(Some(raw));
192 edges.push((
193 edge.get("from")
194 .and_then(Value::as_str)
195 .unwrap_or_default()
196 .into(),
197 edge.get("to")
198 .and_then(Value::as_str)
199 .unwrap_or_default()
200 .into(),
201 ));
202 }
203 edges
204}
205
206fn semantic(document: &Document, report: &mut ValidationReport) {
207 let all_scopes = scopes(document);
208 for scope in &all_scopes {
209 validate_scope(scope, document, report);
210 }
211 validate_recursion(document, report);
212 let has_estimate = all_scopes.iter().any(|scope| {
213 scope
214 .nodes
215 .values()
216 .any(|node| node.get("estimate").is_some())
217 });
218 if object(document.get("constraints"))
219 .get("max_cost_usd")
220 .is_none()
221 && !has_estimate
222 {
223 report.add("AG908", "warning", "graph has neither constraints.max_cost_usd nor any node estimate; its cost cannot be previewed", "");
224 }
225 check_unread_outputs(document, &all_scopes, report);
226}
227
228fn validate_scope(scope: &Scope, document: &Document, report: &mut ValidationReport) {
229 let edges = scope_edges(scope);
230 let mut incoming: BTreeMap<String, usize> =
231 scope.nodes.keys().map(|id| (id.clone(), 0)).collect();
232 let mut direct: BTreeMap<String, Vec<String>> =
233 scope.nodes.keys().map(|id| (id.clone(), vec![])).collect();
234 let explicit_pairs: BTreeSet<(String, String)> = scope
235 .edges
236 .iter()
237 .map(|raw| {
238 let edge = object(Some(raw));
239 (
240 edge.get("from")
241 .and_then(Value::as_str)
242 .unwrap_or_default()
243 .into(),
244 edge.get("to")
245 .and_then(Value::as_str)
246 .unwrap_or_default()
247 .into(),
248 )
249 })
250 .collect();
251 for raw in &scope.edges {
252 let edge = object(Some(raw));
253 for key in ["from", "to"] {
254 let id = edge.get(key).and_then(Value::as_str).unwrap_or_default();
255 if !scope.nodes.contains_key(id) {
256 report.add(
257 "AG113",
258 "error",
259 format!("edge references unknown node {id:?}"),
260 &scope.pointer,
261 );
262 }
263 }
264 }
265 for (id, raw) in &scope.nodes {
266 for dependency in strings(raw.as_object().and_then(|node| node.get("depends_on"))) {
267 if !scope.nodes.contains_key(&dependency) {
268 report.add(
269 "AG114",
270 "error",
271 format!("depends_on references unknown node {dependency:?}"),
272 format!("{}/nodes/{id}", scope.pointer),
273 );
274 }
275 if explicit_pairs.contains(&(dependency.clone(), id.clone())) {
276 report.add(
277 "AG901",
278 "warning",
279 format!(
280 "{dependency} -> {id} declared by both depends_on and an explicit edge"
281 ),
282 format!("{}/nodes/{id}", scope.pointer),
283 );
284 }
285 }
286 }
287 for (from, to) in &edges {
288 if scope.nodes.contains_key(from) && scope.nodes.contains_key(to) {
289 *incoming.get_mut(to).unwrap() += 1;
290 direct.get_mut(to).unwrap().push(from.clone());
291 }
292 }
293 if has_cycle(&scope.nodes, &edges) {
294 report.add(
295 "AG111",
296 "error",
297 "cycle in effective edge set",
298 &scope.pointer,
299 );
300 }
301 for entry in &scope.entrypoints {
302 if !scope.nodes.contains_key(entry) {
303 report.add(
304 if scope.root { "AG115" } else { "AG133" },
305 "error",
306 format!("entrypoint {entry:?} is not a node in this scope"),
307 &scope.pointer,
308 );
309 } else if incoming[entry] > 0 {
310 report.add(
311 "AG112",
312 "error",
313 format!("entrypoint {entry:?} has incoming edges"),
314 &scope.pointer,
315 );
316 }
317 }
318 let mut reachable = BTreeSet::new();
319 let mut queue: VecDeque<String> = scope.entrypoints.iter().cloned().collect();
320 let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
321 for (from, to) in &edges {
322 outgoing.entry(from.clone()).or_default().push(to.clone());
323 }
324 while let Some(id) = queue.pop_front() {
325 if reachable.insert(id.clone()) {
326 queue.extend(outgoing.get(&id).into_iter().flatten().cloned());
327 }
328 }
329 for id in scope.nodes.keys().filter(|id| !reachable.contains(*id)) {
330 report.add(
331 "AG903",
332 "warning",
333 format!("node {id:?} is unreachable from any entrypoint"),
334 format!("{}/nodes/{id}", scope.pointer),
335 );
336 }
337 let predecessors = transitive_predecessors(&scope.nodes, &direct);
338 let mut ids: Vec<_> = scope.nodes.keys().cloned().collect();
339 ids.sort();
340 for id in ids {
341 validate_node(
342 scope,
343 document,
344 &id,
345 incoming.get(&id).copied().unwrap_or(0),
346 predecessors.get(&id).unwrap(),
347 report,
348 );
349 }
350}
351
352fn walk_all_strings(value: &Value, visit: &mut impl FnMut(&str)) {
353 match value {
354 Value::String(text) => visit(text),
355 Value::Array(items) => {
356 for item in items {
357 walk_all_strings(item, visit);
358 }
359 }
360 Value::Object(map) => {
361 for item in map.values() {
362 walk_all_strings(item, visit);
363 }
364 }
365 _ => {}
366 }
367}
368
369fn check_unread_outputs(document: &Document, scopes: &[Scope], report: &mut ValidationReport) {
370 let external = Regex::new(r"nodes\.([A-Za-z_][\w-]*)\.outputs\.([A-Za-z_][\w-]*)").unwrap();
371 let own = Regex::new(r"(?:self|nodes\.self)\.outputs\.([A-Za-z_][\w-]*)").unwrap();
372 let mut reads = BTreeSet::new();
373 walk_all_strings(&Value::Object(document.clone()), &mut |text| {
374 for capture in external.captures_iter(text) {
375 reads.insert((capture[1].to_owned(), capture[2].to_owned()));
376 }
377 });
378 for scope in scopes {
379 for (id, raw) in &scope.nodes {
380 walk_all_strings(raw, &mut |text| {
381 for capture in own.captures_iter(text) {
382 reads.insert((id.clone(), capture[1].to_owned()));
383 }
384 });
385 for name in object(raw.get("outputs")).keys() {
386 if !reads.contains(&(id.clone(), name.clone())) {
387 report.add(
388 "AG904",
389 "warning",
390 format!("output {name:?} of node {id:?} is never read"),
391 format!("{}/nodes/{id}/outputs/{name}", scope.pointer),
392 );
393 }
394 }
395 }
396 }
397}
398
399fn has_cycle(nodes: &Document, edges: &[(String, String)]) -> bool {
400 let mut incoming: BTreeMap<_, usize> = nodes.keys().map(|id| (id.clone(), 0)).collect();
401 let mut outgoing: BTreeMap<String, Vec<String>> = BTreeMap::new();
402 for (from, to) in edges {
403 if incoming.contains_key(from) && incoming.contains_key(to) {
404 *incoming.get_mut(to).unwrap() += 1;
405 outgoing.entry(from.clone()).or_default().push(to.clone());
406 }
407 }
408 let mut queue: VecDeque<_> = incoming
409 .iter()
410 .filter(|(_, n)| **n == 0)
411 .map(|(id, _)| id.clone())
412 .collect();
413 let mut seen = 0;
414 while let Some(id) = queue.pop_front() {
415 seen += 1;
416 for target in outgoing.get(&id).into_iter().flatten() {
417 let count = incoming.get_mut(target).unwrap();
418 *count -= 1;
419 if *count == 0 {
420 queue.push_back(target.clone());
421 }
422 }
423 }
424 seen != nodes.len()
425}
426
427fn transitive_predecessors(
428 nodes: &Document,
429 direct: &BTreeMap<String, Vec<String>>,
430) -> BTreeMap<String, BTreeSet<String>> {
431 let mut result = BTreeMap::new();
432 for id in nodes.keys() {
433 let mut seen = BTreeSet::new();
434 let mut stack = direct.get(id).cloned().unwrap_or_default();
435 while let Some(parent) = stack.pop() {
436 if seen.insert(parent.clone()) {
437 stack.extend(direct.get(&parent).cloned().unwrap_or_default());
438 }
439 }
440 result.insert(id.clone(), seen);
441 }
442 result
443}
444
445fn validate_node(
446 scope: &Scope,
447 document: &Document,
448 id: &str,
449 incoming: usize,
450 predecessors: &BTreeSet<String>,
451 report: &mut ValidationReport,
452) {
453 let node = object(scope.nodes.get(id));
454 let pointer = format!("{}/nodes/{id}", scope.pointer);
455 let kind = node.get("type").and_then(Value::as_str).unwrap_or("task");
456 if id == "self" {
457 report.add(
458 "AG117",
459 "error",
460 "'self' is a reserved namespace root and cannot be a node id",
461 &pointer,
462 );
463 }
464 for other in ["loop", "map", "subgraph", "gate", "decision"] {
465 if other != kind && node.contains_key(other) {
466 report.add(
467 "AG101",
468 "error",
469 format!("node of type {kind:?} declares a {other:?} block"),
470 &pointer,
471 );
472 }
473 }
474 if kind == "gate" && node.contains_key("intelligence") {
475 report.add(
476 "AG102",
477 "error",
478 "gate nodes must not declare intelligence",
479 &pointer,
480 );
481 }
482 if matches!(kind, "decision" | "gate") && object(node.get("outputs")).contains_key("decision") {
483 report.add(
484 "AG122",
485 "error",
486 "'decision' is a reserved output name on decision and gate nodes",
487 &pointer,
488 );
489 }
490 if node.get("join").and_then(Value::as_str) == Some("n_of")
491 && node.get("join_count").and_then(Value::as_u64).unwrap_or(0) as usize > incoming
492 {
493 report.add(
494 "AG116",
495 "error",
496 "join_count exceeds incoming edges",
497 &pointer,
498 );
499 }
500 let intel = object(node.get("intelligence"));
501 let tier = intel
502 .get("tier")
503 .and_then(Value::as_str)
504 .unwrap_or_default();
505 let rank = |name: &str| match name {
506 "minimal" => 1,
507 "standard" => 2,
508 "advanced" => 3,
509 "frontier" => 4,
510 _ => 0,
511 };
512 if let Some(level) = intel.get("level").and_then(Value::as_u64) {
513 if !tier.is_empty() && rank(tier) != level {
514 report.add(
515 "AG141",
516 "error",
517 "intelligence tier and level disagree",
518 &pointer,
519 );
520 }
521 }
522 if let Some(target) = intel.get("escalate_to").and_then(Value::as_str) {
523 if rank(target) < rank(tier) {
524 report.add(
525 "AG142",
526 "error",
527 "escalate_to is below the configured tier",
528 &pointer,
529 );
530 }
531 }
532 if tier == "frontier"
533 && intel
534 .get("rationale")
535 .and_then(Value::as_str)
536 .unwrap_or_default()
537 .is_empty()
538 {
539 report.add(
540 "AG905",
541 "warning",
542 "frontier-tier node has no rationale",
543 &pointer,
544 );
545 }
546 if matches!(kind, "loop" | "map" | "subgraph") {
547 let block = object(node.get(kind));
548 if let Some(used) = block.get("use").and_then(Value::as_str) {
549 if !object(document.get("subgraphs")).contains_key(used) {
550 report.add(
551 "AG132",
552 "error",
553 format!("{kind}.use names unknown fragment {used:?}"),
554 &pointer,
555 );
556 }
557 }
558 let reference = object(block.get("ref"));
559 if let Some(uri) = reference.get("uri").and_then(Value::as_str) {
560 if !uri.starts_with('.')
561 && !uri.starts_with('/')
562 && !reference.contains_key("integrity")
563 {
564 report.add(
565 "AG909",
566 "warning",
567 "non-local subgraph reference has no integrity digest",
568 &pointer,
569 );
570 }
571 }
572 }
573 if kind == "decision" {
574 let decision = object(node.get("decision"));
575 let mut labels: BTreeMap<String, usize> = BTreeMap::new();
576 for (index, raw) in decision
577 .get("branches")
578 .and_then(Value::as_array)
579 .into_iter()
580 .flatten()
581 .enumerate()
582 {
583 let branch = object(Some(raw));
584 let label = branch
585 .get("label")
586 .and_then(Value::as_str)
587 .unwrap_or_default();
588 *labels.entry(label.into()).or_insert(0) += 1;
589 if decision.get("evaluator").and_then(Value::as_str) == Some("expression")
590 && !branch.contains_key("when")
591 {
592 report.add(
593 "AG121",
594 "error",
595 format!("branch {label:?} has no 'when' but evaluator is 'expression'"),
596 format!("{pointer}/decision/branches/{index}"),
597 );
598 }
599 }
600 let duplicates: Vec<_> = labels
601 .iter()
602 .filter(|(_, count)| **count > 1)
603 .map(|(label, _)| label.clone())
604 .collect();
605 if !duplicates.is_empty() {
606 report.add(
607 "AG124",
608 "error",
609 format!("duplicate branch labels {duplicates:?}"),
610 &pointer,
611 );
612 }
613 if let Some(default) = decision.get("default_branch").and_then(Value::as_str) {
614 if !labels.contains_key(default) {
615 report.add(
616 "AG123",
617 "error",
618 format!("default_branch {default:?} is not a declared label"),
619 &pointer,
620 );
621 }
622 }
623 }
624 let outputs = object(node.get("outputs"));
625 let failure = object(node.get("failure"));
626 for (index, raw) in failure
627 .get("fallback")
628 .and_then(Value::as_array)
629 .into_iter()
630 .flatten()
631 .enumerate()
632 {
633 let step = object(Some(raw));
634 let location = format!("{pointer}/failure/fallback/{index}");
635 match step
636 .get("strategy")
637 .and_then(Value::as_str)
638 .unwrap_or_default()
639 {
640 "alternate_node" => {
641 let alternate = step.get("node").and_then(Value::as_str).unwrap_or_default();
642 if let Some(target) = scope.nodes.get(alternate) {
643 let available = declared_outputs(object(Some(target)));
644 let missing: Vec<_> = outputs
645 .iter()
646 .filter(|(_, spec)| {
647 object(Some(spec))
648 .get("required")
649 .and_then(Value::as_bool)
650 .unwrap_or(true)
651 })
652 .filter(|(name, _)| !available.contains(*name))
653 .map(|(name, _)| name.clone())
654 .collect();
655 if !missing.is_empty() {
656 report.add("AG151", "error", format!("fallback node {alternate:?} does not declare required outputs {missing:?}"), &location);
657 }
658 } else {
659 report.add(
660 "AG113",
661 "error",
662 format!("fallback node {alternate:?} does not exist"),
663 &location,
664 );
665 }
666 }
667 "relax_criteria" => {
668 let declared: BTreeSet<_> = object(node.get("success"))
669 .get("criteria")
670 .and_then(Value::as_array)
671 .into_iter()
672 .flatten()
673 .filter_map(|criterion| {
674 object(Some(criterion)).get("id").and_then(Value::as_str)
675 })
676 .collect();
677 let unknown: Vec<_> = strings(step.get("criteria"))
678 .into_iter()
679 .filter(|name| !declared.contains(name.as_str()))
680 .collect();
681 if !unknown.is_empty() {
682 report.add(
683 "AG153",
684 "error",
685 format!("unknown criteria {unknown:?}"),
686 &location,
687 );
688 }
689 }
690 "degrade_outputs" => {
691 let unknown: Vec<_> = strings(step.get("outputs"))
692 .into_iter()
693 .filter(|name| !outputs.contains_key(name))
694 .collect();
695 if !unknown.is_empty() {
696 report.add(
697 "AG153",
698 "error",
699 format!("unknown outputs {unknown:?}"),
700 &location,
701 );
702 }
703 }
704 _ => {}
705 }
706 }
707 if let Some(compensation) = failure.get("compensation").and_then(Value::as_str) {
708 if let Some(target) = scope.nodes.get(compensation) {
709 if object(object(Some(target)).get("failure")).contains_key("compensation") {
710 report.add(
711 "AG152",
712 "error",
713 format!("compensation node {compensation:?} declares its own compensation"),
714 &pointer,
715 );
716 }
717 } else {
718 report.add(
719 "AG113",
720 "error",
721 format!("compensation node {compensation:?} does not exist"),
722 &pointer,
723 );
724 }
725 }
726 let escalation = object(failure.get("escalation"));
727 if escalation.get("to").and_then(Value::as_str) == Some("node") {
728 let target = escalation
729 .get("node")
730 .and_then(Value::as_str)
731 .unwrap_or_default();
732 if !scope.nodes.contains_key(target) {
733 report.add(
734 "AG113",
735 "error",
736 format!("escalation node {target:?} does not exist"),
737 &pointer,
738 );
739 }
740 }
741 let requirements = object(node.get("requirements"));
742 let mut mutating = requirements.get("workspace").and_then(Value::as_str) == Some("read_write");
743 for permission in strings(requirements.get("permissions")) {
744 mutating |= [
745 "fs:write",
746 "fs:delete",
747 "git:commit",
748 "git:push",
749 "shell:exec",
750 ]
751 .iter()
752 .any(|prefix| permission.starts_with(prefix));
753 }
754 if mutating && !node.contains_key("success") && kind == "task" {
755 report.add(
756 "AG902",
757 "warning",
758 "side-effecting node declares no success block",
759 &pointer,
760 );
761 }
762 let success = object(node.get("success"));
763 let required_kinds: Vec<_> = success
764 .get("criteria")
765 .and_then(Value::as_array)
766 .into_iter()
767 .flatten()
768 .filter_map(|raw| {
769 let criterion = object(Some(raw));
770 let severity = criterion
771 .get("severity")
772 .and_then(Value::as_str)
773 .unwrap_or("required");
774 (severity == "required").then(|| {
775 criterion
776 .get("kind")
777 .and_then(Value::as_str)
778 .unwrap_or_default()
779 })
780 })
781 .collect();
782 if !required_kinds.is_empty()
783 && required_kinds
784 .iter()
785 .all(|kind| matches!(*kind, "llm_judge" | "human"))
786 {
787 report.add(
788 "AG906",
789 "warning",
790 "success block has no deterministic required criterion",
791 &pointer,
792 );
793 }
794 let constraints = object(node.get("constraints"));
795 if constraints.get("determinism").and_then(Value::as_str) == Some("strict")
796 && !constraints.contains_key("seed")
797 {
798 report.add(
799 "AG907",
800 "warning",
801 "determinism 'strict' without a seed",
802 &pointer,
803 );
804 }
805 walk_expressions(
806 Value::Object(node.clone()),
807 &pointer,
808 "",
809 scope,
810 id,
811 predecessors,
812 report,
813 );
814}
815
816fn walk_expressions(
817 value: Value,
818 pointer: &str,
819 key: &str,
820 scope: &Scope,
821 node_id: &str,
822 predecessors: &BTreeSet<String>,
823 report: &mut ValidationReport,
824) {
825 match value {
826 Value::Object(map) => {
827 for (child_key, child) in map {
828 if child_key != "body" && child_key != "inline" {
829 walk_expressions(
830 child,
831 &format!("{pointer}/{child_key}"),
832 &child_key,
833 scope,
834 node_id,
835 predecessors,
836 report,
837 );
838 }
839 }
840 }
841 Value::Array(items) => {
842 for (index, item) in items.into_iter().enumerate() {
843 walk_expressions(
844 item,
845 &format!("{pointer}/{index}"),
846 key,
847 scope,
848 node_id,
849 predecessors,
850 report,
851 );
852 }
853 }
854 Value::String(text) => {
855 let expression = matches!(
856 key,
857 "from" | "when" | "expr" | "target" | "condition" | "over"
858 );
859 if expression {
860 validate_expression(&text, pointer, scope, node_id, predecessors, report);
861 } else {
862 for inner in template_expressions(&text) {
863 validate_expression(inner, pointer, scope, node_id, predecessors, report);
864 }
865 }
866 }
867 _ => {}
868 }
869}
870
871fn template_expressions(text: &str) -> Vec<&str> {
872 let mut out = vec![];
873 let mut rest = text;
874 while let Some(start) = rest.find("${{") {
875 rest = &rest[start + 3..];
876 if let Some(end) = rest.find("}}") {
877 out.push(rest[..end].trim());
878 rest = &rest[end + 2..];
879 } else {
880 break;
881 }
882 }
883 out
884}
885
886fn validate_expression(
887 text: &str,
888 pointer: &str,
889 scope: &Scope,
890 node_id: &str,
891 predecessors: &BTreeSet<String>,
892 report: &mut ValidationReport,
893) {
894 if text.contains("${{") {
895 report.add(
896 "AG211",
897 "error",
898 "'${{ }}' interpolation used in expression position",
899 pointer,
900 );
901 return;
902 }
903 if text.trim().is_empty() {
904 return;
905 }
906 let parsed = match parse_expression(text) {
907 Ok(parsed) => parsed,
908 Err(error) => {
909 report.add(
910 "AG204",
911 "error",
912 format!("invalid expression: {error}"),
913 pointer,
914 );
915 return;
916 }
917 };
918 for call in parsed.calls {
919 let allowed = match call.name.as_str() {
920 "get" => (2, 3),
921 "len" | "count" | "lower" | "upper" | "trim" | "int" | "float" | "bool" | "str"
922 | "json" | "any" | "all" | "succeeded" | "failed" | "skipped" => (1, 1),
923 "contains" | "startswith" | "endswith" | "matches" | "split" | "join" | "default"
924 | "output" => (2, 2),
925 _ => {
926 report.add(
927 "AG204",
928 "error",
929 format!("unknown function {:?}", call.name),
930 pointer,
931 );
932 continue;
933 }
934 };
935 if call.arity < allowed.0 || call.arity > allowed.1 {
936 report.add(
937 "AG204",
938 "error",
939 format!("function {} received {} argument(s)", call.name, call.arity),
940 pointer,
941 );
942 }
943 }
944 for parts in parsed.references {
945 if parts.first().is_some_and(|part| part == "secrets") {
946 report.add(
947 "AG205",
948 "error",
949 "expressions must not reference secrets.*",
950 pointer,
951 );
952 continue;
953 }
954 if parts.first().is_some_and(|part| part == "params") {
955 if parts.len() >= 2 && !scope.param_names.contains(&parts[1]) {
956 report.add(
957 "AG203",
958 "error",
959 format!("undeclared param {:?}", parts[1]),
960 pointer,
961 );
962 }
963 continue;
964 }
965 if parts.first().is_some_and(|part| part == "nodes") && parts.len() >= 2 {
966 let target = &parts[1];
967 if !scope.nodes.contains_key(target) {
968 let child_bound = pointer.contains("/loop/condition")
969 || pointer.contains("/loop/collect/")
970 || pointer.contains("/map/collect/");
971 if !child_bound {
972 report.add(
973 if scope.root { "AG203" } else { "AG202" },
974 "error",
975 format!("unknown node {target:?}"),
976 pointer,
977 );
978 }
979 } else {
980 if parts.len() >= 4
981 && parts[2] == "outputs"
982 && !declared_outputs(object(scope.nodes.get(target))).contains(&parts[3])
983 {
984 report.add(
985 "AG206",
986 "error",
987 format!("node {target:?} does not declare output {:?}", parts[3]),
988 pointer,
989 );
990 } else if target != node_id && !predecessors.contains(target) {
991 report.add(
992 "AG201",
993 "error",
994 format!("node {node_id:?} reads output of non-predecessor {target:?}"),
995 pointer,
996 );
997 }
998 }
999 }
1000 }
1001}
1002
1003fn declared_outputs(node: &serde_json::Map<String, Value>) -> BTreeSet<String> {
1004 let mut outputs: BTreeSet<String> = object(node.get("outputs")).keys().cloned().collect();
1005 let kind = node.get("type").and_then(Value::as_str).unwrap_or("task");
1006 if matches!(kind, "decision" | "gate") {
1007 outputs.insert("decision".into());
1008 }
1009 let block = object(node.get(kind));
1010 if matches!(kind, "gate" | "loop" | "map") {
1011 outputs.extend(object(block.get("collect")).keys().cloned());
1012 }
1013 if kind == "subgraph" {
1014 outputs.extend(object(block.get("outputs_from")).keys().cloned());
1015 }
1016 outputs
1017}
1018
1019fn validate_recursion(document: &Document, report: &mut ValidationReport) {
1020 let fragments = object(document.get("subgraphs"));
1021 let mut dependencies: BTreeMap<String, Vec<String>> = BTreeMap::new();
1022 for (name, raw) in fragments {
1023 for node in object(object(Some(raw)).get("nodes")).values() {
1024 let node = object(Some(node));
1025 for kind in ["loop", "map", "subgraph"] {
1026 if let Some(used) = object(node.get(kind)).get("use").and_then(Value::as_str) {
1027 dependencies
1028 .entry(name.clone())
1029 .or_default()
1030 .push(used.into());
1031 }
1032 }
1033 }
1034 }
1035 fn visit(
1036 name: &str,
1037 dependencies: &BTreeMap<String, Vec<String>>,
1038 active: &mut Vec<String>,
1039 done: &mut BTreeSet<String>,
1040 report: &mut ValidationReport,
1041 ) {
1042 if let Some(at) = active.iter().position(|item| item == name) {
1043 let mut cycle = active[at..].to_vec();
1044 cycle.push(name.into());
1045 report.add(
1046 "AG131",
1047 "error",
1048 format!("recursive subgraph reference: {}", cycle.join(" -> ")),
1049 format!("/subgraphs/{name}"),
1050 );
1051 return;
1052 }
1053 if done.contains(name) {
1054 return;
1055 }
1056 active.push(name.into());
1057 for next in dependencies.get(name).into_iter().flatten() {
1058 visit(next, dependencies, active, done, report);
1059 }
1060 active.pop();
1061 done.insert(name.into());
1062 }
1063 let mut done = BTreeSet::new();
1064 for name in fragments.keys() {
1065 visit(name, &dependencies, &mut vec![], &mut done, report);
1066 }
1067}
1068
1069#[allow(dead_code)]
1070fn _root_edges(document: &Document) {
1071 let _ = graph_effective_edges(document);
1072}