diff --git a/crates/arc-attractor/src/validation/rules.rs b/crates/arc-attractor/src/validation/rules.rs index 1e1e9d8e1..dada4c7ed 100644 --- a/crates/arc-attractor/src/validation/rules.rs +++ b/crates/arc-attractor/src/validation/rules.rs @@ -4,7 +4,7 @@ use crate::graph::{AttrValue, Graph}; use super::{Diagnostic, LintRule, Severity}; -/// Returns all 15 built-in lint rules. +/// Returns all 16 built-in lint rules. #[must_use] pub fn built_in_rules() -> Vec> { vec![ @@ -23,6 +23,7 @@ pub fn built_in_rules() -> Vec> { Box::new(PromptOnLlmNodesRule), Box::new(FreeformEdgeCountRule), Box::new(DirectionValidRule), + Box::new(ReservedKeywordNodeIdRule), ] } @@ -703,6 +704,42 @@ impl LintRule for DirectionValidRule { } } +// --- Rule 16: reserved_keyword_node_id (WARNING) --- + +struct ReservedKeywordNodeIdRule; + +const DOT_RESERVED_KEYWORDS: &[&str] = &[ + "graph", "digraph", "subgraph", "node", "edge", "strict", "if", +]; + +impl LintRule for ReservedKeywordNodeIdRule { + fn name(&self) -> &'static str { + "reserved_keyword_node_id" + } + + fn apply(&self, graph: &Graph) -> Vec { + graph + .nodes + .values() + .filter(|node| DOT_RESERVED_KEYWORDS.contains(&node.id.to_lowercase().as_str())) + .map(|node| Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: format!( + "Node ID '{}' is a DOT reserved keyword and may cause parsing failures", + node.id + ), + node_id: Some(node.id.clone()), + edge: None, + fix: Some(format!( + "Rename '{}' to '{}_step' or another non-reserved ID", + node.id, node.id.to_lowercase() + )), + }) + .collect() + } +} + #[cfg(test)] mod tests { use super::*; @@ -2164,4 +2201,58 @@ mod tests { let d = rule.apply(&g); assert!(d.is_empty()); } + + // --- reserved_keyword_node_id tests --- + + #[test] + fn reserved_keyword_node_id_warns_on_keyword() { + let mut g = minimal_graph(); + g.nodes + .insert("graph".to_string(), Node::new("graph")); + g.edges.push(Edge::new("start", "graph")); + g.edges.push(Edge::new("graph", "exit")); + let rule = ReservedKeywordNodeIdRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert_eq!(d[0].node_id.as_deref(), Some("graph")); + } + + #[test] + fn reserved_keyword_node_id_case_insensitive() { + let mut g = minimal_graph(); + g.nodes.insert("Node".to_string(), Node::new("Node")); + g.nodes.insert("EDGE".to_string(), Node::new("EDGE")); + g.edges.push(Edge::new("start", "Node")); + g.edges.push(Edge::new("Node", "EDGE")); + g.edges.push(Edge::new("EDGE", "exit")); + let rule = ReservedKeywordNodeIdRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 2); + } + + #[test] + fn reserved_keyword_node_id_normal_id_no_warning() { + let g = minimal_graph(); + let rule = ReservedKeywordNodeIdRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn reserved_keyword_node_id_multiple_keywords() { + let mut g = minimal_graph(); + g.nodes + .insert("strict".to_string(), Node::new("strict")); + g.nodes + .insert("digraph".to_string(), Node::new("digraph")); + g.nodes.insert("if".to_string(), Node::new("if")); + g.edges.push(Edge::new("start", "strict")); + g.edges.push(Edge::new("strict", "digraph")); + g.edges.push(Edge::new("digraph", "if")); + g.edges.push(Edge::new("if", "exit")); + let rule = ReservedKeywordNodeIdRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 3); + } } \ No newline at end of file