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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 11:34:04 -05:00
parent 5fd4a5ea2e
commit 9556dcdcb1

View file

@ -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<Box<dyn LintRule>> {
vec![
@ -28,6 +29,7 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
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<Diagnostic> {
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());
}
}