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.
This commit is contained in:
Bryan Helmkamp 2026-05-25 10:19:32 -04:00
parent 8c6b187cb7
commit 245052db38
No known key found for this signature in database
4 changed files with 84 additions and 14 deletions

View file

@ -455,6 +455,7 @@ fabro graph [OPTIONS] <WORKFLOW>
| Option | Description |
| --- | --- |
| `-d, --direction <direction>` | Graph layout direction (overrides the DOT file's rankdir)<br />Values: `lr`, `tb` |
| `--allow-invalid` | Render even when workflow validation reports errors |
| `--format <format>` | Output format<br />Values: `svg`<br />Default: `svg` |
| `-o, --output <output>` | Output file path (defaults to stdout) |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |

View file

@ -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<GraphDirection>,
/// Render even when workflow validation reports errors
#[arg(long)]
pub(crate) allow_invalid: bool,
}
#[derive(Args)]

View file

@ -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<GraphDirection>,
) -> anyhow::Result<Vec<u8>> {
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),

View file

@ -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("<svg") && svg.contains("Invalid"),
"expected invalid workflow to render as SVG, got: {}",
&svg[..svg.len().min(200)]
);
}
#[test]
fn graph_invalid_workflow_fails_after_diagnostics() {
let context = test_context!();