fabro/lib/components/fabro-validate/src/lib.rs
Bryan Helmkamp 2a2fc41807
Read catalog policy from lithos core fields and metadata.agent
Fabro's policy layer restated the lithos built-ins under `metadata.fabro`:
enabled flags, credentials, display facts, probe and small-default roles,
and agent profiles. lithos-llm now carries every one of those as a core
field or under the shared `metadata.agent` namespace, so the layer and its
typed view go:

- Delete `fabro-policy.toml` and `FABRO_POLICY_TOML`. The catalog is the
  lithos built-ins plus the operator's `[llm]` overlay, nothing between.
- Delete `fabro_types::catalog_policy`. `enabled`, `stands_in_for`,
  `api_key_url`, `family`, the cutoffs, `estimated_output_tps`,
  `small_default`, and `probe` are read from lithos accessors; the agent
  profile and `reasoning_by_default` come from `metadata.agent`, which
  Pebble reads too.
- `catalog::provider`, `enabled_providers`, and `listed_providers` return
  the lithos `CatalogProvider` directly; `ModelEntry` loses its policy
  field and gains `agent_profile()`.
- Test fixtures move `[providers.x.metadata.fabro] enabled = true` onto
  the provider table, drop `credentials` lists in favor of the secret name
  lithos derives from the provider id, and spell `agent_profile` as
  `metadata.agent.profile`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 23:20:12 -06:00

461 lines
14 KiB
Rust

pub mod rules;
use fabro_graphviz::graph::Graph;
use fabro_llm::lithos_catalog::Catalog;
use serde::{Deserialize, Serialize};
/// Severity level for validation diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Info,
}
/// A validation diagnostic produced by a lint rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub rule: String,
pub severity: Severity,
pub message: String,
pub node_id: Option<String>,
pub edge: Option<(String, String)>,
pub fix: Option<String>,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
pub span_start: Option<usize>,
pub span_len: Option<usize>,
#[serde(default)]
pub related: Vec<RelatedDiagnostic>,
}
impl Default for Diagnostic {
fn default() -> Self {
Self {
rule: String::new(),
severity: Severity::Info,
message: String::new(),
node_id: None,
edge: None,
fix: None,
source_path: None,
line: None,
column: None,
span_start: None,
span_len: None,
related: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedDiagnostic {
pub message: String,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// A lint rule that validates a graph.
pub trait LintRule {
fn name(&self) -> &'static str;
fn apply(&self, graph: &Graph) -> Vec<Diagnostic>;
}
/// Validation error returned when error-severity diagnostics are present.
#[derive(Debug, thiserror::Error)]
#[error("Validation error: {0}")]
pub struct ValidationError(pub String);
/// Run all built-in lint rules (and any extra rules) against the graph.
#[must_use]
pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec<Diagnostic> {
let built_in = rules::built_in_rules();
let mut diagnostics = Vec::new();
for rule in &built_in {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// Run all built-in catalog-free lint rules, caller-supplied model catalog
/// rules, and any extra rules against the graph.
#[must_use]
pub fn validate_with_catalog(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Vec<Diagnostic> {
let mut diagnostics = validate(graph, &[]);
let catalog_rules = rules::catalog_rules(catalog);
for rule in &catalog_rules {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// If any Error-severity diagnostics are present, return `ValidationError`.
///
/// # Errors
/// Returns `ValidationError` with joined error messages.
pub fn raise_on_errors(diagnostics: &[Diagnostic]) -> Result<(), ValidationError> {
let mut errors = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.peekable();
if errors.peek().is_some() {
let message = errors
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(ValidationError(message));
}
Ok(())
}
/// Run all built-in lint rules (and any extra rules). Returns Err if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_or_raise(
graph: &Graph,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate(graph, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
/// Run catalog-aware validation and return `ValidationError` if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_with_catalog_or_raise(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate_with_catalog(graph, catalog, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_graphviz::parser;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::test_support::test_catalog_with_overlay;
use super::*;
fn minimal_valid_graph() -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "exit"));
g
}
fn graph_with_model_and_provider(model: &str, provider: &str) -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut work = Node::new("work");
work.attrs
.insert("model".to_string(), AttrValue::String(model.to_string()));
work.attrs.insert(
"provider".to_string(),
AttrValue::String(provider.to_string()),
);
g.nodes.insert("work".to_string(), work);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
g
}
/// An operator-defined provider layered over the built-ins, the shape an
/// `[llm]` overlay produces.
fn custom_catalog() -> Catalog {
test_catalog_with_overlay(
r#"
[providers.acme-venice]
display_name = "Acme Venice"
adapter = "openai-compatible"
codec = "openai-chat"
base_url = "https://api.venice.ai/api/v1"
auth = { type = "bearer" }
default_model = "venice-large"
[providers.acme-venice.metadata.agent]
profile = "openai"
[providers.acme-venice.models.venice-large]
display_name = "Venice Large"
api_model = "venice-large"
limits = { context_tokens = 128000, max_output_tokens = 8192 }
capabilities = { text = true, tools = true }
"#,
)
}
#[test]
fn validate_minimal_valid_graph_has_no_errors() {
let g = minimal_valid_graph();
let diagnostics = validate(&g, &[]);
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
}
#[test]
fn validate_or_raise_passes_for_valid_graph() {
let g = minimal_valid_graph();
let result = validate_or_raise(&g, &[]);
assert!(result.is_ok());
}
#[test]
fn validate_rejects_join_policy_on_any_node() {
let mut g = minimal_valid_graph();
let mut fork = Node::new("fork");
fork.attrs.insert(
"shape".to_string(),
AttrValue::String("component".to_string()),
);
fork.attrs.insert(
"join_policy".to_string(),
AttrValue::String("wait_all".to_string()),
);
g.nodes.insert("fork".to_string(), fork);
let mut custom = Node::new("custom");
custom.attrs.insert(
"type".to_string(),
AttrValue::String("custom.handler".to_string()),
);
custom.attrs.insert(
"join_policy".to_string(),
AttrValue::String("first_success".to_string()),
);
g.nodes.insert("custom".to_string(), custom);
let diagnostics = validate(&g, &[]);
let removed = diagnostics
.iter()
.filter(|d| d.rule == "join_policy_removed")
.collect::<Vec<_>>();
assert_eq!(removed.len(), 2, "diagnostics: {diagnostics:?}");
assert!(removed.iter().all(|d| d.severity == Severity::Error));
assert!(
removed
.iter()
.all(|d| d.message.contains("Remove 'join_policy'"))
);
assert_eq!(
removed
.iter()
.filter_map(|d| d.node_id.as_deref())
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from(["custom", "fork"]),
);
}
#[test]
fn validate_or_raise_fails_for_missing_start() {
let mut g = Graph::new("test");
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_or_raise_fails_for_missing_exit() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_runs_extra_rules() {
struct AlwaysWarnRule;
impl LintRule for AlwaysWarnRule {
fn name(&self) -> &'static str {
"always_warn"
}
fn apply(&self, _graph: &Graph) -> Vec<Diagnostic> {
vec![Diagnostic {
rule: "always_warn".to_string(),
severity: Severity::Warning,
message: "custom warning".to_string(),
node_id: None,
edge: None,
fix: None,
..Diagnostic::default()
}]
}
}
let g = minimal_valid_graph();
let extra = AlwaysWarnRule;
let diagnostics = validate(&g, &[&extra]);
let custom: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "always_warn")
.collect();
assert_eq!(custom.len(), 1);
}
#[test]
fn validate_does_not_run_catalog_aware_model_rules() {
let g = graph_with_model_and_provider("not-in-any-catalog", "not-a-provider");
let diagnostics = validate(&g, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"catalog-free validation should not emit model/provider diagnostics: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_accepts_custom_catalog_entries() {
let g = graph_with_model_and_provider("venice-large", "acme-venice");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"custom catalog entries should validate cleanly: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_warns_for_unknown_model_and_provider() {
let g = graph_with_model_and_provider("missing-model", "missing-provider");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "node_model_known" && d.message.contains("missing-model")),
"missing model diagnostic not found: {diagnostics:?}"
);
assert!(
diagnostics.iter().any(|d| d.rule == "node_model_known"
&& d.message.contains("missing-provider")
&& d.message.contains("acme-venice")),
"missing provider diagnostic not found: {diagnostics:?}"
);
}
#[test]
fn diagnostic_severity_eq() {
assert_eq!(Severity::Error, Severity::Error);
assert_ne!(Severity::Error, Severity::Warning);
assert_ne!(Severity::Warning, Severity::Info);
}
#[expect(
clippy::disallowed_methods,
reason = "unit test reads checked-in DOT compatibility fixtures synchronously"
)]
#[test]
fn dot_compatibility_corpus_validates() {
let fixtures = dot_compatibility_fixtures();
assert_eq!(
fixtures.len(),
3,
"dot compatibility corpus should stay intentionally small"
);
for fixture in fixtures {
let source = std::fs::read_to_string(&fixture)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture.display()));
let graph = parser::parse(&source)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", fixture.display()));
validate_or_raise(&graph, &[])
.unwrap_or_else(|err| panic!("failed to validate {}: {err}", fixture.display()));
}
}
#[expect(
clippy::disallowed_methods,
reason = "unit test helper enumerates checked-in DOT compatibility fixtures synchronously"
)]
fn dot_compatibility_fixtures() -> Vec<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/dot-compatibility");
let mut fixtures = std::fs::read_dir(&dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
.map(|entry| {
entry
.unwrap_or_else(|err| {
panic!("failed to read entry in {}: {err}", dir.display())
})
.path()
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("fabro"))
.collect::<Vec<_>>();
fixtures.sort();
fixtures
}
}