From 7cd761acbcabf88942845ecadd0e357768013126 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Mon, 31 Aug 2026 21:53:24 -0400 Subject: [PATCH] feat(config): define renderflow execution spec v2 Introduce the versioned execution-intent contract for arbitrary source artifacts, ordered collections, derivative profiles, maximal target selection, explicit execution policy, and deterministic output layout. Keep unversioned v1 configuration behind an explicit compatibility and migration path. Emit structured field-path diagnostics and publish the runtime-owned JSON Schema through CLI and generated documentation. Closes #353 --- .github/workflows/docs.yml | 37 +- crates/renderflow-core/src/app.rs | 38 +- crates/renderflow-core/src/cli.rs | 37 + crates/renderflow-core/src/commands/mod.rs | 1 + crates/renderflow-core/src/commands/spec.rs | 116 ++ crates/renderflow-core/src/lib.rs | 1 + crates/renderflow-core/src/spec.rs | 1225 +++++++++++++++++ docs/cli-reference/spec.md | 29 + docs/user-guide/configuration.md | 63 +- docs/user-guide/spec-v2-reference.md | 164 +++ examples/renderflow-v2.yaml | 80 ++ mkdocs.yml | 2 + schemas/renderflow-v2.schema.json | 503 +++++++ scripts/generate_spec_v2_reference.py | 100 ++ .../spec-v2/invalid-duplicate-source.yaml | 9 + tests/fixtures/spec-v2/invalid-policy.yaml | 13 + tests/fixtures/spec-v2/valid-exact.yaml | 26 + .../fixtures/spec-v2/valid-multi-source.yaml | 24 + 18 files changed, 2437 insertions(+), 31 deletions(-) create mode 100644 crates/renderflow-core/src/commands/spec.rs create mode 100644 crates/renderflow-core/src/spec.rs create mode 100644 docs/cli-reference/spec.md create mode 100644 docs/user-guide/spec-v2-reference.md create mode 100644 examples/renderflow-v2.yaml create mode 100644 schemas/renderflow-v2.schema.json create mode 100644 scripts/generate_spec_v2_reference.py create mode 100644 tests/fixtures/spec-v2/invalid-duplicate-source.yaml create mode 100644 tests/fixtures/spec-v2/invalid-policy.yaml create mode 100644 tests/fixtures/spec-v2/valid-exact.yaml create mode 100644 tests/fixtures/spec-v2/valid-multi-source.yaml 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