Add file-based workflow imports

This commit is contained in:
Bryan Helmkamp 2026-03-28 17:28:37 -04:00
parent 007fb54286
commit 847c0481d0
6 changed files with 1515 additions and 348 deletions

View file

@ -32,6 +32,7 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
Box::new(ScriptAbsoluteCdRule),
Box::new(StylesheetModelKnownRule),
Box::new(NodeModelKnownRule),
Box::new(ImportErrorRule),
Box::new(UnresolvedFileRefRule),
Box::new(ThreadIdRequiresFidelityFullRule),
Box::new(SelectionValidRule),
@ -1033,7 +1034,50 @@ impl LintRule for NodeModelKnownRule {
}
}
// --- Rule 22: unresolved_file_ref (ERROR) ---
// --- Rule 22: import_error (ERROR) ---
struct ImportErrorRule;
impl LintRule for ImportErrorRule {
fn name(&self) -> &'static str {
"import_error"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(AttrValue::String(message)) = node.attrs.get("import_error") {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: message.clone(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some("Fix the imported workflow or import path".to_string()),
});
}
if node.attrs.contains_key("import") {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: "unresolved import (no base directory available)".to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Load the workflow from a file so imports can resolve relative to it"
.to_string(),
),
});
}
}
diagnostics
}
}
// --- Rule 23: unresolved_file_ref (ERROR) ---
struct UnresolvedFileRefRule;
@ -1080,7 +1124,7 @@ impl LintRule for UnresolvedFileRefRule {
}
}
// --- Rule 22: thread_id_requires_fidelity_full (WARNING) ---
// --- Rule 24: thread_id_requires_fidelity_full (WARNING) ---
struct ThreadIdRequiresFidelityFullRule;
@ -3162,6 +3206,55 @@ mod tests {
assert!(d.is_empty());
}
// import_error rule tests
#[test]
fn import_error_rule_fires_on_import_error_attr() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"import_error".to_string(),
AttrValue::String("file not found: ./missing.fabro".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = ImportErrorRule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(d[0].message, "file not found: ./missing.fabro");
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn import_error_rule_fires_on_unresolved_import_attr() {
let mut g = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"import".to_string(),
AttrValue::String("./validate.fabro".to_string()),
);
g.nodes.insert("work".to_string(), node);
let rule = ImportErrorRule;
let d = rule.apply(&g);
assert_eq!(d.len(), 1);
assert_eq!(d[0].severity, Severity::Error);
assert_eq!(
d[0].message,
"unresolved import (no base directory available)"
);
assert_eq!(d[0].node_id.as_deref(), Some("work"));
}
#[test]
fn import_error_rule_silent_for_clean_nodes() {
let g = minimal_graph();
let rule = ImportErrorRule;
let d = rule.apply(&g);
assert!(d.is_empty());
}
// unresolved_file_ref rule tests
#[test]

View file

@ -1,6 +1,6 @@
use crate::transforms::{
FileInliningTransform, ModelResolutionTransform, StylesheetApplicationTransform, Transform,
VariableExpansionTransform,
FileInliningTransform, ImportTransform, ModelResolutionTransform,
StylesheetApplicationTransform, Transform, VariableExpansionTransform,
};
use super::types::{Parsed, TransformOptions, Transformed};
@ -13,16 +13,20 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Transformed {
let Parsed { mut graph, source } = parsed;
// Built-in transforms (PreambleTransform moved to engine execution time)
if let Some(ref dir) = options.base_dir {
let fallback = dirs::home_dir().map(|home| home.join(".fabro"));
ImportTransform::new(dir.clone(), fallback).apply(&mut graph);
}
if let Some(ref dir) = options.base_dir {
let fallback = dirs::home_dir().map(|home| home.join(".fabro"));
FileInliningTransform::new(dir.clone(), fallback).apply(&mut graph);
}
VariableExpansionTransform.apply(&mut graph);
StylesheetApplicationTransform.apply(&mut graph);
ModelResolutionTransform.apply(&mut graph);
// File inlining when base_dir is provided
if let Some(ref dir) = options.base_dir {
let fallback = dirs::home_dir().map(|h| h.join(".fabro"));
FileInliningTransform::new(dir.clone(), fallback).apply(&mut graph);
}
// Custom transforms
for t in &options.custom_transforms {
t.apply(&mut graph);
@ -33,10 +37,19 @@ pub fn transform(parsed: Parsed, options: &TransformOptions) -> Transformed {
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::pipeline::parse::parse;
use fabro_graphviz::graph::AttrValue;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn transform_applies_variable_expansion() {
let dot = r#"digraph Test {
@ -84,4 +97,79 @@ mod tests {
Some(&AttrValue::String("claude-sonnet-4-6".into()))
);
}
#[test]
fn transform_inlines_files_before_variable_expansion() {
let dir = tempfile::tempdir().unwrap();
write_file(&dir.path().join("goal.md"), "Expand $goal");
let parsed = parse(
r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond]
work [prompt="@goal.md"]
exit [shape=Msquare]
start -> work -> exit
}"#,
)
.unwrap();
let transformed = transform(
parsed,
&TransformOptions {
base_dir: Some(dir.path().to_path_buf()),
custom_transforms: vec![],
},
);
assert_eq!(
transformed.graph.nodes["work"]
.attrs
.get("prompt")
.and_then(AttrValue::as_str),
Some("Expand Ship it")
);
}
#[test]
fn transform_imports_before_variable_expansion_and_stylesheet() {
let dir = tempfile::tempdir().unwrap();
write_file(&dir.path().join("prompts/lint.md"), "Run checks for $goal");
write_file(
&dir.path().join("validate.fabro"),
r#"digraph validate {
start [shape=Mdiamond]
lint [prompt="@prompts/lint.md"]
exit [shape=Msquare]
start -> lint -> exit
}"#,
);
let parsed = parse(
r#"digraph Test {
graph [goal="Launch", model_stylesheet=".validate { model: sonnet; }"]
start [shape=Mdiamond]
validate [import="./validate.fabro"]
exit [shape=Msquare]
start -> validate -> exit
}"#,
)
.unwrap();
let transformed = transform(
parsed,
&TransformOptions {
base_dir: Some(dir.path().to_path_buf()),
custom_transforms: vec![],
},
);
let lint = &transformed.graph.nodes["validate.lint"];
assert_eq!(
lint.attrs.get("prompt").and_then(AttrValue::as_str),
Some("Run checks for Launch")
);
assert_eq!(
lint.attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-6".into()))
);
}
}

View file

@ -1,234 +0,0 @@
use fabro_graphviz::graph::{Edge, Graph, Node};
use super::Transform;
/// Merges nodes and edges from secondary graphs into the primary graph.
/// Node IDs from secondary graphs are prefixed with a namespace to avoid collisions.
pub struct GraphMergeTransform {
secondary_graphs: Vec<Graph>,
}
impl GraphMergeTransform {
#[must_use]
pub const fn new(secondary_graphs: Vec<Graph>) -> Self {
Self { secondary_graphs }
}
}
impl Transform for GraphMergeTransform {
fn apply(&self, graph: &mut Graph) {
for secondary in &self.secondary_graphs {
let prefix = &secondary.name;
for (id, node) in &secondary.nodes {
let prefixed_id = format!("{prefix}.{id}");
let mut merged_node = Node::new(&prefixed_id);
merged_node.attrs.clone_from(&node.attrs);
merged_node.classes.clone_from(&node.classes);
graph.nodes.insert(prefixed_id, merged_node);
}
for edge in &secondary.edges {
let mut merged_edge = Edge::new(
format!("{prefix}.{}", edge.from),
format!("{prefix}.{}", edge.to),
);
merged_edge.attrs.clone_from(&edge.attrs);
graph.edges.push(merged_edge);
}
}
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use super::*;
#[test]
fn graph_merge_combines_nodes_and_edges() {
let mut primary = Graph::new("primary");
primary.nodes.insert("a".to_string(), Node::new("a"));
primary.nodes.insert("b".to_string(), Node::new("b"));
primary.edges.push(Edge::new("a", "b"));
let mut secondary = Graph::new("secondary");
secondary.nodes.insert("x".to_string(), Node::new("x"));
secondary.nodes.insert("y".to_string(), Node::new("y"));
secondary.edges.push(Edge::new("x", "y"));
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
// Primary should now have 4 nodes: a, b, secondary.x, secondary.y
assert_eq!(primary.nodes.len(), 4);
assert!(primary.nodes.contains_key("secondary.x"));
assert!(primary.nodes.contains_key("secondary.y"));
// Should have 2 edges: a->b and secondary.x->secondary.y
assert_eq!(primary.edges.len(), 2);
}
#[test]
fn graph_merge_prefixes_node_ids_to_avoid_collisions() {
let mut primary = Graph::new("primary");
primary.nodes.insert("work".to_string(), Node::new("work"));
let mut secondary = Graph::new("sub");
secondary
.nodes
.insert("work".to_string(), Node::new("work"));
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
// Primary "work" is preserved, secondary "work" becomes "sub.work"
assert!(primary.nodes.contains_key("work"));
assert!(primary.nodes.contains_key("sub.work"));
assert_eq!(primary.nodes.len(), 2);
}
#[test]
fn graph_merge_remaps_edges_to_prefixed_ids() {
let mut primary = Graph::new("primary");
primary.nodes.insert("a".to_string(), Node::new("a"));
let mut secondary = Graph::new("sub");
secondary.nodes.insert("x".to_string(), Node::new("x"));
secondary.nodes.insert("y".to_string(), Node::new("y"));
secondary.edges.push(Edge::new("x", "y"));
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
// The edge from secondary should be remapped to sub.x -> sub.y
let merged_edge = primary
.edges
.iter()
.find(|e| e.from == "sub.x")
.expect("should have edge from sub.x");
assert_eq!(merged_edge.to, "sub.y");
}
#[test]
fn graph_merge_preserves_primary_attributes() {
let mut primary = Graph::new("primary");
primary.attrs.insert(
"goal".to_string(),
AttrValue::String("Build feature".to_string()),
);
primary.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { model: sonnet; }".to_string()),
);
let mut secondary = Graph::new("sub");
secondary.attrs.insert(
"goal".to_string(),
AttrValue::String("Sub goal".to_string()),
);
secondary.nodes.insert("x".to_string(), Node::new("x"));
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
assert_eq!(primary.goal(), "Build feature");
assert_eq!(primary.model_stylesheet(), "* { model: sonnet; }");
}
#[test]
fn graph_merge_empty_secondary_is_noop() {
let mut primary = Graph::new("primary");
primary.nodes.insert("a".to_string(), Node::new("a"));
primary.edges.push(Edge::new("a", "a"));
let secondary = Graph::new("empty");
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
assert_eq!(primary.nodes.len(), 1);
assert_eq!(primary.edges.len(), 1);
}
#[test]
fn graph_merge_multiple_secondary_graphs() {
let mut primary = Graph::new("primary");
primary.nodes.insert("a".to_string(), Node::new("a"));
let mut sub1 = Graph::new("sub1");
sub1.nodes.insert("n1".to_string(), Node::new("n1"));
let mut sub2 = Graph::new("sub2");
sub2.nodes.insert("n2".to_string(), Node::new("n2"));
let transform = GraphMergeTransform::new(vec![sub1, sub2]);
transform.apply(&mut primary);
assert_eq!(primary.nodes.len(), 3);
assert!(primary.nodes.contains_key("a"));
assert!(primary.nodes.contains_key("sub1.n1"));
assert!(primary.nodes.contains_key("sub2.n2"));
}
#[test]
fn graph_merge_preserves_node_attributes() {
let mut primary = Graph::new("primary");
let mut secondary = Graph::new("sub");
let mut node = Node::new("worker");
node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Do the work".to_string()),
);
node.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
secondary.nodes.insert("worker".to_string(), node);
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
let merged = &primary.nodes["sub.worker"];
assert_eq!(merged.id, "sub.worker");
assert_eq!(
merged.attrs.get("prompt").and_then(AttrValue::as_str),
Some("Do the work")
);
assert_eq!(
merged.attrs.get("shape").and_then(AttrValue::as_str),
Some("box")
);
}
#[test]
fn graph_merge_preserves_edge_attributes() {
let mut primary = Graph::new("primary");
let mut secondary = Graph::new("sub");
secondary.nodes.insert("x".to_string(), Node::new("x"));
secondary.nodes.insert("y".to_string(), Node::new("y"));
let mut edge = Edge::new("x", "y");
edge.attrs.insert(
"condition".to_string(),
AttrValue::String("outcome=success".to_string()),
);
secondary.edges.push(edge);
let transform = GraphMergeTransform::new(vec![secondary]);
transform.apply(&mut primary);
let merged_edge = primary
.edges
.iter()
.find(|e| e.from == "sub.x")
.expect("should have merged edge");
assert_eq!(merged_edge.to, "sub.y");
assert_eq!(
merged_edge
.attrs
.get("condition")
.and_then(AttrValue::as_str),
Some("outcome=success")
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ pub trait Transform {
}
mod file_inlining;
mod graph_merge;
mod import;
mod model_resolution;
mod preamble;
pub mod stylesheet;
@ -14,7 +14,7 @@ mod stylesheet_application;
pub mod variable_expansion;
pub use file_inlining::{FileInliningTransform, resolve_file_ref};
pub use graph_merge::GraphMergeTransform;
pub use import::ImportTransform;
pub use model_resolution::ModelResolutionTransform;
pub use preamble::PreambleTransform;
pub use stylesheet_application::StylesheetApplicationTransform;

View file

@ -3892,95 +3892,84 @@ async fn manager_loop_child_dotfile_e2e() {
}
// ===========================================================================
// 19c. GraphMerge E2E (TS Scenario 11)
// 19c. ImportTransform E2E (TS Scenario 11)
// ===========================================================================
#[tokio::test]
async fn graph_merge_e2e_through_engine() {
use fabro_workflows::transform::GraphMergeTransform;
// Module "val": lint -> test
let mut val_graph = Graph::new("val");
let mut lint = Node::new("lint");
lint.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
lint.attrs.insert(
"prompt".to_string(),
AttrValue::String("Lint the code".to_string()),
);
val_graph.nodes.insert("lint".to_string(), lint);
let mut test_node = Node::new("test");
test_node
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
test_node.attrs.insert(
"prompt".to_string(),
AttrValue::String("Run tests".to_string()),
);
val_graph.nodes.insert("test".to_string(), test_node);
val_graph.edges.push(Edge::new("lint", "test"));
// Module "dep": stage -> release
let mut dep_graph = Graph::new("dep");
let mut stage = Node::new("stage");
stage
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
stage.attrs.insert(
"prompt".to_string(),
AttrValue::String("Stage the release".to_string()),
);
dep_graph.nodes.insert("stage".to_string(), stage);
let mut release = Node::new("release");
release
.attrs
.insert("shape".to_string(), AttrValue::String("box".to_string()));
release.attrs.insert(
"prompt".to_string(),
AttrValue::String("Release it".to_string()),
);
dep_graph.nodes.insert("release".to_string(), release);
dep_graph.edges.push(Edge::new("stage", "release"));
// Main graph: start, exit; edges connect modules
let mut main_graph = Graph::new("MergeE2E");
main_graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test graph merge".to_string()),
);
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
main_graph.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
main_graph.nodes.insert("exit".to_string(), exit);
// Apply merge transform
let merge = GraphMergeTransform::new(vec![val_graph, dep_graph]);
merge.apply(&mut main_graph);
// Add cross-module edges
main_graph.edges.push(Edge::new("start", "val.lint"));
main_graph.edges.push(Edge::new("val.test", "dep.stage"));
main_graph.edges.push(Edge::new("dep.release", "exit"));
// Verify merged nodes exist
assert!(main_graph.nodes.contains_key("val.lint"));
assert!(main_graph.nodes.contains_key("val.test"));
assert!(main_graph.nodes.contains_key("dep.stage"));
assert!(main_graph.nodes.contains_key("dep.release"));
async fn import_e2e_through_engine() {
use fabro_workflows::pipeline::{TransformOptions, transform, validate};
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("val.fabro"),
r#"digraph validate {
start [shape=Mdiamond]
lint [prompt="Lint the code"]
test [prompt="Run tests"]
exit [shape=Msquare]
start -> lint -> test -> exit
}"#,
)
.unwrap();
std::fs::write(
dir.path().join("dep.fabro"),
r#"digraph deploy {
start [shape=Mdiamond]
stage [prompt="Stage the release"]
release [prompt="Release it"]
exit [shape=Msquare]
start -> stage -> release -> exit
}"#,
)
.unwrap();
let parsed = fabro_workflows::pipeline::parse(
r#"digraph MergeE2E {
graph [goal="Test file imports"]
start [shape=Mdiamond]
validate [import="./val.fabro"]
deploy [import="./dep.fabro"]
exit [shape=Msquare]
start -> validate -> deploy -> exit
}"#,
)
.expect("parse should succeed");
let transformed = transform(
parsed,
&TransformOptions {
base_dir: Some(dir.path().to_path_buf()),
custom_transforms: vec![],
},
);
let validated = validate(transformed, &[]);
validated
.raise_on_errors()
.expect("validation should pass after imports expand");
let (graph, _, _) = validated.into_parts();
assert!(graph.nodes.contains_key("validate.lint"));
assert!(graph.nodes.contains_key("validate.test"));
assert!(graph.nodes.contains_key("deploy.stage"));
assert!(graph.nodes.contains_key("deploy.release"));
assert!(
graph
.edges
.iter()
.any(|edge| edge.from == "start" && edge.to == "validate.lint")
);
assert!(
graph
.edges
.iter()
.any(|edge| edge.from == "validate.test" && edge.to == "deploy.stage")
);
assert!(
graph
.edges
.iter()
.any(|edge| edge.from == "deploy.release" && edge.to == "exit")
);
let engine = WorkflowRunner::new(
make_linear_registry(),
Arc::new(EventEmitter::new()),
@ -4001,47 +3990,51 @@ async fn graph_merge_e2e_through_engine() {
git: None,
};
let outcome = engine
.run(&main_graph, &run_options)
.run(&graph, &run_options)
.await
.expect("graph merge E2E should succeed");
.expect("import E2E should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")).unwrap();
assert!(
checkpoint.completed_nodes.contains(&"val.lint".to_string()),
"val.lint should be completed"
);
assert!(
checkpoint.completed_nodes.contains(&"val.test".to_string()),
"val.test should be completed"
checkpoint
.completed_nodes
.contains(&"validate.lint".to_string()),
"validate.lint should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.stage".to_string()),
"dep.stage should be completed"
.contains(&"validate.test".to_string()),
"validate.test should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"dep.release".to_string()),
"dep.release should be completed"
.contains(&"deploy.stage".to_string()),
"deploy.stage should be completed"
);
assert!(
checkpoint
.completed_nodes
.contains(&"deploy.release".to_string()),
"deploy.release should be completed"
);
// Verify ordering: val.test appears before dep.stage
// Verify ordering: validate.test appears before deploy.stage
let val_test_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "val.test")
.expect("val.test should be in completed_nodes");
.position(|n| n == "validate.test")
.expect("validate.test should be in completed_nodes");
let dep_stage_pos = checkpoint
.completed_nodes
.iter()
.position(|n| n == "dep.stage")
.expect("dep.stage should be in completed_nodes");
.position(|n| n == "deploy.stage")
.expect("deploy.stage should be in completed_nodes");
assert!(
val_test_pos < dep_stage_pos,
"val.test ({val_test_pos}) should execute before dep.stage ({dep_stage_pos})"
"validate.test ({val_test_pos}) should execute before deploy.stage ({dep_stage_pos})"
);
}