diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index db16782..0012724 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -9,11 +9,16 @@ on: - 'mkdocs.yml' - 'scripts/generate_supported_formats_doc.py' - 'scripts/generate_tool_registry_doc.py' + - 'scripts/generate_spec_v2_reference.py' + - 'schemas/renderflow-v2.schema.json' + - 'examples/renderflow-v2.yaml' - 'crates/renderflow-core/data/tool-registry.yaml' - 'crates/renderflow-core/src/audio/format.rs' - 'crates/renderflow-core/src/graph/format.rs' - 'crates/renderflow-core/src/image/format.rs' - 'crates/renderflow-core/src/input_format.rs' + - 'crates/renderflow-core/src/spec.rs' + - 'crates/renderflow-core/src/commands/spec.rs' - 'crates/renderflow-core/src/toolchain.rs' push: branches: @@ -27,11 +32,16 @@ on: - 'mkdocs.yml' - 'scripts/generate_supported_formats_doc.py' - 'scripts/generate_tool_registry_doc.py' + - 'scripts/generate_spec_v2_reference.py' + - 'schemas/renderflow-v2.schema.json' + - 'examples/renderflow-v2.yaml' - 'crates/renderflow-core/data/tool-registry.yaml' - 'crates/renderflow-core/src/audio/format.rs' - 'crates/renderflow-core/src/graph/format.rs' - 'crates/renderflow-core/src/image/format.rs' - 'crates/renderflow-core/src/input_format.rs' + - 'crates/renderflow-core/src/spec.rs' + - 'crates/renderflow-core/src/commands/spec.rs' - 'crates/renderflow-core/src/toolchain.rs' workflow_dispatch: @@ -53,6 +63,11 @@ jobs: with: fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.94" + - name: Setup Python uses: actions/setup-python@v5 with: @@ -63,13 +78,20 @@ jobs: python -m pip install --upgrade pip pip install mkdocs-material mike pyyaml - - name: Regenerate generated docs + - name: Regenerate generated docs and schemas run: | python scripts/generate_supported_formats_doc.py python scripts/generate_tool_registry_doc.py + cargo run --quiet -p renderflow-cli -- spec schema --format json --output schemas/renderflow-v2.schema.json + python scripts/generate_spec_v2_reference.py - - name: Verify generated docs are committed - run: git diff --exit-code -- docs/user-guide/supported-formats.md docs/user-guide/tool-registry.md + - name: Verify generated docs and schemas are committed + run: | + git diff --exit-code -- \ + docs/user-guide/supported-formats.md \ + docs/user-guide/tool-registry.md \ + docs/user-guide/spec-v2-reference.md \ + schemas/renderflow-v2.schema.json - name: Build docs run: mkdocs build --strict @@ -91,6 +113,11 @@ jobs: with: fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.94" + - name: Setup Python uses: actions/setup-python@v5 with: @@ -101,10 +128,12 @@ jobs: python -m pip install --upgrade pip pip install mkdocs-material mike pyyaml - - name: Regenerate generated docs + - name: Regenerate generated docs and schemas run: | python scripts/generate_supported_formats_doc.py python scripts/generate_tool_registry_doc.py + cargo run --quiet -p renderflow-cli -- spec schema --format json --output schemas/renderflow-v2.schema.json + python scripts/generate_spec_v2_reference.py - name: Configure Pages uses: actions/configure-pages@v5 diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index f7cef06..9263c40 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -2,7 +2,9 @@ use anyhow::{bail, Result}; use clap::Parser; use tracing::info; -use crate::cli::{AiCommands, Cli, Commands, GraphCommands, PluginCommands, ToolCommands}; +use crate::cli::{ + AiCommands, Cli, Commands, GraphCommands, PluginCommands, SpecCommands, ToolCommands, +}; use crate::{commands, transforms}; /// Initialize logging for a Renderflow CLI run. @@ -47,22 +49,15 @@ pub fn run_cli(cli: Cli) -> Result<()> { target, all, export, - }) => { - commands::inspect::run( - &config, - &output_format, - target.as_deref(), - all, - export.as_deref(), - None, // optimization: use the mode from the config file - )? - } + }) => commands::inspect::run( + &config, + &output_format, + target.as_deref(), + all, + export.as_deref(), + None, + )?, Some(Commands::Plugin { subcommand }) => { - // The plugin registry is empty at the top-level CLI entry point. - // Third-party plugins are registered programmatically before - // calling renderflow as a library. The CLI commands are - // primarily useful when renderflow is embedded in a larger - // application that populates the registry before dispatching. let registry = transforms::plugin::PluginRegistry::new(); match subcommand { PluginCommands::List => commands::plugin::run_list(®istry)?, @@ -146,6 +141,17 @@ pub fn run_cli(cli: Cli) -> Result<()> { Some(Commands::Capabilities { format, transforms }) => { commands::tools::run_capabilities(transforms.as_deref(), &format)? } + Some(Commands::Spec { subcommand }) => match subcommand { + SpecCommands::Validate { config, format } => { + commands::spec::run_validate(&config, &format)? + } + SpecCommands::Migrate { config, output } => { + commands::spec::run_migrate(&config, output.as_deref())? + } + SpecCommands::Schema { format, output } => { + commands::spec::run_schema(&format, output.as_deref())? + } + }, Some(Commands::Version) => commands::system::run_version(), Some(Commands::Env) => commands::system::run_env(), Some(Commands::Doctor { strict }) => commands::system::run_doctor(strict)?, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 76a676b..90699ad 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -30,6 +30,8 @@ use crate::optimization::OptimizationMode; renderflow tools list List runtime tool providers\n \ renderflow tools inspect tool.ffmpeg Inspect one runtime provider\n \ renderflow capabilities List provider capability IDs\n \ + renderflow spec validate Validate v1/v2 execution specifications\n \ + renderflow spec migrate Migrate unversioned v1 config to v2\n \ renderflow my-project.yaml Shorthand: run build on the given config" )] pub struct Cli { @@ -218,6 +220,13 @@ pub enum Commands { transforms: Option, }, + /// Validate, migrate, and export the Renderflow execution specification. + #[command(subcommand_required = true, arg_required_else_help = true)] + Spec { + #[command(subcommand)] + subcommand: SpecCommands, + }, + /// Print the installed Renderflow version Version, @@ -232,6 +241,34 @@ pub enum Commands { }, } +/// Subcommands for `renderflow spec`. +#[derive(Subcommand)] +pub enum SpecCommands { + /// Validate an unversioned v1 config or a versioned v2 spec. + Validate { + #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] + config: String, + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + + /// Migrate an unversioned v1 config to the v2 execution specification. + Migrate { + #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] + config: String, + #[arg(long, short = 'o', value_name = "FILE")] + output: Option, + }, + + /// Emit the canonical v2 JSON Schema used by the runtime. + Schema { + #[arg(long, default_value = "json", value_name = "FORMAT")] + format: String, + #[arg(long, short = 'o', value_name = "FILE")] + output: Option, + }, +} + /// Subcommands for `renderflow plugin`. #[derive(Subcommand)] pub enum PluginCommands { diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index ba1e8a2..73babad 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod graph; pub mod graph_build; pub mod inspect; pub mod plugin; +pub mod spec; pub mod system; pub mod tools; pub mod watch; diff --git a/crates/renderflow-core/src/commands/spec.rs b/crates/renderflow-core/src/commands/spec.rs new file mode 100644 index 0000000..8ef77b6 --- /dev/null +++ b/crates/renderflow-core/src/commands/spec.rs @@ -0,0 +1,116 @@ +use std::fs; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::spec::{ + json_schema_pretty, migrate_v1_file, validate_spec_file, SourceSpecVersion, SPEC_V2_ID, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFormat { + Text, + Json, + Yaml, +} + +impl OutputFormat { + fn parse(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "text" => Ok(Self::Text), + "json" => Ok(Self::Json), + "yaml" | "yml" => Ok(Self::Yaml), + other => { + anyhow::bail!("unknown spec output format '{other}'; supported: text, json, yaml") + } + } + } +} + +fn write_output(content: &str, output: Option<&str>) -> Result<()> { + if let Some(path) = output { + fs::write(path, content).with_context(|| format!("failed to write '{path}'"))?; + } else { + print!("{content}"); + } + Ok(()) +} + +fn serialize(value: &T, format: OutputFormat) -> Result { + match format { + OutputFormat::Text => anyhow::bail!("text output requires a dedicated renderer"), + OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)), + OutputFormat::Yaml => Ok(serde_yaml_ng::to_string(value)?), + } +} + +pub fn run_validate(config: &str, format: &str) -> Result<()> { + let report = validate_spec_file(config); + let format = OutputFormat::parse(format)?; + + match format { + OutputFormat::Text => { + if report.valid { + let version = match report.source_version { + Some(SourceSpecVersion::V1) => "v1 compatibility", + Some(SourceSpecVersion::V2) => SPEC_V2_ID, + None => "unknown", + }; + println!("Renderflow spec: valid ({version})"); + } else { + eprintln!("Renderflow spec: invalid"); + for diagnostic in &report.diagnostics { + eprintln!( + " {} [{}] {}", + diagnostic.path, diagnostic.code, diagnostic.message + ); + } + } + } + OutputFormat::Json | OutputFormat::Yaml => { + print!("{}", serialize(&report, format)?); + } + } + + if !report.valid { + anyhow::bail!( + "spec validation failed with {} diagnostic(s)", + report.diagnostics.len() + ); + } + Ok(()) +} + +pub fn run_migrate(config: &str, output: Option<&str>) -> Result<()> { + let migrated = migrate_v1_file(config)?; + let yaml = + serde_yaml_ng::to_string(&migrated).context("failed to serialize migrated v2 spec")?; + write_output(&yaml, output) +} + +pub fn run_schema(format: &str, output: Option<&str>) -> Result<()> { + let format = OutputFormat::parse(format)?; + let schema = crate::spec::json_schema(); + let content = match format { + OutputFormat::Text | OutputFormat::Json => json_schema_pretty()?, + OutputFormat::Yaml => serde_yaml_ng::to_string(&schema)?, + }; + write_output(&content, output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_format_rejects_unknown_value() { + assert!(OutputFormat::parse("toml").is_err()); + } + + #[test] + fn schema_command_serialization_is_machine_readable() { + let json = json_schema_pretty().expect("schema serializes"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("schema is JSON"); + assert_eq!(parsed["properties"]["schema"]["const"], SPEC_V2_ID); + } +} diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 8884b8b..0c36296 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -26,6 +26,7 @@ pub mod optimization; mod pipeline; pub mod process; mod sdk; +pub mod spec; pub mod strategies; mod template; pub mod toolchain; diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs new file mode 100644 index 0000000..7ab2557 --- /dev/null +++ b/crates/renderflow-core/src/spec.rs @@ -0,0 +1,1225 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::config::Config; +use crate::optimization::OptimizationMode; + +pub const SPEC_V2_ID: &str = "renderflow/v2"; +pub const SPEC_V2_SCHEMA_PATH: &str = "schemas/renderflow-v2.schema.json"; + +fn default_true() -> bool { + true +} + +fn default_bundle_root() -> String { + "dist".to_string() +} + +fn default_naming_template() -> String { + "{source.id}/{target.role}.{ext}".to_string() +} + +fn default_max_parallel() -> usize { + 1 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + #[default] + Artifact, + Collection, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceSpec { + pub id: String, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub kind: SourceKind, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub members: Vec, + #[serde(default)] + pub media_type: Option, + #[serde(default)] + pub format: Option, + #[serde(default = "default_true")] + pub detect: bool, + #[serde(default = "default_true")] + pub immutable: bool, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SelectorSet { + #[serde(default)] + pub formats: Vec, + #[serde(default)] + pub families: Vec, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub transforms: Vec, + #[serde(default)] + pub profiles: Vec, +} + +impl SelectorSet { + pub fn is_empty(&self) -> bool { + self.formats.is_empty() + && self.families.is_empty() + && self.capabilities.is_empty() + && self.transforms.is_empty() + && self.profiles.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TargetSpec { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub role: Option, + #[serde(default)] + pub format: Option, + #[serde(default)] + pub family: Option, + #[serde(default)] + pub capability: Option, + #[serde(default)] + pub transform: Option, + #[serde(default)] + pub preset: Option, + #[serde(default)] + pub template: Option, +} + +impl TargetSpec { + fn has_selector(&self) -> bool { + self.format.is_some() + || self.family.is_some() + || self.capability.is_some() + || self.transform.is_some() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IntermediatePolicy { + #[default] + CacheOnly, + Retain, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TargetSelection { + #[serde(default)] + pub exact: Vec, + #[serde(default)] + pub profiles: Vec, + #[serde(default)] + pub all_reachable: bool, + #[serde(default)] + pub include: SelectorSet, + #[serde(default)] + pub exclude: SelectorSet, + #[serde(default)] + pub intermediates: IntermediatePolicy, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DerivativeProfile { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub targets: Vec, + #[serde(default)] + pub include: SelectorSet, + #[serde(default)] + pub exclude: SelectorSet, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AllowDenyPolicy { + #[serde(default)] + pub allow: Vec, + #[serde(default)] + pub deny: Vec, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceBudgets { + #[serde(default)] + pub max_output_bytes: Option, + #[serde(default)] + pub max_storage_bytes: Option, + #[serde(default)] + pub max_artifacts: Option, + #[serde(default)] + pub max_depth: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionRequirements { + #[serde(default)] + pub deterministic: bool, + #[serde(default)] + pub local_only: bool, + #[serde(default)] + pub offline: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NetworkPolicy { + #[default] + Deny, + Allow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiPolicy { + #[default] + Deny, + LocalOnly, + Allow, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ValidationPolicy { + #[serde(default = "default_true")] + pub required: bool, + #[serde(default)] + pub validators: Vec, +} + +impl Default for ValidationPolicy { + fn default() -> Self { + Self { + required: true, + validators: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExecutionPolicy { + #[serde(default)] + pub optimization: OptimizationMode, + #[serde(default = "default_max_parallel")] + pub max_parallel: usize, + #[serde(default)] + pub budgets: ResourceBudgets, + #[serde(default)] + pub tools: AllowDenyPolicy, + #[serde(default)] + pub transforms: AllowDenyPolicy, + #[serde(default)] + pub requirements: ExecutionRequirements, + #[serde(default)] + pub network: NetworkPolicy, + #[serde(default)] + pub ai: AiPolicy, + #[serde(default)] + pub retry_policy: Option, + #[serde(default)] + pub timeout_policy: Option, + #[serde(default)] + pub validation: ValidationPolicy, + #[serde(default)] + pub minimum_fidelity: Option, + #[serde(default)] + pub publication_policy: Option, + #[serde(default)] + pub redaction_policy: Option, +} + +impl Default for ExecutionPolicy { + fn default() -> Self { + Self { + optimization: OptimizationMode::default(), + max_parallel: default_max_parallel(), + budgets: ResourceBudgets::default(), + tools: AllowDenyPolicy::default(), + transforms: AllowDenyPolicy::default(), + requirements: ExecutionRequirements::default(), + network: NetworkPolicy::Deny, + ai: AiPolicy::Deny, + retry_policy: None, + timeout_policy: None, + validation: ValidationPolicy::default(), + minimum_fidelity: None, + publication_policy: None, + redaction_policy: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CollisionPolicy { + #[default] + Error, + Replace, + Dedupe, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputLayout { + #[serde(default = "default_bundle_root")] + pub bundle_root: String, + #[serde(default = "default_naming_template")] + pub naming_template: String, + #[serde(default)] + pub collision: CollisionPolicy, +} + +impl Default for OutputLayout { + fn default() -> Self { + Self { + bundle_root: default_bundle_root(), + naming_template: default_naming_template(), + collision: CollisionPolicy::Error, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SpecV2 { + pub schema: String, + pub sources: Vec, + #[serde(default)] + pub profiles: BTreeMap, + pub targets: TargetSelection, + #[serde(default)] + pub execution: ExecutionPolicy, + #[serde(default)] + pub output: OutputLayout, + #[serde(default)] + pub variables: BTreeMap, + #[serde(default)] + pub transforms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceSpecVersion { + V1, + V2, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LoadedSpec { + pub source_version: SourceSpecVersion, + pub spec: SpecV2, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpecDiagnostic { + pub path: String, + pub code: String, + pub message: String, +} + +impl SpecDiagnostic { + fn new(path: impl Into, code: impl Into, message: impl Into) -> Self { + Self { + path: path.into(), + code: code.into(), + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpecValidationReport { + pub valid: bool, + pub source_version: Option, + pub schema: Option, + pub diagnostics: Vec, +} + +impl fmt::Display for SpecValidationReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.valid { + write!(f, "spec is valid") + } else { + for diagnostic in &self.diagnostics { + writeln!( + f, + "{} [{}]: {}", + diagnostic.path, diagnostic.code, diagnostic.message + )?; + } + Ok(()) + } + } +} + +impl SpecV2 { + pub fn validate(&self) -> Vec { + let mut diagnostics = Vec::new(); + + if self.schema != SPEC_V2_ID { + diagnostics.push(SpecDiagnostic::new( + "$.schema", + "schema.unsupported", + format!("expected schema '{SPEC_V2_ID}', got '{}'", self.schema), + )); + } + + if self.sources.is_empty() { + diagnostics.push(SpecDiagnostic::new( + "$.sources", + "sources.empty", + "at least one source artifact is required", + )); + } + + let mut source_ids = BTreeSet::new(); + for (index, source) in self.sources.iter().enumerate() { + let base = format!("$.sources[{index}]"); + if !is_stable_id(&source.id) { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.id"), + "source.id.invalid", + "source id must use only ASCII letters, digits, '.', '_', or '-'", + )); + } + if !source_ids.insert(source.id.clone()) { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.id"), + "source.id.duplicate", + format!("source id '{}' is declared more than once", source.id), + )); + } + if !source.immutable { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.immutable"), + "source.mutable", + "v2 sources are immutable inputs; copy or derive a new artifact instead", + )); + } + + match source.kind { + SourceKind::Artifact => { + let locator_count = + usize::from(source.path.is_some()) + usize::from(source.uri.is_some()); + if locator_count != 1 { + diagnostics.push(SpecDiagnostic::new( + base.clone(), + "source.locator.invalid", + "artifact sources require exactly one of 'path' or 'uri'", + )); + } + if !source.members.is_empty() { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.members"), + "source.members.unexpected", + "artifact sources cannot declare collection members", + )); + } + } + SourceKind::Collection => { + if source.path.is_some() || source.uri.is_some() { + diagnostics.push(SpecDiagnostic::new( + base.clone(), + "collection.locator.unexpected", + "collection sources reference member source ids instead of a path or uri", + )); + } + if source.members.is_empty() { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.members"), + "collection.members.empty", + "ordered collections require at least one member source id", + )); + } + } + } + } + + for (index, source) in self.sources.iter().enumerate() { + if source.kind == SourceKind::Collection { + for (member_index, member) in source.members.iter().enumerate() { + if !source_ids.contains(member) { + diagnostics.push(SpecDiagnostic::new( + format!("$.sources[{index}].members[{member_index}]"), + "collection.member.unknown", + format!( + "collection member '{member}' does not match a declared source id" + ), + )); + } + if member == &source.id { + diagnostics.push(SpecDiagnostic::new( + format!("$.sources[{index}].members[{member_index}]"), + "collection.member.self_reference", + "a collection cannot contain itself", + )); + } + } + } + } + + if self.targets.exact.is_empty() + && self.targets.profiles.is_empty() + && !self.targets.all_reachable + { + diagnostics.push(SpecDiagnostic::new( + "$.targets", + "targets.empty", + "declare at least one exact target, named profile, or all_reachable: true", + )); + } + + validate_targets(&self.targets.exact, "$.targets.exact", &mut diagnostics); + + for (index, profile_name) in self.targets.profiles.iter().enumerate() { + if !self.profiles.contains_key(profile_name) { + diagnostics.push(SpecDiagnostic::new( + format!("$.targets.profiles[{index}]"), + "profile.unknown", + format!("target profile '{profile_name}' is not declared in $.profiles"), + )); + } + } + + for (profile_name, profile) in &self.profiles { + let base = format!("$.profiles.{profile_name}"); + if !is_stable_id(profile_name) { + diagnostics.push(SpecDiagnostic::new( + base.clone(), + "profile.id.invalid", + "profile names must use only ASCII letters, digits, '.', '_', or '-'", + )); + } + if profile.targets.is_empty() && profile.include.is_empty() { + diagnostics.push(SpecDiagnostic::new( + base.clone(), + "profile.empty", + "a derivative profile must declare targets or include selectors", + )); + } + validate_targets( + &profile.targets, + &format!("{base}.targets"), + &mut diagnostics, + ); + } + + if self.execution.max_parallel == 0 { + diagnostics.push(SpecDiagnostic::new( + "$.execution.max_parallel", + "execution.concurrency.invalid", + "max_parallel must be at least 1", + )); + } + + validate_optional_positive( + self.execution.budgets.max_output_bytes, + "$.execution.budgets.max_output_bytes", + &mut diagnostics, + ); + validate_optional_positive( + self.execution.budgets.max_storage_bytes, + "$.execution.budgets.max_storage_bytes", + &mut diagnostics, + ); + validate_optional_positive( + self.execution.budgets.max_artifacts, + "$.execution.budgets.max_artifacts", + &mut diagnostics, + ); + if self.execution.budgets.max_depth == Some(0) { + diagnostics.push(SpecDiagnostic::new( + "$.execution.budgets.max_depth", + "execution.budget.invalid", + "max_depth must be greater than zero when provided", + )); + } + + validate_allow_deny(&self.execution.tools, "$.execution.tools", &mut diagnostics); + validate_allow_deny( + &self.execution.transforms, + "$.execution.transforms", + &mut diagnostics, + ); + + if let Some(fidelity) = self.execution.minimum_fidelity { + if !(0.0..=1.0).contains(&fidelity) { + diagnostics.push(SpecDiagnostic::new( + "$.execution.minimum_fidelity", + "execution.fidelity.invalid", + "minimum_fidelity must be between 0.0 and 1.0 inclusive", + )); + } + } + + if self.output.bundle_root.trim().is_empty() { + diagnostics.push(SpecDiagnostic::new( + "$.output.bundle_root", + "output.bundle_root.empty", + "bundle_root must not be empty", + )); + } + if self.output.naming_template.trim().is_empty() { + diagnostics.push(SpecDiagnostic::new( + "$.output.naming_template", + "output.naming_template.empty", + "naming_template must not be empty", + )); + } + + diagnostics + } +} + +fn validate_targets(targets: &[TargetSpec], base: &str, diagnostics: &mut Vec) { + let mut ids = BTreeSet::new(); + for (index, target) in targets.iter().enumerate() { + let path = format!("{base}[{index}]"); + if !target.has_selector() { + diagnostics.push(SpecDiagnostic::new( + path.clone(), + "target.selector.empty", + "target must select by format, family, capability, transform, or profile", + )); + } + if let Some(id) = &target.id { + if !is_stable_id(id) { + diagnostics.push(SpecDiagnostic::new( + format!("{path}.id"), + "target.id.invalid", + "target id must use only ASCII letters, digits, '.', '_', or '-'", + )); + } + if !ids.insert(id.clone()) { + diagnostics.push(SpecDiagnostic::new( + format!("{path}.id"), + "target.id.duplicate", + format!("target id '{id}' is declared more than once in this target list"), + )); + } + } + } +} + +fn validate_optional_positive( + value: Option, + path: &str, + diagnostics: &mut Vec, +) { + if value == Some(0) { + diagnostics.push(SpecDiagnostic::new( + path, + "execution.budget.invalid", + "budget must be greater than zero when provided", + )); + } +} + +fn validate_allow_deny( + policy: &AllowDenyPolicy, + base: &str, + diagnostics: &mut Vec, +) { + let allowed: BTreeSet<&str> = policy.allow.iter().map(String::as_str).collect(); + for (index, denied) in policy.deny.iter().enumerate() { + if allowed.contains(denied.as_str()) { + diagnostics.push(SpecDiagnostic::new( + format!("{base}.deny[{index}]"), + "policy.allow_deny.conflict", + format!("'{denied}' appears in both allow and deny lists"), + )); + } + } +} + +fn is_stable_id(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +pub fn validate_spec_file(path: &str) -> SpecValidationReport { + match fs::read_to_string(path) { + Ok(content) => validate_spec_str(&content), + Err(error) => SpecValidationReport { + valid: false, + source_version: None, + schema: None, + diagnostics: vec![SpecDiagnostic::new( + "$", + "io.read", + format!("failed to read spec '{path}': {error}"), + )], + }, + } +} + +pub fn validate_spec_str(content: &str) -> SpecValidationReport { + let root: serde_yaml_ng::Value = match serde_yaml_ng::from_str(content) { + Ok(value) => value, + Err(error) => { + return SpecValidationReport { + valid: false, + source_version: None, + schema: None, + diagnostics: vec![SpecDiagnostic::new("$", "yaml.parse", error.to_string())], + }; + } + }; + + let schema = root + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml_ng::Value::String("schema".to_string()))) + .and_then(serde_yaml_ng::Value::as_str) + .map(str::to_string); + + match schema.as_deref() { + None => validate_v1_compat(content), + Some(SPEC_V2_ID) => validate_v2(content), + Some(other) => SpecValidationReport { + valid: false, + source_version: None, + schema: Some(other.to_string()), + diagnostics: vec![SpecDiagnostic::new( + "$.schema", + "schema.unsupported", + format!( + "unsupported renderflow schema '{other}'; supported: unversioned v1 compatibility or '{SPEC_V2_ID}'" + ), + )], + }, + } +} + +fn validate_v1_compat(content: &str) -> SpecValidationReport { + match serde_yaml_ng::from_str::(content) { + Ok(config) => match config.validate() { + Ok(()) => { + let migrated = migrate_v1_config(&config); + let diagnostics = migrated.validate(); + SpecValidationReport { + valid: diagnostics.is_empty(), + source_version: Some(SourceSpecVersion::V1), + schema: None, + diagnostics, + } + } + Err(error) => SpecValidationReport { + valid: false, + source_version: Some(SourceSpecVersion::V1), + schema: None, + diagnostics: vec![SpecDiagnostic::new( + "$", + "v1.compat.invalid", + error.to_string(), + )], + }, + }, + Err(error) => SpecValidationReport { + valid: false, + source_version: Some(SourceSpecVersion::V1), + schema: None, + diagnostics: vec![SpecDiagnostic::new( + "$", + "v1.compat.parse", + error.to_string(), + )], + }, + } +} + +fn validate_v2(content: &str) -> SpecValidationReport { + match serde_yaml_ng::from_str::(content) { + Ok(spec) => { + let diagnostics = spec.validate(); + SpecValidationReport { + valid: diagnostics.is_empty(), + source_version: Some(SourceSpecVersion::V2), + schema: Some(spec.schema.clone()), + diagnostics, + } + } + Err(error) => SpecValidationReport { + valid: false, + source_version: Some(SourceSpecVersion::V2), + schema: Some(SPEC_V2_ID.to_string()), + diagnostics: vec![SpecDiagnostic::new("$", "v2.parse", error.to_string())], + }, + } +} + +pub fn load_spec(path: &str) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read Renderflow spec: {path}"))?; + load_spec_str(&content) +} + +pub fn load_spec_str(content: &str) -> Result { + let report = validate_spec_str(content); + if !report.valid { + anyhow::bail!("Renderflow spec validation failed:\n{report}"); + } + + match report.source_version { + Some(SourceSpecVersion::V2) => Ok(LoadedSpec { + source_version: SourceSpecVersion::V2, + spec: serde_yaml_ng::from_str(content).context("failed to parse validated v2 spec")?, + }), + Some(SourceSpecVersion::V1) => { + let config: Config = + serde_yaml_ng::from_str(content).context("failed to parse validated v1 config")?; + Ok(LoadedSpec { + source_version: SourceSpecVersion::V1, + spec: migrate_v1_config(&config), + }) + } + None => anyhow::bail!("spec version could not be determined"), + } +} + +pub fn migrate_v1_file(path: &str) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read v1 Renderflow config: {path}"))?; + migrate_v1_str(&content) +} + +pub fn migrate_v1_str(content: &str) -> Result { + let root: serde_yaml_ng::Value = + serde_yaml_ng::from_str(content).context("failed to parse Renderflow YAML")?; + if root + .as_mapping() + .and_then(|mapping| mapping.get(serde_yaml_ng::Value::String("schema".to_string()))) + .is_some() + { + anyhow::bail!( + "migration expects an unversioned v1 config; input already declares a schema" + ); + } + + let config: Config = serde_yaml_ng::from_str(content).context("failed to parse v1 config")?; + config.validate()?; + let migrated = migrate_v1_config(&config); + let diagnostics = migrated.validate(); + if !diagnostics.is_empty() { + let report = SpecValidationReport { + valid: false, + source_version: Some(SourceSpecVersion::V2), + schema: Some(SPEC_V2_ID.to_string()), + diagnostics, + }; + anyhow::bail!("migrated v2 spec is invalid:\n{report}"); + } + Ok(migrated) +} + +pub(crate) fn migrate_v1_config(config: &Config) -> SpecV2 { + let exact = config + .outputs + .iter() + .enumerate() + .map(|(index, output)| TargetSpec { + id: Some(format!("target.{}", index + 1)), + role: Some(output.output_type.to_string()), + format: Some(output.output_type.to_string()), + family: None, + capability: None, + transform: None, + preset: output.profile.clone(), + template: output.template.clone(), + }) + .collect(); + + let mut variables = BTreeMap::new(); + variables.extend(config.variables.clone()); + + SpecV2 { + schema: SPEC_V2_ID.to_string(), + sources: vec![SourceSpec { + id: "source.main".to_string(), + role: Some("primary".to_string()), + kind: SourceKind::Artifact, + path: Some(config.input.clone()), + uri: None, + members: Vec::new(), + media_type: None, + format: Some(config.input_format().to_string()), + detect: config.input_format.is_none(), + immutable: true, + }], + profiles: BTreeMap::new(), + targets: TargetSelection { + exact, + profiles: Vec::new(), + all_reachable: false, + include: SelectorSet::default(), + exclude: SelectorSet::default(), + intermediates: IntermediatePolicy::CacheOnly, + }, + execution: ExecutionPolicy { + optimization: config.optimization, + ..ExecutionPolicy::default() + }, + output: OutputLayout { + bundle_root: config.output_dir.clone(), + ..OutputLayout::default() + }, + variables, + transforms: config.transforms.clone(), + } +} + +pub fn json_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-v2.schema.json", + "title": "Renderflow execution specification v2", + "description": "Declarative source, derivative target, execution policy, and output-layout intent consumed by the Renderflow planner.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "sources", "targets"], + "properties": { + "schema": {"const": SPEC_V2_ID}, + "sources": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/source"}}, + "profiles": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/profile"}, + "default": {} + }, + "targets": {"$ref": "#/$defs/targetSelection"}, + "execution": {"$ref": "#/$defs/executionPolicy"}, + "output": {"$ref": "#/$defs/outputLayout"}, + "variables": { + "type": "object", + "additionalProperties": {"type": "string"}, + "default": {} + }, + "transforms": {"type": ["string", "null"]} + }, + "$defs": { + "stableId": {"type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9._-]+$"}, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": {"$ref": "#/$defs/stableId"}, + "role": {"type": ["string", "null"]}, + "kind": {"enum": ["artifact", "collection"], "default": "artifact"}, + "path": {"type": ["string", "null"]}, + "uri": {"type": ["string", "null"]}, + "members": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, + "media_type": {"type": ["string", "null"]}, + "format": {"type": ["string", "null"]}, + "detect": {"type": "boolean", "default": true}, + "immutable": {"const": true, "default": true} + } + }, + "selectorSet": { + "type": "object", + "additionalProperties": false, + "properties": { + "formats": {"type": "array", "items": {"type": "string"}, "default": []}, + "families": {"type": "array", "items": {"type": "string"}, "default": []}, + "capabilities": {"type": "array", "items": {"type": "string"}, "default": []}, + "transforms": {"type": "array", "items": {"type": "string"}, "default": []}, + "profiles": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []} + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]}, + "role": {"type": ["string", "null"]}, + "format": {"type": ["string", "null"]}, + "family": {"type": ["string", "null"]}, + "capability": {"type": ["string", "null"]}, + "transform": {"type": ["string", "null"]}, + "preset": {"type": ["string", "null"]}, + "template": {"type": ["string", "null"]} + }, + "anyOf": [ + {"required": ["format"]}, + {"required": ["family"]}, + {"required": ["capability"]}, + {"required": ["transform"]} + ] + }, + "targetSelection": { + "type": "object", + "additionalProperties": false, + "properties": { + "exact": {"type": "array", "items": {"$ref": "#/$defs/target"}, "default": []}, + "profiles": {"type": "array", "items": {"$ref": "#/$defs/stableId"}, "default": []}, + "all_reachable": {"type": "boolean", "default": false}, + "include": {"$ref": "#/$defs/selectorSet"}, + "exclude": {"$ref": "#/$defs/selectorSet"}, + "intermediates": {"enum": ["cache_only", "retain"], "default": "cache_only"} + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "properties": { + "description": {"type": ["string", "null"]}, + "targets": {"type": "array", "items": {"$ref": "#/$defs/target"}, "default": []}, + "include": {"$ref": "#/$defs/selectorSet"}, + "exclude": {"$ref": "#/$defs/selectorSet"} + } + }, + "allowDeny": { + "type": "object", + "additionalProperties": false, + "properties": { + "allow": {"type": "array", "items": {"type": "string"}, "default": []}, + "deny": {"type": "array", "items": {"type": "string"}, "default": []} + } + }, + "budgets": { + "type": "object", + "additionalProperties": false, + "properties": { + "max_output_bytes": {"type": ["integer", "null"], "minimum": 1}, + "max_storage_bytes": {"type": ["integer", "null"], "minimum": 1}, + "max_artifacts": {"type": ["integer", "null"], "minimum": 1}, + "max_depth": {"type": ["integer", "null"], "minimum": 1} + } + }, + "requirements": { + "type": "object", + "additionalProperties": false, + "properties": { + "deterministic": {"type": "boolean", "default": false}, + "local_only": {"type": "boolean", "default": false}, + "offline": {"type": "boolean", "default": false} + } + }, + "validation": { + "type": "object", + "additionalProperties": false, + "properties": { + "required": {"type": "boolean", "default": true}, + "validators": {"type": "array", "items": {"type": "string"}, "default": []} + } + }, + "executionPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "optimization": {"enum": ["speed", "quality", "balanced", "pareto"], "default": "balanced"}, + "max_parallel": {"type": "integer", "minimum": 1, "default": 1}, + "budgets": {"$ref": "#/$defs/budgets"}, + "tools": {"$ref": "#/$defs/allowDeny"}, + "transforms": {"$ref": "#/$defs/allowDeny"}, + "requirements": {"$ref": "#/$defs/requirements"}, + "network": {"enum": ["deny", "allow"], "default": "deny"}, + "ai": {"enum": ["deny", "local_only", "allow"], "default": "deny"}, + "retry_policy": {"type": ["string", "null"]}, + "timeout_policy": {"type": ["string", "null"]}, + "validation": {"$ref": "#/$defs/validation"}, + "minimum_fidelity": {"type": ["number", "null"], "minimum": 0.0, "maximum": 1.0}, + "publication_policy": {"type": ["string", "null"]}, + "redaction_policy": {"type": ["string", "null"]} + } + }, + "outputLayout": { + "type": "object", + "additionalProperties": false, + "properties": { + "bundle_root": {"type": "string", "minLength": 1, "default": "dist"}, + "naming_template": {"type": "string", "minLength": 1, "default": "{source.id}/{target.role}.{ext}"}, + "collision": {"enum": ["error", "replace", "dedupe"], "default": "error"} + } + } + } + }) +} + +pub fn json_schema_pretty() -> Result { + Ok(format!( + "{}\n", + serde_json::to_string_pretty(&json_schema())? + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::OutputType; + + const VALID_MULTI_SOURCE: &str = + include_str!("../../../tests/fixtures/spec-v2/valid-multi-source.yaml"); + const VALID_EXACT: &str = include_str!("../../../tests/fixtures/spec-v2/valid-exact.yaml"); + const INVALID_DUPLICATE_SOURCE: &str = + include_str!("../../../tests/fixtures/spec-v2/invalid-duplicate-source.yaml"); + const INVALID_POLICY: &str = + include_str!("../../../tests/fixtures/spec-v2/invalid-policy.yaml"); + + #[test] + fn v2_multi_source_and_ordered_collection_are_representable() { + let loaded = load_spec_str(VALID_MULTI_SOURCE).expect("valid v2 spec should load"); + assert_eq!(loaded.source_version, SourceSpecVersion::V2); + assert_eq!(loaded.spec.sources.len(), 3); + let collection = loaded + .spec + .sources + .iter() + .find(|source| source.kind == SourceKind::Collection) + .expect("collection source should exist"); + assert_eq!(collection.members, vec!["source.cover", "source.body"]); + assert!(loaded.spec.targets.all_reachable); + } + + #[test] + fn exact_targets_and_profiles_are_representable() { + let loaded = load_spec_str(VALID_EXACT).expect("valid exact-target v2 spec should load"); + assert_eq!(loaded.spec.targets.exact.len(), 2); + assert_eq!(loaded.spec.targets.profiles, vec!["publication.web"]); + } + + #[test] + fn network_and_ai_default_to_deny() { + let yaml = r#" +schema: renderflow/v2 +sources: + - id: source.main + path: input.md +targets: + exact: + - format: html +"#; + let loaded = load_spec_str(yaml).expect("minimal v2 spec should load"); + assert_eq!(loaded.spec.execution.network, NetworkPolicy::Deny); + assert_eq!(loaded.spec.execution.ai, AiPolicy::Deny); + } + + #[test] + fn duplicate_source_ids_report_a_field_path() { + let report = validate_spec_str(INVALID_DUPLICATE_SOURCE); + assert!(!report.valid); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic.path == "$.sources[1].id" && diagnostic.code == "source.id.duplicate" + })); + } + + #[test] + fn invalid_policy_reports_precise_paths() { + let report = validate_spec_str(INVALID_POLICY); + assert!(!report.valid); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.execution.max_parallel")); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.execution.minimum_fidelity")); + assert!(report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.path == "$.execution.tools.deny[0]")); + } + + #[test] + fn unversioned_v1_is_loaded_through_explicit_compatibility_path() { + let yaml = r#" +outputs: + - type: pdf + - type: html +input: input.md +output_dir: public +"#; + let loaded = load_spec_str(yaml).expect("v1 config should migrate in memory"); + assert_eq!(loaded.source_version, SourceSpecVersion::V1); + assert_eq!(loaded.spec.schema, SPEC_V2_ID); + assert_eq!(loaded.spec.output.bundle_root, "public"); + assert_eq!(loaded.spec.targets.exact.len(), 2); + } + + #[test] + fn v1_migration_preserves_transform_and_optimization_intent() { + let yaml = r#" +outputs: + - type: html +input: input.md +output_dir: dist +optimization: quality +transforms: transforms.yaml +variables: + project: renderflow +"#; + let migrated = migrate_v1_str(yaml).expect("v1 migration should succeed"); + assert_eq!(migrated.execution.optimization, OptimizationMode::Quality); + assert_eq!(migrated.transforms.as_deref(), Some("transforms.yaml")); + assert_eq!( + migrated.variables.get("project").map(String::as_str), + Some("renderflow") + ); + } + + #[test] + fn unsupported_schema_is_actionable() { + let report = validate_spec_str("schema: renderflow/v99\nsources: []\ntargets: {}\n"); + assert!(!report.valid); + assert_eq!(report.diagnostics[0].path, "$.schema"); + assert_eq!(report.diagnostics[0].code, "schema.unsupported"); + } + + #[test] + fn runtime_json_schema_declares_v2_identifier() { + let schema = json_schema(); + assert_eq!(schema["properties"]["schema"]["const"], SPEC_V2_ID); + assert_eq!(schema["additionalProperties"], false); + } + + #[test] + fn migration_rejects_already_versioned_input() { + let error = migrate_v1_str(VALID_EXACT).expect_err("v2 input must not be migrated as v1"); + assert!(error.to_string().contains("already declares a schema")); + } + + #[test] + fn unsupported_v1_output_still_fails_compatibility_validation() { + let yaml = "outputs:\n - type: definitely-not-real\ninput: input.md\n"; + let report = validate_spec_str(yaml); + assert!(!report.valid); + assert_eq!(report.source_version, Some(SourceSpecVersion::V1)); + } + + #[test] + fn output_type_conversion_remains_lossless_for_v1_document_targets() { + let config: Config = + serde_yaml_ng::from_str("outputs:\n - type: pdf\ninput: input.md\noutput_dir: dist\n") + .expect("v1 config parses"); + let migrated = migrate_v1_config(&config); + assert!(matches!(config.outputs[0].output_type, OutputType::Pdf)); + assert_eq!(migrated.targets.exact[0].format.as_deref(), Some("pdf")); + } +} diff --git a/docs/cli-reference/spec.md b/docs/cli-reference/spec.md new file mode 100644 index 0000000..8d7f0e7 --- /dev/null +++ b/docs/cli-reference/spec.md @@ -0,0 +1,29 @@ +# `renderflow spec` + +Renderflow spec commands inspect, validate, migrate, and export the canonical execution specification contract. + +## Validate a configuration + +```bash +renderflow spec validate --config renderflow.yaml +renderflow spec validate --config renderflow.yaml --format json +``` + +Unversioned files are validated through the explicit v1 compatibility path. Files declaring `schema: renderflow/v2` are parsed and semantically validated as v2. Unsupported declared schema identifiers fail actionably. + +## Migrate v1 to v2 + +```bash +renderflow spec migrate --config renderflow.yaml --output renderflow.v2.yaml +``` + +Migration is deterministic. It preserves the v1 source path, output targets, output directory, variables, transform registry path, optimization mode, templates, and output profiles while making v2 policy defaults explicit. + +## Export the JSON Schema + +```bash +renderflow spec schema --output schemas/renderflow-v2.schema.json +renderflow spec schema --format yaml +``` + +The runtime-emitted schema is the canonical machine-readable contract. Documentation CI regenerates the checked-in schema and reference page from this command to prevent drift. diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index dfff444..9e8c098 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -1,8 +1,45 @@ # Configuration -Renderflow's main config is a YAML file that deserializes into `Config` in `src/config.rs`. +Renderflow supports two explicit configuration contracts: -## Minimal config +- **v1 compatibility** — the existing unversioned `input` / `outputs` configuration used by current build commands. +- **spec v2** — the versioned `schema: renderflow/v2` execution-intent contract for arbitrary sources, derivative profiles, maximal artifact forests, explicit execution policy, and deterministic output layout. + +Renderflow never silently reinterprets a declared schema version. Unversioned files are treated as v1 compatibility files; unsupported declared schema identifiers are rejected actionably. + +!!! important + Issue #353 defines the v2 intent contract, validation, migration, and generated schema. The canonical planner/executor consumes this model in the follow-up unification work tracked by #354. Existing v1 builds remain backward compatible in the meantime. + +## Spec v2 + +A minimal v2 document looks like this: + +```yaml +schema: renderflow/v2 +sources: + - id: source.main + path: input.md +targets: + exact: + - role: web + format: html +``` + +V2 can also express multiple immutable sources, ordered collections, named derivative profiles, `all_reachable` expansion, include/exclude selectors, resource budgets, tool/transform allowlists and denylists, deterministic/local/offline requirements, network and AI policy, validation requirements, fidelity thresholds, and deterministic output layout. + +Use the CLI to validate, migrate, or export the canonical schema: + +```bash +renderflow spec validate --config renderflow.yaml +renderflow spec migrate --config renderflow.yaml --output renderflow.v2.yaml +renderflow spec schema --output schemas/renderflow-v2.schema.json +``` + +See the generated [Spec v2 Reference](spec-v2-reference.md) for the canonical field matrix and complete example. + +## V1 compatibility format + +### Minimal config ```yaml input: input.md @@ -11,7 +48,7 @@ outputs: - type: html ``` -## Full document-oriented example +### Full document-oriented example ```yaml input: report.md @@ -29,7 +66,7 @@ outputs: - type: docx ``` -## Key reference +### Key reference | Key | Required | Default | Notes | |---|---|---|---| @@ -41,9 +78,9 @@ outputs: | `transforms` | No | none | Path to a YAML transform graph / transform registry file | | `outputs` | Yes for standard builds | empty | List of output definitions | -### `outputs[]` +#### `outputs[]` -Each output item maps to `OutputConfig`. +Each output item maps to the v1 `OutputConfig` compatibility model. | Key | Required | Notes | |---|---|---| @@ -51,9 +88,9 @@ Each output item maps to `OutputConfig`. | `template` | No | Template name looked up in `templates/` | | `profile` | No | Audio quality profile for audio outputs only | -## Validation rules +## V1 validation rules -Renderflow validates several constraints before running a standard build: +Renderflow validates several constraints before running a standard v1 build: - `input` must not be empty - `outputs` must contain at least one item @@ -63,12 +100,14 @@ Renderflow validates several constraints before running a standard build: - image inputs only produce image outputs - incompatible document input/output combinations fail early +These family-specific v1 gates are compatibility behavior. They are not constraints on the v2 artifact-forest model. + !!! note - Graph inspection and graph build commands load config through `load_config_for_graph`, which skips the `outputs` requirement. That allows graph-driven commands to discover targets from the transform graph. + Graph inspection and graph build commands currently load v1 config through `load_config_for_graph`, which skips the `outputs` requirement. Canonical v1/v2 planner unification is tracked by #354. ## Input formats -Auto-detection is based on file extension: +V1 auto-detection is based on file extension: | Extension | Format | |---|---| @@ -79,6 +118,8 @@ Auto-detection is based on file extension: | `.rst` | `rst` | | `.tex` | `latex` | +V2 source declarations can carry explicit `format` / `media_type` intent or request detection. Universal multi-signal source inspection is expanded by #365. + ## Output types Document outputs are first-class: @@ -100,7 +141,7 @@ For the full generated list of config values, graph identifiers, file extensions ## Audio profiles -Audio outputs can specify a named profile, for example: +V1 audio outputs can specify a named profile, for example: ```yaml outputs: diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md new file mode 100644 index 0000000..e465282 --- /dev/null +++ b/docs/user-guide/spec-v2-reference.md @@ -0,0 +1,164 @@ + +# Renderflow spec v2 reference + +This page is generated from the JSON Schema emitted by the Renderflow runtime. +Do not edit it by hand. + +**Schema identifier:** `renderflow/v2` + +Spec v2 describes source intent, derivative selection, execution policy, and deterministic output layout. Planning resolves this intent into an execution plan; the specification itself does not encode a resolved DAG. + +## Top-level fields + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `execution` | `executionPolicy` | no | — | +| `output` | `outputLayout` | no | — | +| `profiles` | `object` | no | `{}` | +| `schema` | `"renderflow/v2"` | yes | — | +| `sources` | `array` | yes | — | +| `targets` | `targetSelection` | yes | — | +| `transforms` | `string` / `null` | no | — | +| `variables` | `object` | no | `{}` | + +## Source + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `detect` | `boolean` | no | `true` | +| `format` | `string` / `null` | no | — | +| `id` | `stableId` | yes | — | +| `immutable` | `true` | no | `true` | +| `kind` | `artifact` / `collection` | no | `"artifact"` | +| `media_type` | `string` / `null` | no | — | +| `members` | `array` | no | `[]` | +| `path` | `string` / `null` | no | — | +| `role` | `string` / `null` | no | — | +| `uri` | `string` / `null` | no | — | + +## Target selection + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `all_reachable` | `boolean` | no | `false` | +| `exact` | `array` | no | `[]` | +| `exclude` | `selectorSet` | no | — | +| `include` | `selectorSet` | no | — | +| `intermediates` | `cache_only` / `retain` | no | `"cache_only"` | +| `profiles` | `array` | no | `[]` | + +## Execution policy + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `ai` | `deny` / `local_only` / `allow` | no | `"deny"` | +| `budgets` | `budgets` | no | — | +| `max_parallel` | `integer` | no | `1` | +| `minimum_fidelity` | `number` / `null` | no | — | +| `network` | `deny` / `allow` | no | `"deny"` | +| `optimization` | `speed` / `quality` / `balanced` / `pareto` | no | `"balanced"` | +| `publication_policy` | `string` / `null` | no | — | +| `redaction_policy` | `string` / `null` | no | — | +| `requirements` | `requirements` | no | — | +| `retry_policy` | `string` / `null` | no | — | +| `timeout_policy` | `string` / `null` | no | — | +| `tools` | `allowDeny` | no | — | +| `transforms` | `allowDeny` | no | — | +| `validation` | `validation` | no | — | + +## Output layout + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `bundle_root` | `string` | no | `"dist"` | +| `collision` | `error` / `replace` / `dedupe` | no | `"error"` | +| `naming_template` | `string` | no | `"{source.id}/{target.role}.{ext}"` | + +## Compatibility + +Unversioned configuration files are treated as the explicit v1 compatibility format. Use `renderflow spec migrate` to produce a v2 document. Unsupported declared schema identifiers are rejected rather than reinterpreted. + +## Example + +```yaml +schema: renderflow/v2 + +sources: + - id: source.cover + role: cover + path: assets/cover.png + media_type: image/png + detect: true + + - id: source.body + role: manuscript + path: examples/input.md + format: markdown + detect: true + + - id: source.publication + role: publication + kind: collection + members: + - source.cover + - source.body + +profiles: + publication.web: + description: Browser-ready publication derivatives + targets: + - id: target.web + role: web + format: html + publication.archive: + description: Long-lived local archival derivatives + targets: + - id: target.pdf + role: archival + format: pdf + +targets: + profiles: + - publication.web + all_reachable: true + include: + families: + - document + - image + exclude: + capabilities: + - ai.generate + intermediates: cache_only + +execution: + optimization: balanced + max_parallel: 4 + budgets: + max_output_bytes: 1073741824 + max_storage_bytes: 2147483648 + max_artifacts: 500 + max_depth: 8 + tools: + deny: [] + transforms: + deny: [] + requirements: + deterministic: true + local_only: true + offline: true + network: deny + ai: deny + validation: + required: true + minimum_fidelity: 0.9 + +output: + bundle_root: dist + naming_template: "{source.id}/{target.role}.{ext}" + collision: error + +variables: + project: renderflow + +transforms: transforms.yaml +``` diff --git a/examples/renderflow-v2.yaml b/examples/renderflow-v2.yaml new file mode 100644 index 0000000..bd9b085 --- /dev/null +++ b/examples/renderflow-v2.yaml @@ -0,0 +1,80 @@ +schema: renderflow/v2 + +sources: + - id: source.cover + role: cover + path: assets/cover.png + media_type: image/png + detect: true + + - id: source.body + role: manuscript + path: examples/input.md + format: markdown + detect: true + + - id: source.publication + role: publication + kind: collection + members: + - source.cover + - source.body + +profiles: + publication.web: + description: Browser-ready publication derivatives + targets: + - id: target.web + role: web + format: html + publication.archive: + description: Long-lived local archival derivatives + targets: + - id: target.pdf + role: archival + format: pdf + +targets: + profiles: + - publication.web + all_reachable: true + include: + families: + - document + - image + exclude: + capabilities: + - ai.generate + intermediates: cache_only + +execution: + optimization: balanced + max_parallel: 4 + budgets: + max_output_bytes: 1073741824 + max_storage_bytes: 2147483648 + max_artifacts: 500 + max_depth: 8 + tools: + deny: [] + transforms: + deny: [] + requirements: + deterministic: true + local_only: true + offline: true + network: deny + ai: deny + validation: + required: true + minimum_fidelity: 0.9 + +output: + bundle_root: dist + naming_template: "{source.id}/{target.role}.{ext}" + collision: error + +variables: + project: renderflow + +transforms: transforms.yaml diff --git a/mkdocs.yml b/mkdocs.yml index 64f0967..bc2f0d1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - CLI Overview: getting-started/cli-overview.md - User Guide: - Configuration: user-guide/configuration.md + - Spec v2 Reference: user-guide/spec-v2-reference.md - Supported Formats: user-guide/supported-formats.md - Tool Registry: user-guide/tool-registry.md - Pipelines: user-guide/pipelines.md @@ -119,6 +120,7 @@ nav: - plugin: cli-reference/plugin.md - ai: cli-reference/ai.md - tools & capabilities: cli-reference/tools.md + - spec: cli-reference/spec.md - Transform Reference: - Overview: transform-reference/index.md - Emoji: transform-reference/emoji.md diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json new file mode 100644 index 0000000..38cadf8 --- /dev/null +++ b/schemas/renderflow-v2.schema.json @@ -0,0 +1,503 @@ +{ + "$defs": { + "allowDeny": { + "additionalProperties": false, + "properties": { + "allow": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "deny": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "budgets": { + "additionalProperties": false, + "properties": { + "max_artifacts": { + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "max_depth": { + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "max_output_bytes": { + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "max_storage_bytes": { + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "executionPolicy": { + "additionalProperties": false, + "properties": { + "ai": { + "default": "deny", + "enum": [ + "deny", + "local_only", + "allow" + ] + }, + "budgets": { + "$ref": "#/$defs/budgets" + }, + "max_parallel": { + "default": 1, + "minimum": 1, + "type": "integer" + }, + "minimum_fidelity": { + "maximum": 1.0, + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "network": { + "default": "deny", + "enum": [ + "deny", + "allow" + ] + }, + "optimization": { + "default": "balanced", + "enum": [ + "speed", + "quality", + "balanced", + "pareto" + ] + }, + "publication_policy": { + "type": [ + "string", + "null" + ] + }, + "redaction_policy": { + "type": [ + "string", + "null" + ] + }, + "requirements": { + "$ref": "#/$defs/requirements" + }, + "retry_policy": { + "type": [ + "string", + "null" + ] + }, + "timeout_policy": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "$ref": "#/$defs/allowDeny" + }, + "transforms": { + "$ref": "#/$defs/allowDeny" + }, + "validation": { + "$ref": "#/$defs/validation" + } + }, + "type": "object" + }, + "outputLayout": { + "additionalProperties": false, + "properties": { + "bundle_root": { + "default": "dist", + "minLength": 1, + "type": "string" + }, + "collision": { + "default": "error", + "enum": [ + "error", + "replace", + "dedupe" + ] + }, + "naming_template": { + "default": "{source.id}/{target.role}.{ext}", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "profile": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "exclude": { + "$ref": "#/$defs/selectorSet" + }, + "include": { + "$ref": "#/$defs/selectorSet" + }, + "targets": { + "default": [], + "items": { + "$ref": "#/$defs/target" + }, + "type": "array" + } + }, + "type": "object" + }, + "requirements": { + "additionalProperties": false, + "properties": { + "deterministic": { + "default": false, + "type": "boolean" + }, + "local_only": { + "default": false, + "type": "boolean" + }, + "offline": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "selectorSet": { + "additionalProperties": false, + "properties": { + "capabilities": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "families": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "formats": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "profiles": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" + }, + "transforms": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "source": { + "additionalProperties": false, + "properties": { + "detect": { + "default": true, + "type": "boolean" + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "id": { + "$ref": "#/$defs/stableId" + }, + "immutable": { + "const": true, + "default": true + }, + "kind": { + "default": "artifact", + "enum": [ + "artifact", + "collection" + ] + }, + "media_type": { + "type": [ + "string", + "null" + ] + }, + "members": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "stableId": { + "minLength": 1, + "pattern": "^[A-Za-z0-9._-]+$", + "type": "string" + }, + "target": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "format" + ] + }, + { + "required": [ + "family" + ] + }, + { + "required": [ + "capability" + ] + }, + { + "required": [ + "transform" + ] + } + ], + "properties": { + "capability": { + "type": [ + "string", + "null" + ] + }, + "family": { + "type": [ + "string", + "null" + ] + }, + "format": { + "type": [ + "string", + "null" + ] + }, + "id": { + "anyOf": [ + { + "$ref": "#/$defs/stableId" + }, + { + "type": "null" + } + ] + }, + "preset": { + "type": [ + "string", + "null" + ] + }, + "role": { + "type": [ + "string", + "null" + ] + }, + "template": { + "type": [ + "string", + "null" + ] + }, + "transform": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "targetSelection": { + "additionalProperties": false, + "properties": { + "all_reachable": { + "default": false, + "type": "boolean" + }, + "exact": { + "default": [], + "items": { + "$ref": "#/$defs/target" + }, + "type": "array" + }, + "exclude": { + "$ref": "#/$defs/selectorSet" + }, + "include": { + "$ref": "#/$defs/selectorSet" + }, + "intermediates": { + "default": "cache_only", + "enum": [ + "cache_only", + "retain" + ] + }, + "profiles": { + "default": [], + "items": { + "$ref": "#/$defs/stableId" + }, + "type": "array" + } + }, + "type": "object" + }, + "validation": { + "additionalProperties": false, + "properties": { + "required": { + "default": true, + "type": "boolean" + }, + "validators": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Declarative source, derivative target, execution policy, and output-layout intent consumed by the Renderflow planner.", + "properties": { + "execution": { + "$ref": "#/$defs/executionPolicy" + }, + "output": { + "$ref": "#/$defs/outputLayout" + }, + "profiles": { + "additionalProperties": { + "$ref": "#/$defs/profile" + }, + "default": {}, + "type": "object" + }, + "schema": { + "const": "renderflow/v2" + }, + "sources": { + "items": { + "$ref": "#/$defs/source" + }, + "minItems": 1, + "type": "array" + }, + "targets": { + "$ref": "#/$defs/targetSelection" + }, + "transforms": { + "type": [ + "string", + "null" + ] + }, + "variables": { + "additionalProperties": { + "type": "string" + }, + "default": {}, + "type": "object" + } + }, + "required": [ + "schema", + "sources", + "targets" + ], + "title": "Renderflow execution specification v2", + "type": "object" +} diff --git a/scripts/generate_spec_v2_reference.py b/scripts/generate_spec_v2_reference.py new file mode 100644 index 0000000..d2e60c8 --- /dev/null +++ b/scripts/generate_spec_v2_reference.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Generate the Renderflow spec v2 reference from the runtime JSON Schema.""" + +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "schemas" / "renderflow-v2.schema.json" +OUTPUT_PATH = ROOT / "docs" / "user-guide" / "spec-v2-reference.md" +EXAMPLE_PATH = ROOT / "examples" / "renderflow-v2.yaml" + + +def format_default(schema: dict[str, object]) -> str: + """Return a compact Markdown representation of a JSON Schema default.""" + if "default" not in schema: + return "—" + return f"`{json.dumps(schema['default'], separators=(',', ':'))}`" + + +def property_type(schema: dict[str, object]) -> str: + """Render a Markdown-safe type or enum summary for a schema property.""" + if "$ref" in schema: + return f"`{str(schema['$ref']).split('/')[-1]}`" + if "const" in schema: + return f"`{json.dumps(schema['const'])}`" + if "enum" in schema: + return " / ".join(f"`{value}`" for value in schema["enum"]) + value = schema.get("type", "object") + if isinstance(value, list): + return " / ".join(f"`{item}`" for item in value) + return f"`{value}`" + + +def render_properties(title: str, schema: dict[str, object]) -> list[str]: + """Render one JSON Schema object's properties as a Markdown table.""" + properties = schema.get("properties", {}) + required = set(schema.get("required", [])) + lines = [ + f"## {title}", + "", + "| Field | Type | Required | Default |", + "| --- | --- | --- | --- |", + ] + for name, definition in properties.items(): + definition = dict(definition) + lines.append( + f"| `{name}` | {property_type(definition)} | " + f"{'yes' if name in required else 'no'} | {format_default(definition)} |" + ) + lines.append("") + return lines + + +def main() -> None: + """Generate the checked-in spec v2 reference page.""" + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + example = EXAMPLE_PATH.read_text(encoding="utf-8").rstrip() + definitions = schema["$defs"] + + lines = [ + "", + "# Renderflow spec v2 reference", + "", + "This page is generated from the JSON Schema emitted by the Renderflow runtime.", + "Do not edit it by hand.", + "", + f"**Schema identifier:** `{schema['properties']['schema']['const']}`", + "", + "Spec v2 describes source intent, derivative selection, execution policy, and deterministic output layout. Planning resolves this intent into an execution plan; the specification itself does not encode a resolved DAG.", + "", + ] + lines.extend(render_properties("Top-level fields", schema)) + lines.extend(render_properties("Source", definitions["source"])) + lines.extend(render_properties("Target selection", definitions["targetSelection"])) + lines.extend(render_properties("Execution policy", definitions["executionPolicy"])) + lines.extend(render_properties("Output layout", definitions["outputLayout"])) + lines.extend( + [ + "## Compatibility", + "", + "Unversioned configuration files are treated as the explicit v1 compatibility format. Use `renderflow spec migrate` to produce a v2 document. Unsupported declared schema identifiers are rejected rather than reinterpreted.", + "", + "## Example", + "", + "```yaml", + example, + "```", + "", + ] + ) + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text("\n".join(lines), encoding="utf-8") + print(f"Generated {OUTPUT_PATH.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/spec-v2/invalid-duplicate-source.yaml b/tests/fixtures/spec-v2/invalid-duplicate-source.yaml new file mode 100644 index 0000000..316e448 --- /dev/null +++ b/tests/fixtures/spec-v2/invalid-duplicate-source.yaml @@ -0,0 +1,9 @@ +schema: renderflow/v2 +sources: + - id: source.main + path: first.md + - id: source.main + path: second.md +targets: + exact: + - format: html diff --git a/tests/fixtures/spec-v2/invalid-policy.yaml b/tests/fixtures/spec-v2/invalid-policy.yaml new file mode 100644 index 0000000..fad1739 --- /dev/null +++ b/tests/fixtures/spec-v2/invalid-policy.yaml @@ -0,0 +1,13 @@ +schema: renderflow/v2 +sources: + - id: source.main + path: input.md +targets: + exact: + - format: html +execution: + max_parallel: 0 + minimum_fidelity: 1.5 + tools: + allow: [tool.pandoc] + deny: [tool.pandoc] diff --git a/tests/fixtures/spec-v2/valid-exact.yaml b/tests/fixtures/spec-v2/valid-exact.yaml new file mode 100644 index 0000000..8b1f8c1 --- /dev/null +++ b/tests/fixtures/spec-v2/valid-exact.yaml @@ -0,0 +1,26 @@ +schema: renderflow/v2 +sources: + - id: source.main + path: input.md + format: markdown +profiles: + publication.web: + targets: + - id: profile.web + role: web + format: html +targets: + exact: + - id: target.pdf + role: print + format: pdf + - id: target.html + role: web + capability: document.generate + profiles: [publication.web] +execution: + optimization: quality + network: deny + ai: deny +output: + naming_template: "{source.id}/{target.role}.{ext}" diff --git a/tests/fixtures/spec-v2/valid-multi-source.yaml b/tests/fixtures/spec-v2/valid-multi-source.yaml new file mode 100644 index 0000000..97e4714 --- /dev/null +++ b/tests/fixtures/spec-v2/valid-multi-source.yaml @@ -0,0 +1,24 @@ +schema: renderflow/v2 +sources: + - id: source.cover + role: cover + path: cover.png + - id: source.body + role: manuscript + path: body.md + format: markdown + - id: source.publication + role: publication + kind: collection + members: [source.cover, source.body] +targets: + all_reachable: true + include: + families: [document, image] +execution: + max_parallel: 2 + network: deny + ai: deny +output: + bundle_root: dist + collision: error