mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
## Summary Template failures from `fabro run` and structural warnings from `fabro validate` now preserve source provenance through rendering, workflow transforms, API serialization, and CLI display. Diagnostics can point at the actual workflow, import, or prompt file with node/attribute context instead of surfacing MiniJinja's generic `<string>` source. ## What Changed - Added named MiniJinja render APIs plus miette-aware `TemplateError` metadata for source names, source text, spans, and labels. - Reworked workflow template expansion so inline attributes, imported workflows, and `@prompt` files render with file and owner context. - Split strict run behavior from structural validate behavior: run-start still hard-fails on missing inputs, while validate emits source-aware warnings and continues linting. - Extended validation diagnostics through Rust structs, OpenAPI, server DTO mapping, and CLI rendering with optional source path, line, column, span, and related metadata. - Added regression coverage across template rendering, workflow transforms, CLI output, and the server validate endpoint. ## Verification - `cargo nextest run -p fabro-template` - `ulimit -n 4096 && cargo nextest run -p fabro-workflow --no-fail-fast` - `cargo nextest run -p fabro-cli bare_fabro_with_unbound_inputs_validates_structurally_with_warning run_rejects_unbound_template_inputs_before_creating_remote_run` - `cargo nextest run -p fabro-server validate_endpoint_returns_template_source_coordinates` - `cargo build -p fabro-api` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex) --------- Co-authored-by: Aleksi Asikainen <1086393+salieri@users.noreply.github.com>
144 lines
4.8 KiB
Rust
144 lines
4.8 KiB
Rust
use fabro_graphviz::graph::{Graph, Node};
|
|
use fabro_types::LlmBackend;
|
|
|
|
use crate::{Diagnostic, LintRule, Severity};
|
|
|
|
pub(super) fn rule() -> Box<dyn LintRule> {
|
|
Box::new(Rule)
|
|
}
|
|
|
|
struct Rule;
|
|
|
|
impl LintRule for Rule {
|
|
fn name(&self) -> &'static str {
|
|
"backend_valid"
|
|
}
|
|
|
|
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
|
|
let mut diagnostics = Vec::new();
|
|
for node in graph.nodes.values() {
|
|
if let Some(backend) = node.backend() {
|
|
match node.llm_backend() {
|
|
Some(Err(_)) => {
|
|
let expected = LlmBackend::expected_values();
|
|
diagnostics.push(Diagnostic {
|
|
rule: self.name().to_string(),
|
|
severity: Severity::Error,
|
|
message: format!(
|
|
"unsupported LLM backend \"{backend}\"; expected one of: {expected}"
|
|
),
|
|
node_id: Some(node.id.clone()),
|
|
edge: None,
|
|
fix: Some(format!("Use one of: {expected}")),
|
|
|
|
..Diagnostic::default()
|
|
});
|
|
}
|
|
Some(Ok(LlmBackend::Acp)) if acp_command_missing(node) => {
|
|
diagnostics.push(Diagnostic {
|
|
rule: self.name().to_string(),
|
|
severity: Severity::Error,
|
|
message: "backend=\"acp\" requires acp_command because Fabro does \
|
|
not install ACP agents"
|
|
.to_string(),
|
|
node_id: Some(node.id.clone()),
|
|
edge: None,
|
|
fix: Some(
|
|
"Set acp_command to a stdio ACP command available in the sandbox"
|
|
.to_string(),
|
|
),
|
|
|
|
..Diagnostic::default()
|
|
});
|
|
}
|
|
Some(Ok(_)) | None => {}
|
|
}
|
|
}
|
|
}
|
|
diagnostics
|
|
}
|
|
}
|
|
|
|
fn acp_command_missing(node: &Node) -> bool {
|
|
match node.acp_command() {
|
|
Some(command) => command.trim().is_empty(),
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use fabro_graphviz::graph::{AttrValue, Node};
|
|
|
|
use super::Rule;
|
|
use crate::rules::test_support::minimal_graph;
|
|
use crate::{LintRule, Severity};
|
|
|
|
#[test]
|
|
fn backend_valid_accepts_absent_api_and_cli() {
|
|
for backend in [None, Some("api"), Some("cli")] {
|
|
let mut graph = minimal_graph();
|
|
let mut node = Node::new("work");
|
|
if let Some(backend) = backend {
|
|
node.attrs.insert(
|
|
"backend".to_string(),
|
|
AttrValue::String(backend.to_string()),
|
|
);
|
|
}
|
|
graph.nodes.insert("work".to_string(), node);
|
|
|
|
assert!(Rule.apply(&graph).is_empty(), "backend: {backend:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn backend_valid_rejects_unknown_backend() {
|
|
let mut graph = minimal_graph();
|
|
let mut node = Node::new("work");
|
|
node.attrs.insert(
|
|
"backend".to_string(),
|
|
AttrValue::String("codex".to_string()),
|
|
);
|
|
graph.nodes.insert("work".to_string(), node);
|
|
|
|
let diagnostics = Rule.apply(&graph);
|
|
assert_eq!(diagnostics.len(), 1);
|
|
assert_eq!(diagnostics[0].severity, Severity::Error);
|
|
assert!(
|
|
diagnostics[0]
|
|
.message
|
|
.contains("unsupported LLM backend \"codex\"; expected one of: api, cli, acp")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backend_valid_requires_acp_command_for_acp_backend() {
|
|
let mut graph = minimal_graph();
|
|
let mut node = Node::new("work");
|
|
node.attrs
|
|
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
|
|
graph.nodes.insert("work".to_string(), node);
|
|
|
|
let diagnostics = Rule.apply(&graph);
|
|
assert_eq!(diagnostics.len(), 1);
|
|
assert_eq!(diagnostics[0].severity, Severity::Error);
|
|
assert!(diagnostics[0].message.contains(
|
|
"backend=\"acp\" requires acp_command because Fabro does not install ACP agents"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn backend_valid_accepts_acp_backend_with_acp_command() {
|
|
let mut graph = minimal_graph();
|
|
let mut node = Node::new("work");
|
|
node.attrs
|
|
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
|
|
node.attrs.insert(
|
|
"acp_command".to_string(),
|
|
AttrValue::String("agent-acp".to_string()),
|
|
);
|
|
graph.nodes.insert("work".to_string(), node);
|
|
|
|
assert!(Rule.apply(&graph).is_empty());
|
|
}
|
|
}
|