Skip to main content

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