mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Add file-based workflow imports
This commit is contained in:
parent
007fb54286
commit
847c0481d0
6 changed files with 1515 additions and 348 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
1227
lib/crates/fabro-workflows/src/transforms/import.rs
Normal file
1227
lib/crates/fabro-workflows/src/transforms/import.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue