diff --git a/crates/arc-workflows/src/validation/rules.rs b/crates/arc-workflows/src/validation/rules.rs index badd35a0c..46d74d155 100644 --- a/crates/arc-workflows/src/validation/rules.rs +++ b/crates/arc-workflows/src/validation/rules.rs @@ -5,7 +5,7 @@ use crate::graph::{AttrValue, Graph}; use super::{Diagnostic, LintRule, Severity}; -/// Returns all 18 built-in lint rules. +/// Returns all 19 built-in lint rules. #[must_use] pub fn built_in_rules() -> Vec> { vec![ @@ -27,6 +27,7 @@ pub fn built_in_rules() -> Vec> { Box::new(ReservedKeywordNodeIdRule), Box::new(AllConditionalEdgesRule), Box::new(OrphanCustomOutcomeRule), + Box::new(ScriptAbsoluteCdRule), ] } @@ -834,6 +835,69 @@ impl LintRule for OrphanCustomOutcomeRule { } } +// --- Rule 19: script_absolute_cd (WARNING) --- + +struct ScriptAbsoluteCdRule; + +/// Returns true if `text` contains `cd` followed by whitespace and then `/`. +fn contains_cd_absolute(text: &str) -> bool { + let bytes = text.as_bytes(); + let len = bytes.len(); + let mut i = 0; + while i + 3 < len { + if bytes[i] == b'c' && bytes[i + 1] == b'd' && bytes[i + 2].is_ascii_whitespace() { + // found "cd", scan past remaining whitespace to check for '/' + let mut j = i + 2; + while j < len && bytes[j].is_ascii_whitespace() { + j += 1; + } + if j < len && bytes[j] == b'/' { + return true; + } + } + i += 1; + } + false +} + +impl LintRule for ScriptAbsoluteCdRule { + fn name(&self) -> &'static str { + "script_absolute_cd" + } + + fn apply(&self, graph: &Graph) -> Vec { + let mut diagnostics = Vec::new(); + for node in graph.nodes.values() { + if node.handler_type() != Some("script") { + continue; + } + let script = node + .attrs + .get("script") + .or_else(|| node.attrs.get("tool_command")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if contains_cd_absolute(script) { + diagnostics.push(Diagnostic { + rule: self.name().to_string(), + severity: Severity::Warning, + message: format!( + "Script node '{}' contains `cd /…` with an absolute path", + node.id + ), + node_id: Some(node.id.clone()), + edge: None, + fix: Some( + "Use a relative path; the engine sets the working directory to the worktree automatically" + .to_string(), + ), + }); + } + } + diagnostics + } +} + #[cfg(test)] mod tests { use super::*; @@ -2505,4 +2569,80 @@ mod tests { let d = rule.apply(&g); assert!(d.is_empty()); } + + // script_absolute_cd rule tests + + #[test] + fn script_absolute_cd_warns_on_cd_abs_path() { + let mut g = minimal_graph(); + let mut node = Node::new("run"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + node.attrs.insert( + "script".to_string(), + AttrValue::String("cd /tmp && ls".to_string()), + ); + g.nodes.insert("run".to_string(), node); + let rule = ScriptAbsoluteCdRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + } + + #[test] + fn script_absolute_cd_no_warning_on_relative() { + let mut g = minimal_graph(); + let mut node = Node::new("run"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + node.attrs.insert( + "script".to_string(), + AttrValue::String("cd src && ls".to_string()), + ); + g.nodes.insert("run".to_string(), node); + let rule = ScriptAbsoluteCdRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn script_absolute_cd_warns_on_legacy_tool_command() { + let mut g = minimal_graph(); + let mut node = Node::new("run"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("parallelogram".to_string()), + ); + node.attrs.insert( + "tool_command".to_string(), + AttrValue::String("cd /home/user && make".to_string()), + ); + g.nodes.insert("run".to_string(), node); + let rule = ScriptAbsoluteCdRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + } + + #[test] + fn script_absolute_cd_skips_non_script_nodes() { + let mut g = minimal_graph(); + let mut node = Node::new("gen"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("box".to_string()), + ); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("cd /tmp and do stuff".to_string()), + ); + g.nodes.insert("gen".to_string(), node); + let rule = ScriptAbsoluteCdRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } }