From 1245106cea3381c103fae1069e291f027d0c8ff6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 28 Feb 2026 17:16:24 -0500 Subject: [PATCH] Add reserved keyword node ID lint rule Warn when DOT reserved keywords (graph, digraph, subgraph, node, edge, strict, if) are used as node IDs since they cause silent routing failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/arc-attractor/src/validation/rules.rs | 93 +++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) 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