From 9556dcdcb196dd1ed1f5cae90bd9bdd10cf96d0a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 4 Mar 2026 11:34:04 -0500 Subject: [PATCH] Add stylesheet_model_known lint rule to validate model/provider names Checks llm_model and llm_provider values in model_stylesheet against the built-in catalog during `arc validate`. Unknown models or providers emit warnings (not errors) since the catalog is advisory, not restrictive. Co-Authored-By: Claude Opus 4.6 --- crates/arc-workflows/src/validation/rules.rs | 145 ++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/crates/arc-workflows/src/validation/rules.rs b/crates/arc-workflows/src/validation/rules.rs index dcd6a8e52..8dcaf1101 100644 --- a/crates/arc-workflows/src/validation/rules.rs +++ b/crates/arc-workflows/src/validation/rules.rs @@ -1,11 +1,12 @@ use std::collections::{HashSet, VecDeque}; +use std::str::FromStr; use crate::condition::parse_condition; use crate::graph::{AttrValue, Graph}; use super::{Diagnostic, LintRule, Severity}; -/// Returns all 19 built-in lint rules. +/// Returns all 20 built-in lint rules. #[must_use] pub fn built_in_rules() -> Vec> { vec![ @@ -28,6 +29,7 @@ pub fn built_in_rules() -> Vec> { Box::new(AllConditionalEdgesRule), Box::new(OrphanCustomOutcomeRule), Box::new(ScriptAbsoluteCdRule), + Box::new(StylesheetModelKnownRule), ] } @@ -898,6 +900,82 @@ impl LintRule for ScriptAbsoluteCdRule { } } +// --- Rule 20: stylesheet_model_known (WARNING) --- + +struct StylesheetModelKnownRule; + +impl StylesheetModelKnownRule { + fn selector_label(selector: &crate::stylesheet::Selector) -> String { + match selector { + crate::stylesheet::Selector::Universal => "*".to_string(), + crate::stylesheet::Selector::Shape(s) => s.clone(), + crate::stylesheet::Selector::Class(c) => format!(".{c}"), + crate::stylesheet::Selector::Id(id) => format!("#{id}"), + } + } +} + +impl LintRule for StylesheetModelKnownRule { + fn name(&self) -> &'static str { + "stylesheet_model_known" + } + + fn apply(&self, graph: &Graph) -> Vec { + let stylesheet_str = graph.model_stylesheet(); + if stylesheet_str.is_empty() { + return Vec::new(); + } + let stylesheet = match crate::stylesheet::parse_stylesheet(stylesheet_str) { + Ok(ss) => ss, + Err(_) => return Vec::new(), // syntax errors caught by stylesheet_syntax rule + }; + + let mut diagnostics = Vec::new(); + for rule in &stylesheet.rules { + let label = Self::selector_label(&rule.selector); + for decl in &rule.declarations { + match decl.property.as_str() { + "llm_model" => { + if arc_llm::catalog::get_model_info(&decl.value).is_none() { + diagnostics.push(Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: format!( + "Unknown model '{}' in stylesheet rule '{label}'. Run `arc models list` to see available models", + decl.value + ), + node_id: None, + edge: None, + fix: Some("Use a model ID from `arc models list`".to_string()), + }); + } + } + "llm_provider" => { + if arc_llm::Provider::from_str(&decl.value).is_err() { + let valid: Vec<&str> = + arc_llm::Provider::ALL.iter().map(|p| p.as_str()).collect(); + diagnostics.push(Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: format!( + "Unknown provider '{}' in stylesheet rule '{label}'. Valid providers: {}", + decl.value, + valid.join(", ") + ), + node_id: None, + edge: None, + fix: Some(format!("Use one of: {}", valid.join(", "))), + }); + } + } + _ => {} + } + } + } + diagnostics + } +} + #[cfg(test)] mod tests { use super::*; @@ -2643,4 +2721,69 @@ mod tests { let d = rule.apply(&g); assert!(d.is_empty()); } + + // stylesheet_model_known rule tests + + #[test] + fn stylesheet_model_known_rule_valid() { + let mut g = minimal_graph(); + g.attrs.insert( + "model_stylesheet".to_string(), + AttrValue::String( + "* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }".to_string(), + ), + ); + let rule = StylesheetModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn stylesheet_model_known_rule_unknown_model() { + let mut g = minimal_graph(); + g.attrs.insert( + "model_stylesheet".to_string(), + AttrValue::String("#opus { llm_model: claude-opus-4-5; }".to_string()), + ); + let rule = StylesheetModelKnownRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert!(d[0].message.contains("claude-opus-4-5")); + assert!(d[0].message.contains("#opus")); + } + + #[test] + fn stylesheet_model_known_rule_unknown_provider() { + let mut g = minimal_graph(); + g.attrs.insert( + "model_stylesheet".to_string(), + AttrValue::String("* { llm_provider: google; }".to_string()), + ); + let rule = StylesheetModelKnownRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert!(d[0].message.contains("google")); + } + + #[test] + fn stylesheet_model_known_rule_alias() { + let mut g = minimal_graph(); + g.attrs.insert( + "model_stylesheet".to_string(), + AttrValue::String("* { llm_model: opus; }".to_string()), + ); + let rule = StylesheetModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn stylesheet_model_known_rule_no_stylesheet() { + let g = minimal_graph(); + let rule = StylesheetModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } }