runx-parser 0.6.19

Pure Rust parser parity crate for runx skills, graphs, and tool manifests.
Documentation
use std::collections::BTreeSet;

use runx_contracts::JsonObject;

use super::fanout::{validate_fanout_groups, validate_fanout_step_bindings};
use super::helpers::{
    optional_string, required_array, required_object, required_string, validation_error,
};
use super::policy::validate_graph_policy;
use super::step::validate_step;
use super::types::{ExecutionGraph, RawGraphIr};
use crate::{ParseError, ValidationError, assert_yaml_parity_subset};

pub fn parse_graph_yaml(source: &str) -> Result<RawGraphIr, ParseError> {
    assert_yaml_parity_subset("graph", source)?;
    let document: JsonObject =
        serde_norway::from_str(source).map_err(|error| ParseError::InvalidYaml {
            field: "graph".to_owned(),
            message: error.to_string(),
        })?;
    Ok(RawGraphIr { document })
}

pub fn validate_graph(raw: RawGraphIr) -> Result<ExecutionGraph, ValidationError> {
    validate_graph_document(raw.document.clone(), Some(raw))
}

pub fn validate_graph_document(
    document: JsonObject,
    raw: Option<RawGraphIr>,
) -> Result<ExecutionGraph, ValidationError> {
    reject_unsupported_top_level(&document)?;

    let name = required_string(document.get("name"), "name")?;
    let owner = optional_string(document.get("owner"), "owner")?;
    let charter_from = optional_string(document.get("charter_from"), "charter_from")?;
    let raw_steps = required_array(document.get("steps"), "steps")?;
    let fanout_groups = validate_fanout_groups(document.get("fanout"), "fanout")?;
    let policy = validate_graph_policy(document.get("policy"), "policy")?;
    let mut seen_step_ids = BTreeSet::new();
    let mut steps = Vec::new();

    for (index, raw_step) in raw_steps.iter().enumerate() {
        let field = format!("steps.{index}");
        let raw_step = required_object(Some(raw_step), &field)?;
        let step = validate_step(raw_step, &field, &seen_step_ids, charter_from.is_some())?;
        seen_step_ids.insert(step.id.clone());
        steps.push(step);
    }

    validate_fanout_step_bindings(&steps, &fanout_groups)?;

    Ok(ExecutionGraph {
        name,
        owner,
        charter_from,
        steps,
        fanout_groups,
        policy,
        raw: raw.unwrap_or(RawGraphIr { document }),
    })
}

fn reject_unsupported_top_level(document: &JsonObject) -> Result<(), ValidationError> {
    for field in ["sync", "schedule", "schedules"] {
        if document.contains_key(field) {
            return Err(validation_error(format!(
                "{field} is not supported by the local sequential graph runner."
            )));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{parse_graph_yaml, validate_graph};

    #[test]
    fn inputs_reject_previous_step_output_references() -> Result<(), String> {
        let raw = parse_graph_yaml(
            r#"
name: bad-input-ref
steps:
  - id: select
    run:
      type: agent-task
  - id: review
    run:
      type: agent-task
    inputs:
      bounty: select.result
"#,
        )
        .map_err(|error| error.to_string())?;
        let error = validate_graph(raw)
            .err()
            .ok_or_else(|| "graph unexpectedly validated".to_owned())?;
        let message = error.to_string();
        assert!(message.contains("steps.1.inputs.bounty"));
        assert!(message.contains("move it to context"));
        Ok(())
    }

    #[test]
    fn inputs_allow_literals_that_are_not_previous_step_refs() -> Result<(), String> {
        let raw = parse_graph_yaml(
            r#"
name: literal-input
steps:
  - id: review
    run:
      type: agent-task
    inputs:
      literal: select.result
      variable: $input.claim
      url: https://example.com/a.b
"#,
        )
        .map_err(|error| error.to_string())?;
        validate_graph(raw).map_err(|error| error.to_string())?;
        Ok(())
    }

    fn validate_yaml(source: &str) -> Result<crate::ExecutionGraph, String> {
        let raw = parse_graph_yaml(source).map_err(|error| error.to_string())?;
        validate_graph(raw).map_err(|error| error.to_string())
    }

    fn validate_err(source: &str) -> Result<String, String> {
        let raw = parse_graph_yaml(source).map_err(|error| error.to_string())?;
        validate_graph(raw)
            .err()
            .map(|error| error.to_string())
            .ok_or_else(|| "graph unexpectedly validated".to_owned())
    }

    #[test]
    fn charter_from_and_static_scope_mint_validate() -> Result<(), String> {
        let graph = validate_yaml(
            r#"
name: mint-static
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    scopes:
      - payments:spend
    mint_authority:
      source: static_scopes
"#,
        )?;
        assert_eq!(graph.charter_from.as_deref(), Some("charter"));
        let directive = graph.steps[0]
            .mint_authority
            .ok_or_else(|| "expected mint_authority".to_owned())?;
        assert_eq!(directive.source, crate::MintScopeSource::StaticScopes);
        Ok(())
    }

    #[test]
    fn requested_scope_mint_validates() -> Result<(), String> {
        let graph = validate_yaml(
            r#"
name: mint-dynamic
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    requested_scope_from: needed_scope
    mint_authority:
      source: requested_scope
"#,
        )?;
        let step = &graph.steps[0];
        assert_eq!(step.requested_scope_from.as_deref(), Some("needed_scope"));
        assert_eq!(
            step.mint_authority.map(|directive| directive.source),
            Some(crate::MintScopeSource::RequestedScope)
        );
        Ok(())
    }

    #[test]
    fn mint_authority_without_charter_is_rejected() -> Result<(), String> {
        let message = validate_err(
            r#"
name: mint-no-charter
steps:
  - id: dispatch
    run:
      type: agent-task
    scopes:
      - payments:spend
    mint_authority:
      source: static_scopes
"#,
        )?;
        assert!(message.contains("requires the graph to declare charter_from"));
        Ok(())
    }

    #[test]
    fn static_scopes_mint_rejects_requested_scope_from() -> Result<(), String> {
        let message = validate_err(
            r#"
name: mint-two-sources
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    scopes:
      - payments:spend
    requested_scope_from: needed_scope
    mint_authority:
      source: static_scopes
"#,
        )?;
        assert!(message.contains("must not declare requested_scope_from"));
        Ok(())
    }

    #[test]
    fn static_scopes_mint_requires_non_empty_scopes() -> Result<(), String> {
        let message = validate_err(
            r#"
name: mint-no-scopes
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    mint_authority:
      source: static_scopes
"#,
        )?;
        assert!(message.contains("requires a non-empty scopes list"));
        Ok(())
    }

    #[test]
    fn requested_scope_mint_requires_input_key() -> Result<(), String> {
        let message = validate_err(
            r#"
name: mint-missing-input
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    mint_authority:
      source: requested_scope
"#,
        )?;
        assert!(message.contains("requires requested_scope_from"));
        Ok(())
    }

    #[test]
    fn requested_scope_mint_rejects_static_scopes() -> Result<(), String> {
        let message = validate_err(
            r#"
name: mint-mixed
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    scopes:
      - payments:spend
    requested_scope_from: needed_scope
    mint_authority:
      source: requested_scope
"#,
        )?;
        assert!(message.contains("must not declare a static scopes list"));
        Ok(())
    }

    #[test]
    fn requested_scope_from_without_directive_is_rejected() -> Result<(), String> {
        let message = validate_err(
            r#"
name: dangling-requested-scope
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    requested_scope_from: needed_scope
"#,
        )?;
        assert!(message.contains("only valid with a mint_authority directive"));
        Ok(())
    }

    #[test]
    fn unknown_mint_source_is_rejected() -> Result<(), String> {
        let message = validate_err(
            r#"
name: bad-source
charter_from: charter
steps:
  - id: dispatch
    run:
      type: agent-task
    scopes:
      - payments:spend
    mint_authority:
      source: widen_everything
"#,
        )?;
        assert!(message.contains("must be static_scopes or requested_scope"));
        Ok(())
    }
}