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
text/typescript
/**
* 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);
}
}