From 245052db387eb6bb0fe659a559d26b545524aa2a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 25 May 2026 10:19:32 -0400 Subject: [PATCH] feat(cli): allow rendering invalid graphs Keep graph validation diagnostics visible, but let users opt into rendering DOT workflows that fail semantic validation with --allow-invalid. --- docs/public/reference/cli.mdx | 1 + lib/crates/fabro-cli/src/args.rs | 4 ++ lib/crates/fabro-cli/src/commands/graph.rs | 57 +++++++++++++++++----- lib/crates/fabro-cli/tests/it/cmd/graph.rs | 36 +++++++++++++- 4 files changed, 84 insertions(+), 14 deletions(-) diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 3dbf3b04d..8e03fda14 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -455,6 +455,7 @@ fabro graph [OPTIONS] | Option | Description | | --- | --- | | `-d, --direction ` | Graph layout direction (overrides the DOT file's rankdir)
Values: `lr`, `tb` | +| `--allow-invalid` | Render even when workflow validation reports errors | | `--format ` | Output format
Values: `svg`
Default: `svg` | | `-o, --output ` | Output file path (defaults to stdout) | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 9b4f065fe..e926dced6 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -483,6 +483,10 @@ pub(crate) struct GraphArgs { /// Graph layout direction (overrides the DOT file's rankdir) #[arg(short = 'd', long)] pub(crate) direction: Option, + + /// Render even when workflow validation reports errors + #[arg(long)] + pub(crate) allow_invalid: bool, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/commands/graph.rs b/lib/crates/fabro-cli/src/commands/graph.rs index 1c046d311..5730fc873 100644 --- a/lib/crates/fabro-cli/src/commands/graph.rs +++ b/lib/crates/fabro-cli/src/commands/graph.rs @@ -12,6 +12,7 @@ use std::io::Write; use anyhow::{Context, bail}; use fabro_api::types; use fabro_config::user::active_settings_path; +use fabro_graphviz::render; use fabro_manifest::{ManifestBuildInput, build_run_manifest}; use fabro_util::terminal::Styles; use tracing::debug; @@ -43,23 +44,27 @@ pub(crate) async fn run( let diagnostics = api_diagnostics_to_local(&preflight.workflow.diagnostics); print_diagnostics(&diagnostics, styles, printer); - if diagnostics + let has_errors = diagnostics .iter() - .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error) - { + .any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error); + if has_errors && !args.allow_invalid { bail!("Validation failed"); } - let rendered = client - .render_workflow_graph(types::RenderWorkflowGraphRequest { - manifest: built.manifest, - format: Some(types::RenderWorkflowGraphFormat::Svg), - direction: args.direction.map(|direction| match direction { - GraphDirection::Lr => types::RenderWorkflowGraphDirection::Lr, - GraphDirection::Tb => types::RenderWorkflowGraphDirection::Tb, - }), - }) - .await?; + let rendered = if has_errors && args.allow_invalid { + render_manifest_graph_locally(&built.manifest, args.direction)? + } else { + client + .render_workflow_graph(types::RenderWorkflowGraphRequest { + manifest: built.manifest, + format: Some(types::RenderWorkflowGraphFormat::Svg), + direction: args.direction.map(|direction| match direction { + GraphDirection::Lr => types::RenderWorkflowGraphDirection::Lr, + GraphDirection::Tb => types::RenderWorkflowGraphDirection::Tb, + }), + }) + .await? + }; if let Some(ref output_path) = args.output { std::fs::write(output_path, &rendered) @@ -80,6 +85,32 @@ pub(crate) async fn run( Ok(()) } +fn render_manifest_graph_locally( + manifest: &types::RunManifest, + direction: Option, +) -> anyhow::Result> { + let source = manifest_root_source(manifest)?; + let source = match direction { + Some(direction) => render::apply_direction(source, &direction.to_string()).into_owned(), + None => source.to_string(), + }; + + render::render_dot(&source).context("rendering workflow graph") +} + +fn manifest_root_source(manifest: &types::RunManifest) -> anyhow::Result<&str> { + manifest + .workflows + .get(&manifest.target.path) + .map(|workflow| workflow.source.as_str()) + .with_context(|| { + format!( + "manifest target path is missing from workflows map: {}", + manifest.target.path + ) + }) +} + fn output_file_json(output_path: &std::path::Path, format: GraphOutputFormat) -> serde_json::Value { serde_json::json!({ "path": absolute_or_current(output_path), diff --git a/lib/crates/fabro-cli/tests/it/cmd/graph.rs b/lib/crates/fabro-cli/tests/it/cmd/graph.rs index ace6c3eed..6b801928a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/graph.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/graph.rs @@ -1,6 +1,6 @@ use fabro_test::{fabro_snapshot, test_context}; -use super::support::fixture; +use super::support::{fixture, read_text}; #[test] fn help() { @@ -61,6 +61,9 @@ fn help() { [env: FABRO_QUIET=] + --allow-invalid + Render even when workflow validation reports errors + --verbose Enable verbose output @@ -72,6 +75,37 @@ fn help() { "); } +#[test] +fn graph_allow_invalid_renders_after_diagnostics() { + let context = test_context!(); + let workflow = fixture("invalid.fabro"); + let output_path = context.temp_dir.join("invalid.svg"); + let mut cmd = context.command(); + cmd.args([ + "graph", + "--allow-invalid", + "-o", + output_path.to_str().unwrap(), + workflow.to_str().unwrap(), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + error: Pipeline must have exactly one start node (shape=Mdiamond or id start/Start) (start_node) + error [node: exit]: Exit node 'exit' has 1 outgoing edge(s) but must have none (exit_no_outgoing) + "); + + let svg = read_text(&output_path); + assert!( + svg.contains("