fabro/lib/crates/fabro-validate/src/rules/backend_valid.rs
Bryan Helmkamp 2ba04be181
feat(template): add source-aware diagnostics (#292)
## 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`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](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>
2026-05-16 18:47:37 -04:00

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());
}
}