Skip to main content

vtcode_skills/
enhanced_validator.rs

1//! Enhanced skill validator with comprehensive error collection
2//!
3//! Validates skills against Agent Skills specification and collects all issues
4//! instead of failing on the first error.
5
6use crate::file_references::FileReferenceValidator;
7use crate::types::SkillManifest;
8use crate::validation_report::SkillValidationReport;
9use std::path::Path;
10
11/// Enhanced validator that collects all validation issues
12pub struct ComprehensiveSkillValidator {
13    strict_mode: bool,
14}
15
16impl ComprehensiveSkillValidator {
17    pub fn new() -> Self {
18        Self { strict_mode: false }
19    }
20
21    pub fn strict() -> Self {
22        Self { strict_mode: true }
23    }
24
25    /// Validate a skill manifest comprehensively
26    pub fn validate_manifest(&self, manifest: &SkillManifest, skill_path: &Path) -> SkillValidationReport {
27        let mut report = SkillValidationReport::new(manifest.name.clone(), skill_path.to_path_buf());
28
29        // Validate name field
30        self.validate_name_field(manifest, &mut report);
31
32        // Validate description field
33        self.validate_description_field(manifest, &mut report);
34
35        // Validate directory name match
36        self.validate_directory_match(manifest, skill_path, &mut report);
37
38        // Validate optional fields
39        self.validate_optional_fields(manifest, &mut report);
40
41        // Validate instructions length
42        self.validate_instructions_length(manifest, &mut report);
43
44        report.finalize();
45        report
46    }
47
48    /// Validate name field with all checks
49    fn validate_name_field(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
50        // Check empty
51        if manifest.name.is_empty() {
52            report.add_error(Some("name".to_string()), "name is required and must not be empty".to_string(), None);
53            return;
54        }
55
56        // Check length
57        if manifest.name.len() > 64 {
58            report.add_error(
59                Some("name".to_string()),
60                format!("name exceeds maximum length: {} characters (max 64)", manifest.name.len()),
61                Some("Use a shorter name (1-64 characters)".to_string()),
62            );
63        }
64
65        // Check for valid characters
66        if !manifest.name.chars().all(|c| c.is_lowercase() || c.is_numeric() || c == '-') {
67            report.add_error(
68                Some("name".to_string()),
69                format!(
70                    "name contains invalid characters: '{}'\nMust contain only lowercase letters, numbers, and hyphens",
71                    manifest.name
72                ),
73                Some("Use only a-z, 0-9, and hyphens".to_string()),
74            );
75        }
76
77        // Check consecutive hyphens
78        if manifest.name.contains("--") {
79            report.add_error(
80                Some("name".to_string()),
81                format!("name contains consecutive hyphens: '{}'", manifest.name),
82                Some("Remove consecutive hyphens (--)".to_string()),
83            );
84        }
85
86        // Check leading hyphen
87        if manifest.name.starts_with('-') {
88            report.add_error(
89                Some("name".to_string()),
90                format!("name starts with hyphen: '{}'", manifest.name),
91                Some("Remove the leading hyphen".to_string()),
92            );
93        }
94
95        // Check trailing hyphen
96        if manifest.name.ends_with('-') {
97            report.add_error(
98                Some("name".to_string()),
99                format!("name ends with hyphen: '{}'", manifest.name),
100                Some("Remove the trailing hyphen".to_string()),
101            );
102        }
103
104        // Check reserved words
105        if manifest.name.contains("anthropic") || manifest.name.contains("claude") {
106            report.add_error(
107                Some("name".to_string()),
108                format!("name contains reserved word: '{}'\nMust not contain 'anthropic' or 'claude'", manifest.name),
109                Some("Choose a different name without these words".to_string()),
110            );
111        }
112    }
113
114    /// Validate description field
115    fn validate_description_field(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
116        if manifest.description.is_empty() {
117            report.add_error(
118                Some("description".to_string()),
119                "description is required and must not be empty".to_string(),
120                Some("Add a description explaining what the skill does and when to use it".to_string()),
121            );
122            return;
123        }
124
125        if manifest.description.len() > 1024 {
126            report.add_error(
127                Some("description".to_string()),
128                format!("description exceeds maximum length: {} characters (max 1024)", manifest.description.len()),
129                Some("Shorten the description to 1024 characters or less".to_string()),
130            );
131        }
132
133        // Suggest longer description if too short
134        if manifest.description.len() < 50 {
135            report.add_suggestion(Some("description".to_string()), "Description is very short".to_string());
136        }
137    }
138
139    /// Validate directory name matches skill name
140    fn validate_directory_match(
141        &self,
142        manifest: &SkillManifest,
143        skill_path: &Path,
144        report: &mut SkillValidationReport,
145    ) {
146        if let Err(e) = manifest.validate_directory_name_match(skill_path) {
147            report.add_warning(
148                Some("name".to_string()),
149                e.to_string(),
150                Some("Rename the skill directory to match the name field, or rename the skill".to_string()),
151            );
152        }
153    }
154
155    /// Validate all optional fields
156    fn validate_optional_fields(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
157        // Validate allowed-tools field
158        if let Some(allowed_tools) = &manifest.allowed_tools {
159            let tools: Vec<&str> = allowed_tools.split_whitespace().collect();
160
161            if tools.len() > 16 {
162                report.add_error(
163                    Some("allowed-tools".to_string()),
164                    format!("allowed-tools exceeds maximum tool count: {} tools (max 16)", tools.len()),
165                    Some("Reduce the number of tools to 16 or fewer".to_string()),
166                );
167            }
168
169            if tools.is_empty() {
170                report.add_error(
171                    Some("allowed-tools".to_string()),
172                    "allowed-tools must not be empty if specified".to_string(),
173                    Some("Either remove the field or add valid tool names".to_string()),
174                );
175            }
176        }
177
178        // Validate license field
179        if let Some(license) = &manifest.license
180            && license.len() > 512
181        {
182            report.add_error(
183                Some("license".to_string()),
184                format!("license exceeds maximum length: {} characters (max 512)", license.len()),
185                Some("Shorten the license field".to_string()),
186            );
187        }
188
189        // Validate compatibility field
190        if let Some(compatibility) = &manifest.compatibility {
191            if compatibility.is_empty() {
192                report.add_error(
193                    Some("compatibility".to_string()),
194                    "compatibility must not be empty if specified".to_string(),
195                    Some("Either remove the field or add meaningful compatibility info".to_string()),
196                );
197            } else if compatibility.len() > 500 {
198                report.add_error(
199                    Some("compatibility".to_string()),
200                    format!("compatibility exceeds maximum length: {} characters (max 500)", compatibility.len()),
201                    Some("Shorten the compatibility field".to_string()),
202                );
203            }
204        }
205
206        // Suggest adding optional fields if missing
207        if manifest.license.is_none() {
208            report.add_suggestion(Some("license".to_string()), "Consider adding a license field".to_string());
209        }
210
211        if manifest.compatibility.is_none() {
212            report.add_suggestion(
213                Some("compatibility".to_string()),
214                "Consider adding a compatibility field if the skill has specific requirements".to_string(),
215            );
216        }
217
218        if !self.description_has_routing_signals(&manifest.description) {
219            self.report_routing_quality_issue(
220                report,
221                "description",
222                "Description lacks concrete routing signals (inputs/triggers/outputs)",
223                "Rewrite description as routing logic: when to use, when not to use, expected result.",
224            );
225        }
226    }
227
228    fn report_routing_quality_issue(
229        &self,
230        report: &mut SkillValidationReport,
231        field: &str,
232        message: &str,
233        remediation: &str,
234    ) {
235        if self.strict_mode {
236            report.add_error(Some(field.to_string()), message.to_string(), Some(remediation.to_string()));
237        } else {
238            report.add_warning(Some(field.to_string()), message.to_string(), Some(remediation.to_string()));
239        }
240    }
241
242    fn description_has_routing_signals(&self, description: &str) -> bool {
243        let text = description.to_lowercase();
244        let has_trigger_hint =
245            text.contains("when") || text.contains("if ") || text.contains("for ") || text.contains("trigger");
246        let has_output_hint = text.contains("output")
247            || text.contains("returns")
248            || text.contains("result")
249            || text.contains("generat")
250            || text.contains("produ");
251        let has_action_hint = text.contains("analy")
252            || text.contains("extract")
253            || text.contains("transform")
254            || text.contains("validate")
255            || text.contains("summar")
256            || text.contains("convert")
257            || text.contains("clean");
258
259        has_action_hint && (has_trigger_hint || has_output_hint)
260    }
261
262    /// Validate instructions length (suggest keeping under 500 lines)
263    fn validate_instructions_length(&self, _manifest: &SkillManifest, report: &mut SkillValidationReport) {
264        // This is a suggestion based on the spec recommendation
265        report.add_suggestion(None, "Keep SKILL.md under 500 lines for optimal context usage".to_string());
266    }
267
268    /// Validate file references in instructions
269    pub fn validate_file_references(
270        &self,
271        _manifest: &SkillManifest,
272        skill_path: &Path,
273        instructions: &str,
274        report: &mut SkillValidationReport,
275    ) {
276        let skill_root = skill_path.parent().unwrap_or(skill_path);
277        let validator = FileReferenceValidator::new(skill_root.to_path_buf());
278        let reference_errors = validator.validate_references(instructions);
279
280        for error in reference_errors {
281            // In strict mode, treat reference errors as errors, otherwise warnings
282            if self.strict_mode {
283                report.add_error(
284                    None,
285                    format!("File reference issue: {error}"),
286                    Some("Fix the file reference or ensure the referenced file exists".to_string()),
287                );
288            } else {
289                report.add_warning(
290                    None,
291                    format!("File reference issue: {error}"),
292                    Some("Fix the file reference or ensure the referenced file exists".to_string()),
293                );
294            }
295        }
296
297        // List valid references as info
298        let valid_refs = validator.list_valid_references();
299        if !valid_refs.is_empty() {
300            let ref_list: Vec<String> = valid_refs.iter().map(|p| p.to_string_lossy().to_string()).collect();
301            report.add_suggestion(
302                None,
303                format!("Found {} valid file references: {}", ref_list.len(), ref_list.join(", ")),
304            );
305        }
306    }
307}
308
309impl Default for ComprehensiveSkillValidator {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use std::path::PathBuf;
319
320    #[test]
321    fn test_comprehensive_validation() {
322        let validator = ComprehensiveSkillValidator::new();
323        let manifest = SkillManifest {
324            name: "test-skill".to_string(),
325            description: "A test skill for validation".to_string(),
326            version: Some("1.0.0".to_string()),
327            author: Some("Test Author".to_string()),
328            allowed_tools: Some("Read Write Bash".to_string()),
329            compatibility: Some("Designed for VT Code".to_string()),
330            ..Default::default()
331        };
332
333        // Note: We can't easily test directory validation without creating temp dirs
334        // So we'll test with a non-existent path which should generate warnings
335        let report = validator.validate_manifest(&manifest, PathBuf::from("/tmp/nonexistent").as_path());
336
337        // Should have some suggestions for missing fields
338        assert!(report.suggestions.iter().any(|s| s.field == Some("license".to_string())));
339    }
340}