Skip to main content

agentic_graph_spec/
validate.rs

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
15/// Runs embedded JSON Schema, semantic, topology, and AGX dataflow validation.
16pub 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
75/// Loads and validates an AGS document, returning parse failures as diagnostics.
76pub 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}