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
JavaScript
/**
* 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