Initial commit
This commit is contained in:
284
src/analyzers/validator.js
Normal file
284
src/analyzers/validator.js
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Validation Utilities
|
||||
* Validates that converted skills/extensions meet platform requirements
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { PLATFORM_TYPES } from './detector.js';
|
||||
|
||||
export class Validator {
|
||||
constructor() {
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a skill/extension for a specific platform
|
||||
* @param {string} dirPath - Path to the directory to validate
|
||||
* @param {string} platform - Target platform (claude, gemini, or universal)
|
||||
* @returns {Promise<{valid: boolean, errors: array, warnings: array}>}
|
||||
*/
|
||||
async validate(dirPath, platform) {
|
||||
this.errors = [];
|
||||
this.warnings = [];
|
||||
|
||||
try {
|
||||
if (platform === PLATFORM_TYPES.CLAUDE || platform === PLATFORM_TYPES.UNIVERSAL) {
|
||||
await this._validateClaude(dirPath);
|
||||
}
|
||||
|
||||
if (platform === PLATFORM_TYPES.GEMINI || platform === PLATFORM_TYPES.UNIVERSAL) {
|
||||
await this._validateGemini(dirPath);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: this.errors.length === 0,
|
||||
errors: this.errors,
|
||||
warnings: this.warnings
|
||||
};
|
||||
} catch (error) {
|
||||
this.errors.push(`Validation failed: ${error.message}`);
|
||||
return {
|
||||
valid: false,
|
||||
errors: this.errors,
|
||||
warnings: this.warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Claude skill requirements
|
||||
*/
|
||||
async _validateClaude(dirPath) {
|
||||
// Check for SKILL.md
|
||||
const skillPath = path.join(dirPath, 'SKILL.md');
|
||||
if (!await this._fileExists(skillPath)) {
|
||||
this.errors.push('Missing required file: SKILL.md');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate SKILL.md frontmatter
|
||||
const content = await fs.readFile(skillPath, 'utf8');
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]+?)\n---/);
|
||||
|
||||
if (!frontmatterMatch) {
|
||||
this.errors.push('SKILL.md must have YAML frontmatter');
|
||||
return;
|
||||
}
|
||||
|
||||
const frontmatter = this._parseYAML(frontmatterMatch[1]);
|
||||
|
||||
// Check required frontmatter fields
|
||||
if (!frontmatter.name) {
|
||||
this.errors.push('SKILL.md frontmatter missing required field: name');
|
||||
} else {
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9-]+$/.test(frontmatter.name)) {
|
||||
this.errors.push('Skill name must be lowercase letters, numbers, and hyphens only');
|
||||
}
|
||||
if (frontmatter.name.length > 64) {
|
||||
this.errors.push('Skill name must be 64 characters or less');
|
||||
}
|
||||
}
|
||||
|
||||
if (!frontmatter.description) {
|
||||
this.errors.push('SKILL.md frontmatter missing required field: description');
|
||||
} else {
|
||||
if (frontmatter.description.length > 1024) {
|
||||
this.errors.push('Description must be 1024 characters or less');
|
||||
}
|
||||
if (frontmatter.description.length < 50) {
|
||||
this.warnings.push('Description should be descriptive (at least 50 characters recommended)');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for marketplace.json (optional but recommended)
|
||||
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
||||
if (!await this._fileExists(marketplacePath)) {
|
||||
this.warnings.push('Missing .claude-plugin/marketplace.json (recommended for MCP server integration)');
|
||||
} else {
|
||||
await this._validateMarketplaceJSON(marketplacePath);
|
||||
}
|
||||
|
||||
// Validate file paths use forward slashes
|
||||
if (content.includes('\\')) {
|
||||
this.warnings.push('Use forward slashes (/) for file paths, not backslashes (\\)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Gemini extension requirements
|
||||
*/
|
||||
async _validateGemini(dirPath) {
|
||||
// Check for gemini-extension.json
|
||||
const manifestPath = path.join(dirPath, 'gemini-extension.json');
|
||||
if (!await this._fileExists(manifestPath)) {
|
||||
this.errors.push('Missing required file: gemini-extension.json');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate manifest JSON
|
||||
const content = await fs.readFile(manifestPath, 'utf8');
|
||||
let manifest;
|
||||
|
||||
try {
|
||||
manifest = JSON.parse(content);
|
||||
} catch (error) {
|
||||
this.errors.push(`Invalid JSON in gemini-extension.json: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!manifest.name) {
|
||||
this.errors.push('gemini-extension.json missing required field: name');
|
||||
} else {
|
||||
// Validate name matches directory
|
||||
const dirName = path.basename(dirPath);
|
||||
if (manifest.name !== dirName) {
|
||||
this.warnings.push(`Extension name "${manifest.name}" should match directory name "${dirName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!manifest.version) {
|
||||
this.errors.push('gemini-extension.json missing required field: version');
|
||||
}
|
||||
|
||||
// Validate MCP servers configuration
|
||||
if (manifest.mcpServers) {
|
||||
for (const [serverName, config] of Object.entries(manifest.mcpServers)) {
|
||||
if (!config.command) {
|
||||
this.errors.push(`MCP server "${serverName}" missing required field: command`);
|
||||
}
|
||||
|
||||
if (config.args) {
|
||||
// Check for proper variable substitution
|
||||
const argsStr = JSON.stringify(config.args);
|
||||
if (argsStr.includes('mcp-server') && !argsStr.includes('${extensionPath}')) {
|
||||
this.warnings.push(`MCP server "${serverName}" should use \${extensionPath} variable for paths`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate settings if present
|
||||
if (manifest.settings) {
|
||||
if (!Array.isArray(manifest.settings)) {
|
||||
this.errors.push('settings must be an array');
|
||||
} else {
|
||||
manifest.settings.forEach((setting, index) => {
|
||||
if (!setting.name) {
|
||||
this.errors.push(`Setting at index ${index} missing required field: name`);
|
||||
}
|
||||
if (!setting.description) {
|
||||
this.warnings.push(`Setting "${setting.name}" should have a description`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for context file
|
||||
const contextFileName = manifest.contextFileName || 'GEMINI.md';
|
||||
const contextPath = path.join(dirPath, contextFileName);
|
||||
if (!await this._fileExists(contextPath)) {
|
||||
this.warnings.push(`Missing context file: ${contextFileName} (recommended for providing context to Gemini)`);
|
||||
}
|
||||
|
||||
// Validate excludeTools if present
|
||||
if (manifest.excludeTools) {
|
||||
if (!Array.isArray(manifest.excludeTools)) {
|
||||
this.errors.push('excludeTools must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate marketplace.json structure
|
||||
*/
|
||||
async _validateMarketplaceJSON(filePath) {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
let marketplace;
|
||||
|
||||
try {
|
||||
marketplace = JSON.parse(content);
|
||||
} catch (error) {
|
||||
this.errors.push(`Invalid JSON in marketplace.json: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (!marketplace.name) {
|
||||
this.errors.push('marketplace.json missing required field: name');
|
||||
}
|
||||
|
||||
if (!marketplace.metadata) {
|
||||
this.errors.push('marketplace.json missing required field: metadata');
|
||||
} else {
|
||||
if (!marketplace.metadata.description) {
|
||||
this.warnings.push('marketplace.json metadata should include description');
|
||||
}
|
||||
if (!marketplace.metadata.version) {
|
||||
this.warnings.push('marketplace.json metadata should include version');
|
||||
}
|
||||
}
|
||||
|
||||
if (!marketplace.plugins || !Array.isArray(marketplace.plugins)) {
|
||||
this.errors.push('marketplace.json missing required field: plugins (array)');
|
||||
} else {
|
||||
marketplace.plugins.forEach((plugin, index) => {
|
||||
if (!plugin.name) {
|
||||
this.errors.push(`Plugin at index ${index} missing required field: name`);
|
||||
}
|
||||
if (!plugin.description) {
|
||||
this.errors.push(`Plugin at index ${index} missing required field: description`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple YAML parser for validation
|
||||
*/
|
||||
_parseYAML(yaml) {
|
||||
const parsed = {};
|
||||
const lines = yaml.split('\n');
|
||||
let currentKey = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith('-')) {
|
||||
// Array item
|
||||
if (currentKey && Array.isArray(parsed[currentKey])) {
|
||||
parsed[currentKey].push(line.trim().substring(1).trim());
|
||||
}
|
||||
} else if (line.includes(':')) {
|
||||
// Key-value pair
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
currentKey = key.trim();
|
||||
|
||||
if (value === '') {
|
||||
// Array or multi-line value
|
||||
parsed[currentKey] = [];
|
||||
} else {
|
||||
parsed[currentKey] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file exists
|
||||
*/
|
||||
async _fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Validator;
|
||||
Reference in New Issue
Block a user