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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 17:16:24 -05:00
parent ea89a62768
commit 1245106cea

View file

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