UNPKG

recoder-code

Version:

🚀 AI-powered development platform - Chat with 32+ models, build projects, automate workflows. Free models included!

785 lines (672 loc) • 23.9 kB
/** * ValidationService * Handles package validation, integrity checks, and quality analysis */ import { Config } from '../config'; import * as tar from 'tar'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as crypto from 'crypto'; import * as semver from 'semver'; export interface ValidationResult { valid: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; quality_score: number; size_analysis: SizeAnalysis; dependency_analysis: DependencyAnalysis; metadata_analysis: MetadataAnalysis; } export interface ValidationError { code: string; message: string; field?: string; severity: 'error' | 'critical'; } export interface ValidationWarning { code: string; message: string; field?: string; suggestion?: string; } export interface SizeAnalysis { total_size: number; unpacked_size: number; file_count: number; large_files: Array<{ path: string; size: number }>; excluded_files: string[]; size_score: number; } export interface DependencyAnalysis { dependency_count: number; dev_dependency_count: number; peer_dependency_count: number; outdated_dependencies: Array<{ name: string; current: string; latest: string }>; circular_dependencies: string[][]; dependency_score: number; } export interface MetadataAnalysis { has_readme: boolean; has_license: boolean; has_changelog: boolean; has_tests: boolean; has_types: boolean; description_quality: number; keyword_relevance: number; metadata_score: number; } export interface PackageData { packageJson: any; files: Map<string, Buffer>; size: number; integrity: string; } export class ValidationService { private readonly maxPackageSize = 50 * 1024 * 1024; // 50MB private readonly maxFileCount = 10000; private readonly maxFileSize = 10 * 1024 * 1024; // 10MB private readonly logger = { log: (message: string) => console.log(`[ValidationService] ${message}`), warn: (message: string, error?: any) => console.warn(`[ValidationService] ${message}`, error), error: (message: string, error?: any) => console.error(`[ValidationService] ${message}`, error) }; constructor(private config: Config) {} async validatePackage( packageBuffer: Buffer, expectedName?: string, expectedVersion?: string ): Promise<ValidationResult> { console.log(`Starting package validation (size: ${packageBuffer.length} bytes)`); const result: ValidationResult = { valid: true, errors: [], warnings: [], quality_score: 0, size_analysis: { total_size: 0, unpacked_size: 0, file_count: 0, large_files: [], excluded_files: [], size_score: 0 } as SizeAnalysis, dependency_analysis: { dependency_count: 0, dev_dependency_count: 0, peer_dependency_count: 0, outdated_dependencies: [], circular_dependencies: [], dependency_score: 0 } as DependencyAnalysis, metadata_analysis: { has_readme: false, has_license: false, has_changelog: false, has_tests: false, has_types: false, description_quality: 0, keyword_relevance: 0, metadata_score: 0 } as MetadataAnalysis }; try { // Step 1: Basic package structure validation const packageData = await this.extractAndParsePackage(packageBuffer); // Step 2: Package.json validation this.validatePackageJson(packageData.packageJson, result, expectedName, expectedVersion); // Step 3: Size analysis result.size_analysis = await this.analyzeSizes(packageData); // Step 4: Dependency analysis result.dependency_analysis = await this.analyzeDependencies(packageData.packageJson); // Step 5: Metadata analysis result.metadata_analysis = await this.analyzeMetadata(packageData); // Step 6: File structure validation await this.validateFileStructure(packageData, result); // Step 7: Security validation await this.validateSecurity(packageData, result); // Step 8: Calculate quality score result.quality_score = this.calculateQualityScore(result); // Determine if package is valid result.valid = result.errors.length === 0; this.logger.log(`Package validation completed: ${result.valid ? 'VALID' : 'INVALID'} (score: ${result.quality_score})`); return result; } catch (error) { if (error instanceof Error) { console.error(`Package validation failed: ${error.message}`, error.stack); result.errors.push({ code: 'VALIDATION_FAILED', message: `Validation failed: ${error.message}`, severity: 'critical' }); } else { console.error(`Package validation failed: ${String(error)}`); result.errors.push({ code: 'VALIDATION_FAILED', message: `Validation failed: ${String(error)}`, severity: 'critical' }); } result.valid = false; return result; } } async validateTarball(tarballBuffer: Buffer): Promise<ValidationResult> { // Simple tarball validation - just return valid for now return { valid: true, errors: [], warnings: [], quality_score: 100, size_analysis: { total_size: tarballBuffer.length, unpacked_size: 0, file_count: 0, large_files: [], excluded_files: [], size_score: 100 } as SizeAnalysis, dependency_analysis: { dependency_count: 0, dev_dependency_count: 0, peer_dependency_count: 0, outdated_dependencies: [], circular_dependencies: [], dependency_score: 100 } as DependencyAnalysis, metadata_analysis: { has_readme: false, has_license: false, has_changelog: false, has_tests: false, has_types: false, description_quality: 0, keyword_relevance: 0, metadata_score: 100 } as MetadataAnalysis }; } private async extractAndParsePackage(packageBuffer: Buffer): Promise<PackageData> { const files = new Map<string, Buffer>(); let packageJson: any = null; let totalSize = 0; // Calculate integrity const integrity = crypto.createHash('sha512').update(packageBuffer).digest('base64'); return new Promise((resolve, reject) => { const parser = new tar.Parse(); parser.on('entry', (entry) => { const chunks: Buffer[] = []; entry.on('data', (chunk: Buffer) => { chunks.push(chunk); totalSize += chunk.length; // Prevent zip bombs if (totalSize > this.maxPackageSize * 10) { reject(new Error('Package too large when extracted')); return; } }); entry.on('end', () => { const content = Buffer.concat(chunks); const relativePath = entry.path.replace(/^[^/]+\//, ''); // Remove top-level directory files.set(relativePath, content); // Parse package.json if (relativePath === 'package.json') { try { packageJson = JSON.parse(content.toString('utf8')); } catch (error) { if (error instanceof Error) { reject(new Error(`Invalid package.json: ${error.message}`)); } else { reject(new Error(`Invalid package.json: ${String(error)}`)); } return; } } }); }); parser.on('end', () => { if (!packageJson) { reject(new Error('Missing package.json')); return; } resolve({ packageJson, files, size: packageBuffer.length, integrity }); }); (parser as any).on('error', reject); parser.write(packageBuffer); parser.end(); }); } private validatePackageJson( packageJson: any, result: ValidationResult, expectedName?: string, expectedVersion?: string ): void { // Required fields const requiredFields = ['name', 'version']; for (const field of requiredFields) { if (!packageJson[field]) { result.errors.push({ code: 'MISSING_REQUIRED_FIELD', message: `Missing required field: ${field}`, field, severity: 'error' }); } } // Name validation if (packageJson.name) { if (expectedName && packageJson.name !== expectedName) { result.errors.push({ code: 'NAME_MISMATCH', message: `Package name mismatch: expected ${expectedName}, got ${packageJson.name}`, field: 'name', severity: 'error' }); } if (!this.isValidPackageName(packageJson.name)) { result.errors.push({ code: 'INVALID_NAME', message: 'Invalid package name format', field: 'name', severity: 'error' }); } } // Version validation if (packageJson.version) { if (expectedVersion && packageJson.version !== expectedVersion) { result.errors.push({ code: 'VERSION_MISMATCH', message: `Version mismatch: expected ${expectedVersion}, got ${packageJson.version}`, field: 'version', severity: 'error' }); } if (!semver.valid(packageJson.version)) { result.errors.push({ code: 'INVALID_VERSION', message: 'Invalid semver version', field: 'version', severity: 'error' }); } } // Description validation if (!packageJson.description) { result.warnings.push({ code: 'MISSING_DESCRIPTION', message: 'Package description is missing', field: 'description', suggestion: 'Add a clear description of what your package does' }); } else if (packageJson.description.length < 10) { result.warnings.push({ code: 'SHORT_DESCRIPTION', message: 'Package description is too short', field: 'description', suggestion: 'Provide a more detailed description' }); } // License validation if (!packageJson.license) { result.warnings.push({ code: 'MISSING_LICENSE', message: 'Package license is missing', field: 'license', suggestion: 'Add a valid SPDX license identifier' }); } // Keywords validation if (!packageJson.keywords || packageJson.keywords.length === 0) { result.warnings.push({ code: 'MISSING_KEYWORDS', message: 'Package keywords are missing', field: 'keywords', suggestion: 'Add relevant keywords to improve discoverability' }); } // Repository validation if (!packageJson.repository) { result.warnings.push({ code: 'MISSING_REPOSITORY', message: 'Repository information is missing', field: 'repository', suggestion: 'Add repository URL for better transparency' }); } // Main/entry point validation if (packageJson.main && !packageJson.main.endsWith('.js')) { result.warnings.push({ code: 'UNUSUAL_MAIN_ENTRY', message: 'Main entry point is not a .js file', field: 'main', suggestion: 'Ensure main entry point is correct' }); } // Scripts validation if (!packageJson.scripts) { result.warnings.push({ code: 'MISSING_SCRIPTS', message: 'No npm scripts defined', field: 'scripts', suggestion: 'Add test and build scripts' }); } else { if (!packageJson.scripts.test) { result.warnings.push({ code: 'MISSING_TEST_SCRIPT', message: 'No test script defined', field: 'scripts.test', suggestion: 'Add a test script' }); } } // Dependencies validation this.validateDependencies(packageJson, result); } private validateDependencies(packageJson: any, result: ValidationResult): void { const allDeps = { ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies, ...packageJson.optionalDependencies }; for (const [depName, depVersion] of Object.entries(allDeps)) { if (!semver.validRange(depVersion as string)) { result.warnings.push({ code: 'INVALID_DEPENDENCY_VERSION', message: `Invalid version range for dependency ${depName}: ${depVersion}`, field: 'dependencies', suggestion: 'Use valid semver ranges' }); } // Check for potentially dangerous dependencies if (this.isDangerousDependency(depName)) { result.warnings.push({ code: 'DANGEROUS_DEPENDENCY', message: `Potentially dangerous dependency: ${depName}`, field: 'dependencies', suggestion: 'Review if this dependency is necessary' }); } } } private async analyzeSizes(packageData: PackageData): Promise<SizeAnalysis> { const largeFiles: Array<{ path: string; size: number }> = []; let unpackedSize = 0; const excludedFiles: string[] = []; for (const [filePath, content] of packageData.files) { unpackedSize += content.length; if (content.length > this.maxFileSize) { largeFiles.push({ path: filePath, size: content.length }); } // Check for unnecessary files if (this.isUnnecessaryFile(filePath)) { excludedFiles.push(filePath); } } const sizeScore = this.calculateSizeScore(packageData.size, unpackedSize, packageData.files.size); return { total_size: packageData.size, unpacked_size: unpackedSize, file_count: packageData.files.size, large_files: largeFiles, excluded_files: excludedFiles, size_score: sizeScore }; } private async analyzeDependencies(packageJson: any): Promise<DependencyAnalysis> { const dependencyCount = Object.keys(packageJson.dependencies || {}).length; const devDependencyCount = Object.keys(packageJson.devDependencies || {}).length; const peerDependencyCount = Object.keys(packageJson.peerDependencies || {}).length; // In a real implementation, these would make API calls to check for updates const outdatedDependencies: Array<{ name: string; current: string; latest: string }> = []; const circularDependencies: string[][] = []; const dependencyScore = this.calculateDependencyScore( dependencyCount, outdatedDependencies.length, circularDependencies.length ); return { dependency_count: dependencyCount, dev_dependency_count: devDependencyCount, peer_dependency_count: peerDependencyCount, outdated_dependencies: outdatedDependencies, circular_dependencies: circularDependencies, dependency_score: dependencyScore }; } private async analyzeMetadata(packageData: PackageData): Promise<MetadataAnalysis> { const hasReadme = packageData.files.has('README.md') || packageData.files.has('readme.md') || packageData.files.has('README.txt'); const hasLicense = packageData.files.has('LICENSE') || packageData.files.has('LICENSE.md') || packageData.files.has('LICENSE.txt') || !!packageData.packageJson.license; const hasChangelog = packageData.files.has('CHANGELOG.md') || packageData.files.has('CHANGELOG.txt') || packageData.files.has('HISTORY.md'); const hasTests = Array.from(packageData.files.keys()).some(path => path.includes('test') || path.includes('spec') || path.includes('__tests__') ); const hasTypes = packageData.files.has('index.d.ts') || !!packageData.packageJson.types || !!packageData.packageJson.typings; const descriptionQuality = this.calculateDescriptionQuality(packageData.packageJson.description); const keywordRelevance = this.calculateKeywordRelevance(packageData.packageJson.keywords); const metadataScore = this.calculateMetadataScore({ hasReadme, hasLicense, hasChangelog, hasTests, hasTypes, descriptionQuality, keywordRelevance }); return { has_readme: hasReadme, has_license: hasLicense, has_changelog: hasChangelog, has_tests: hasTests, has_types: hasTypes, description_quality: descriptionQuality, keyword_relevance: keywordRelevance, metadata_score: metadataScore }; } private async validateFileStructure(packageData: PackageData, result: ValidationResult): Promise<void> { // Check for required files const mainFile = packageData.packageJson.main || 'index.js'; if (mainFile && !packageData.files.has(mainFile)) { result.errors.push({ code: 'MISSING_MAIN_FILE', message: `Main file not found: ${mainFile}`, field: 'main', severity: 'error' }); } // Check file count if (packageData.files.size > this.maxFileCount) { result.errors.push({ code: 'TOO_MANY_FILES', message: `Package contains too many files (${packageData.files.size} > ${this.maxFileCount})`, severity: 'error' }); } // Check for suspicious files for (const filePath of packageData.files.keys()) { if (this.isSuspiciousFile(filePath)) { result.warnings.push({ code: 'SUSPICIOUS_FILE', message: `Suspicious file detected: ${filePath}`, suggestion: 'Review if this file should be included' }); } } } private async validateSecurity(packageData: PackageData, result: ValidationResult): Promise<void> { // Check for common security issues for (const [filePath, content] of packageData.files) { if (filePath.endsWith('.js') || filePath.endsWith('.ts')) { const contentStr = content.toString('utf8'); // Check for eval usage if (contentStr.includes('eval(')) { result.warnings.push({ code: 'UNSAFE_EVAL', message: `Unsafe eval() usage detected in ${filePath}`, suggestion: 'Avoid using eval() for security reasons' }); } // Check for subprocess execution if (contentStr.includes('child_process') || contentStr.includes('exec(')) { result.warnings.push({ code: 'SUBPROCESS_EXECUTION', message: `Subprocess execution detected in ${filePath}`, suggestion: 'Review subprocess usage for security implications' }); } } } } private isValidPackageName(name: string): boolean { // NPM package name rules const nameRegex = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; return nameRegex.test(name) && name.length <= 214 && !name.startsWith('.') && !name.startsWith('_') && !name.includes(' '); } private isDangerousDependency(name: string): boolean { const dangerousDeps = [ 'eval', 'vm2', 'node-serialize', 'serialize-javascript' ]; return dangerousDeps.includes(name); } private isUnnecessaryFile(filePath: string): boolean { const unnecessaryPatterns = [ /\.DS_Store$/, /Thumbs\.db$/, /\.git\//, /\.svn\//, /\.hg\//, /\.vscode\//, /\.idea\//, /node_modules\//, /coverage\//, /\.nyc_output\//, /\.cache\//, /\.tmp\//, /\.temp\// ]; return unnecessaryPatterns.some(pattern => pattern.test(filePath)); } private isSuspiciousFile(filePath: string): boolean { const suspiciousPatterns = [ /\.exe$/, /\.bat$/, /\.cmd$/, /\.sh$/, /\.ps1$/, /\.dll$/, /\.so$/, /\.dylib$/ ]; return suspiciousPatterns.some(pattern => pattern.test(filePath)); } private calculateSizeScore(totalSize: number, unpackedSize: number, fileCount: number): number { let score = 100; // Penalize large packages if (totalSize > 10 * 1024 * 1024) score -= 30; // 10MB else if (totalSize > 5 * 1024 * 1024) score -= 20; // 5MB else if (totalSize > 1 * 1024 * 1024) score -= 10; // 1MB // Penalize excessive file count if (fileCount > 1000) score -= 20; else if (fileCount > 500) score -= 10; // Penalize large unpacked size const compressionRatio = totalSize / unpackedSize; if (compressionRatio < 0.1) score -= 10; // Poor compression return Math.max(0, score); } private calculateDependencyScore(depCount: number, outdatedCount: number, circularCount: number): number { let score = 100; // Penalize too many dependencies if (depCount > 50) score -= 30; else if (depCount > 20) score -= 15; else if (depCount > 10) score -= 5; // Penalize outdated dependencies score -= outdatedCount * 5; // Penalize circular dependencies score -= circularCount * 10; return Math.max(0, score); } private calculateDescriptionQuality(description?: string): number { if (!description) return 0; let score = 0; // Length scoring if (description.length > 20) score += 20; if (description.length > 50) score += 20; if (description.length > 100) score += 10; // Content quality if (/[.!?]$/.test(description)) score += 10; // Proper punctuation if (description.split(' ').length > 5) score += 20; // Adequate detail if (/\b(build|create|help|manage|parse|render|transform)\b/i.test(description)) score += 20; // Action words return Math.min(100, score); } private calculateKeywordRelevance(keywords?: string[]): number { if (!keywords || keywords.length === 0) return 0; let score = 0; // Quantity scoring if (keywords.length >= 3) score += 30; if (keywords.length >= 5) score += 20; // Quality scoring const hasFrameworkKeywords = keywords.some(k => ['react', 'vue', 'angular', 'node', 'express', 'typescript'].includes(k.toLowerCase()) ); if (hasFrameworkKeywords) score += 25; const hasTypeKeywords = keywords.some(k => ['cli', 'api', 'library', 'framework', 'plugin', 'tool'].includes(k.toLowerCase()) ); if (hasTypeKeywords) score += 25; return Math.min(100, score); } private calculateMetadataScore(metadata: any): number { let score = 0; if (metadata.hasReadme) score += 20; if (metadata.hasLicense) score += 15; if (metadata.hasChangelog) score += 10; if (metadata.hasTests) score += 20; if (metadata.hasTypes) score += 15; score += metadata.descriptionQuality * 0.1; score += metadata.keywordRelevance * 0.1; return Math.min(100, score); } private calculateQualityScore(result: ValidationResult): number { if (result.errors.length > 0) return 0; const weights = { size: 0.2, dependency: 0.3, metadata: 0.3, warnings: 0.2 }; const sizeScore = result.size_analysis?.size_score || 0; const dependencyScore = result.dependency_analysis?.dependency_score || 0; const metadataScore = result.metadata_analysis?.metadata_score || 0; const warningPenalty = Math.max(0, 100 - (result.warnings.length * 5)); const totalScore = sizeScore * weights.size + dependencyScore * weights.dependency + metadataScore * weights.metadata + warningPenalty * weights.warnings; return Math.round(totalScore); } }