1use crate::skills::file_references::FileReferenceValidator;
7use crate::skills::types::SkillManifest;
8use crate::skills::validation_report::SkillValidationReport;
9use std::path::Path;
10
11pub 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 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 self.validate_name_field(manifest, &mut report);
36
37 self.validate_description_field(manifest, &mut report);
39
40 self.validate_directory_match(manifest, skill_path, &mut report);
42
43 self.validate_optional_fields(manifest, &mut report);
45
46 self.validate_instructions_length(manifest, &mut report);
48
49 report.finalize();
50 report
51 }
52
53 fn validate_name_field(&self, manifest: &SkillManifest, report: &mut SkillValidationReport) {
55 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 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 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 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 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 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 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 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 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 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 fn validate_optional_fields(
192 &self,
193 manifest: &SkillManifest,
194 report: &mut SkillValidationReport,
195 ) {
196 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 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 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 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 fn validate_instructions_length(
328 &self,
329 _manifest: &SkillManifest,
330 report: &mut SkillValidationReport,
331 ) {
332 report.add_suggestion(
334 None,
335 "Keep SKILL.md under 500 lines for optimal context usage".to_string(),
336 );
337 }
338
339 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 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 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 let report =
414 validator.validate_manifest(&manifest, PathBuf::from("/tmp/nonexistent").as_path());
415
416 assert!(
418 report
419 .suggestions
420 .iter()
421 .any(|s| s.field == Some("license".to_string()))
422 );
423 }
424}