UNPKG

claude-flow-novice

Version:

Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.

396 lines (395 loc) 14.1 kB
/** * Skill Markdown Validator * * Enforces consistent structure for all Markdown skill files with validated * frontmatter and content sections. * * @module skill-markdown-validator * @version 1.0.0 */ import { parseFrontmatter, validateFrontmatter } from './skill-frontmatter-parser.js'; import { StandardError } from './errors.js'; import { getSafePath, PathValidationError } from './path-validator.js'; import * as fs from 'fs'; import * as path from 'path'; /** * Skill markdown validation error */ export class SkillMarkdownError extends StandardError { constructor(message, context){ super('SKILL_MARKDOWN_VALIDATION_ERROR', message, context); this.name = 'SkillMarkdownError'; } } /** * Required sections in skill files (in order) */ export const REQUIRED_SECTIONS = [ 'Overview', 'Usage', 'Examples', 'Implementation', 'Tests' ]; /** * Optional sections (can appear after required sections) */ export const OPTIONAL_SECTIONS = [ 'API Reference', 'Configuration', 'Related Skills', 'References', 'Troubleshooting', 'Performance', 'Security', 'Quick Start' ]; /** * Supported code block languages */ export const SUPPORTED_LANGUAGES = [ 'bash', 'sh', 'typescript', 'javascript', 'json', 'yaml', 'yml', 'markdown', 'md', 'python', 'sql', 'html', 'css', 'dockerfile', 'plaintext', 'text' ]; /** * Minimum content length per section (characters) */ export const MIN_SECTION_LENGTH = 50; /** * Validate complete skill markdown file * * @param content - Raw SKILL.md content * @param basePath - Base path for link validation (optional) * @returns Validation result * @throws SkillMarkdownError if parsing fails */ export function validateSkillMarkdown(content, basePath) { const errors = []; const warnings = []; let frontmatterValid = false; let contentValid = false; let codeBlocksValid = false; let linksValid = false; try { // Step 1: Parse and validate frontmatter const parsed = parseFrontmatter(content); const frontmatterValidation = validateFrontmatter(parsed.frontmatter); frontmatterValid = frontmatterValidation.valid; errors.push(...frontmatterValidation.errors); warnings.push(...frontmatterValidation.warnings); // Step 2: Validate content structure const contentValidation = validateContentStructure(parsed.content); contentValid = contentValidation.valid; errors.push(...contentValidation.errors); warnings.push(...contentValidation.warnings); // Step 3: Validate code blocks const codeBlockValidation = validateCodeBlocks(parsed.content); codeBlocksValid = codeBlockValidation.valid; errors.push(...codeBlockValidation.errors); warnings.push(...codeBlockValidation.warnings); // Step 4: Validate links (if basePath provided) if (basePath) { const linkValidation = validateInternalLinks(parsed.content, basePath); linksValid = linkValidation.valid; errors.push(...linkValidation.errors); warnings.push(...linkValidation.warnings); } else { linksValid = true; // Skip link validation if no basePath } return { valid: frontmatterValid && contentValid && codeBlocksValid && linksValid, errors, warnings, frontmatterValid, contentValid, codeBlocksValid, linksValid }; } catch (error) { if (error instanceof Error) { throw new SkillMarkdownError('Failed to validate skill markdown', { originalError: error.message }); } throw error; } } /** * Validate content structure and section ordering * * @param content - Markdown content (without frontmatter) * @returns Content validation result */ export function validateContentStructure(content) { const errors = []; const warnings = []; const sections = {}; const missingRequiredSections = []; const sectionOrderErrors = []; const optionalSections = []; // Extract sections using regex const sectionRegex = /^##\s+(.+)$/gm; const foundSections = []; let match; while((match = sectionRegex.exec(content)) !== null){ const sectionName = match[1].trim(); const sectionIndex = match.index; // Find content up to next section or end const nextMatch = sectionRegex.exec(content); const endIndex = nextMatch ? nextMatch.index : content.length; sectionRegex.lastIndex = nextMatch ? nextMatch.index : content.length; const sectionContent = content.substring(sectionIndex, endIndex); foundSections.push({ name: sectionName, index: sectionIndex, content: sectionContent }); } // Check required sections REQUIRED_SECTIONS.forEach((requiredSection)=>{ const found = foundSections.some((s)=>s.name === requiredSection); sections[requiredSection] = found; if (!found) { missingRequiredSections.push(requiredSection); errors.push(`Required section "${requiredSection}" is missing`); } }); // Check section order let lastRequiredIndex = -1; REQUIRED_SECTIONS.forEach((requiredSection)=>{ const sectionIndex = foundSections.findIndex((s)=>s.name === requiredSection); if (sectionIndex !== -1) { if (sectionIndex < lastRequiredIndex) { sectionOrderErrors.push(`Section "${requiredSection}" appears out of order (should be after previous required sections)`); errors.push(`Section "${requiredSection}" is out of order. Expected order: ${REQUIRED_SECTIONS.join(', ')}`); } lastRequiredIndex = sectionIndex; } }); // Identify optional sections foundSections.forEach((section)=>{ if (!REQUIRED_SECTIONS.includes(section.name) && OPTIONAL_SECTIONS.includes(section.name)) { optionalSections.push(section.name); } }); // Check minimum content length per section foundSections.forEach((section)=>{ const contentLength = section.content.replace(/^##.+$/m, '').trim().length; if (contentLength < MIN_SECTION_LENGTH) { warnings.push(`Section "${section.name}" has less than minimum content length (${contentLength} < ${MIN_SECTION_LENGTH} characters)`); } }); return { valid: errors.length === 0, errors, warnings, sections, missingRequiredSections, sectionOrderErrors, optionalSections }; } /** * Validate code blocks and syntax highlighting * * @param content - Markdown content * @returns Code block validation result */ export function validateCodeBlocks(content) { const errors = []; const warnings = []; const codeBlocks = []; // Extract code blocks using regex const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; let match; let lineNumber = 1; // Track line numbers const lines = content.split('\n'); while((match = codeBlockRegex.exec(content)) !== null){ const language = match[1] || ''; const blockContent = match[2]; // Calculate line number const matchIndex = match.index; const precedingContent = content.substring(0, matchIndex); lineNumber = precedingContent.split('\n').length; // Check language specification if (!language) { errors.push(`Code block at line ${lineNumber} missing language specification`); } else { codeBlocks.push({ language, content: blockContent, lineNumber }); // Check if language is supported if (!SUPPORTED_LANGUAGES.includes(language)) { warnings.push(`Code block at line ${lineNumber} uses unsupported language "${language}"`); } } // Check for empty code blocks if (blockContent.trim().length === 0) { warnings.push(`Code block at line ${lineNumber} is empty`); } } return { valid: errors.length === 0, errors, warnings, codeBlocks }; } /** * Validate internal links and anchors * * Security: All internal links are validated to prevent path traversal attacks. * Paths are normalized, verified to stay within basePath, and checked for * symlinks before file access. * * @param content - Markdown content * @param basePath - Base path for resolving relative links (must be within allowed directories) * @returns Link validation result * @throws SkillMarkdownError if basePath is invalid */ export function validateInternalLinks(content, basePath) { const errors = []; const warnings = []; const links = []; const brokenLinks = []; const externalLinks = []; // Validate basePath for security try { // Ensure basePath is safe by resolving it const normalizedBase = path.normalize(path.resolve(basePath)); basePath = normalizedBase; } catch (error) { throw new SkillMarkdownError('Invalid base path for link validation', { basePath, error: error instanceof Error ? error.message : String(error) }); } // Extract all markdown links const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; let match; while((match = linkRegex.exec(content)) !== null){ const text = match[1]; const href = match[2]; // Calculate line number const precedingContent = content.substring(0, match.index); const lineNumber = precedingContent.split('\n').length; // Determine link type let linkType; if (href.startsWith('http://') || href.startsWith('https://')) { linkType = 'external'; externalLinks.push(href); } else if (href.startsWith('#')) { linkType = 'anchor'; } else { linkType = 'internal'; } links.push({ text, href, type: linkType, lineNumber }); // Validate internal links with path traversal protection if (linkType === 'internal') { try { // Validate path to prevent directory traversal (SECURITY: CVSS 7.5) const validatedPath = getSafePath(href, basePath); if (!fs.existsSync(validatedPath)) { brokenLinks.push(href); errors.push(`Broken internal link at line ${lineNumber}: "${href}" (file does not exist)`); } } catch (error) { if (error instanceof PathValidationError) { // Path traversal or security violation detected brokenLinks.push(href); errors.push(`Invalid internal link at line ${lineNumber}: "${href}" (${error.context?.reason || 'path validation failed'})`); } else { // Other file system errors brokenLinks.push(href); errors.push(`Error validating internal link at line ${lineNumber}: "${href}" (${error instanceof Error ? error.message : String(error)})`); } } } // Validate anchor links if (linkType === 'anchor') { const anchorName = href.substring(1); // Remove '#' const sectionRegex = new RegExp(`^##\\s+${anchorName}$`, 'im'); // Also check slug format (lowercase, hyphens) const slugRegex = new RegExp(`^##\\s+${anchorName.split('-').map((w)=>w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}$`, 'im'); if (!sectionRegex.test(content) && !slugRegex.test(content)) { brokenLinks.push(href); errors.push(`Broken anchor link at line ${lineNumber}: "${href}" (section not found)`); } } } return { valid: errors.length === 0, errors, warnings, links, brokenLinks, externalLinks }; } /** * Extract sections from markdown content * * @param content - Markdown content * @returns Map of section names to content */ export function extractSections(content) { const sections = new Map(); const sectionRegex = /^##\s+(.+)$/gm; const matches = []; let match; while((match = sectionRegex.exec(content)) !== null){ matches.push({ name: match[1].trim(), index: match.index }); } // Extract content for each section matches.forEach((current, index)=>{ const nextSection = matches[index + 1]; const endIndex = nextSection ? nextSection.index : content.length; const sectionContent = content.substring(current.index, endIndex).trim(); sections.set(current.name, sectionContent); }); return sections; } /** * Get validation summary for display * * @param result - Validation result * @returns Human-readable summary */ export function getValidationSummary(result) { const parts = []; if (result.valid) { parts.push('✓ All validations passed'); } else { parts.push(`✗ Validation failed (${result.errors.length} errors)`); } if (!result.frontmatterValid) { parts.push(' - Frontmatter validation failed'); } if (!result.contentValid) { parts.push(' - Content structure validation failed'); } if (!result.codeBlocksValid) { parts.push(' - Code block validation failed'); } if (!result.linksValid) { parts.push(' - Link validation failed'); } if (result.warnings.length > 0) { parts.push(` - ${result.warnings.length} warnings`); } return parts.join('\n'); } //# sourceMappingURL=skill-markdown-validator.js.map