@neurolint/cli
Version:
NeuroLint CLI for React/Next.js modernization with advanced 6-layer orchestration and intelligent AST transformations
320 lines (280 loc) • 9.48 kB
text/typescript
import fs from "fs-extra";
import path from "path";
import chalk from "chalk";
export interface CLIValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Comprehensive CLI input validation utilities
* Ensures robustness and security of CLI operations
*/
export class CLIValidator {
/**
* Validate file paths for security and existence
*/
static async validateFilePaths(
files: string[],
): Promise<CLIValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
if (!Array.isArray(files)) {
errors.push("Files parameter must be an array");
return { valid: false, errors, warnings };
}
for (const file of files) {
// Check basic string validation
if (!file || typeof file !== "string" || file.trim().length === 0) {
errors.push("All file paths must be valid non-empty strings");
continue;
}
// Security checks for path traversal
const normalizedPath = path.normalize(file);
if (
normalizedPath.includes("..") ||
normalizedPath.startsWith("/etc") ||
normalizedPath.startsWith("/proc")
) {
errors.push(`Potentially unsafe file path: ${file}`);
continue;
}
// Check if path is too long (OS limits)
if (file.length > 4096) {
errors.push(`File path too long: ${file.substring(0, 50)}...`);
continue;
}
// Check for suspicious characters
if (/[\x00-\x1f\x7f-\x9f]/.test(file)) {
errors.push(`File path contains invalid characters: ${file}`);
continue;
}
// Warning for very deep paths
const depth = file.split(path.sep).length;
if (depth > 20) {
warnings.push(`Very deep file path (${depth} levels): ${file}`);
}
// Check if file exists and is accessible
try {
if (await fs.pathExists(file)) {
const stats = await fs.stat(file);
if (!stats.isFile()) {
warnings.push(`Path is not a file: ${file}`);
}
} else {
warnings.push(`File does not exist: ${file}`);
}
} catch (error) {
warnings.push(`Cannot access file: ${file}`);
}
}
return { valid: errors.length === 0, errors, warnings };
}
/**
* Validate layer numbers and dependencies
*/
static validateLayers(layers: string | number[]): CLIValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
let layerArray: number[];
try {
if (typeof layers === "string") {
if (layers.trim().length === 0) {
errors.push("Layer string cannot be empty");
return { valid: false, errors, warnings };
}
layerArray = layers.split(",").map((l) => {
const num = parseInt(l.trim(), 10);
if (isNaN(num)) {
throw new Error(`Invalid layer number: ${l.trim()}`);
}
return num;
});
} else if (Array.isArray(layers)) {
layerArray = layers;
} else {
errors.push("Layers must be a string or array of numbers");
return { valid: false, errors, warnings };
}
// Check for valid layer numbers
const invalidLayers = layerArray.filter(
(layer) => !Number.isInteger(layer) || layer < 1 || layer > 6,
);
if (invalidLayers.length > 0) {
errors.push(
`Invalid layer numbers: ${invalidLayers.join(", ")}. Must be integers between 1-6.`,
);
}
// Check for duplicates
const uniqueLayers = [...new Set(layerArray)];
if (uniqueLayers.length !== layerArray.length) {
errors.push("Duplicate layer numbers are not allowed");
}
// Check layer dependencies
const dependencies = {
1: [],
2: [1],
3: [1, 2],
4: [1, 2, 3],
5: [1, 2, 3, 4],
6: [1, 2, 3, 4, 5],
};
for (const layer of uniqueLayers) {
const required = dependencies[layer as keyof typeof dependencies] || [];
const missing = required.filter((dep) => !uniqueLayers.includes(dep));
if (missing.length > 0) {
warnings.push(
`Layer ${layer} requires layers ${missing.join(", ")}. Consider adding them.`,
);
}
}
return { valid: errors.length === 0, errors, warnings };
} catch (error) {
errors.push(
`Layer validation error: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return { valid: false, errors, warnings };
}
}
/**
* Validate CLI options object
*/
static validateOptions(options: any): CLIValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (options === null) {
errors.push("Options cannot be null");
return { valid: false, errors, warnings };
}
if (options !== undefined && typeof options !== "object") {
errors.push("Options must be an object");
return { valid: false, errors, warnings };
}
if (options) {
// Validate output format
if (
options.output &&
!["table", "json", "summary"].includes(options.output)
) {
errors.push(
`Invalid output format: ${options.output}. Must be table, json, or summary.`,
);
}
// Validate boolean options
const booleanOptions = ["recursive", "dryRun", "backup", "verbose"];
for (const opt of booleanOptions) {
if (options[opt] !== undefined && typeof options[opt] !== "boolean") {
errors.push(`Option ${opt} must be a boolean`);
}
}
// Validate config path if provided
if (options.config) {
if (typeof options.config !== "string") {
errors.push("Config path must be a string");
} else if (options.config.length > 4096) {
errors.push("Config path too long");
}
}
}
return { valid: errors.length === 0, errors, warnings };
}
/**
* Validate API configuration
*/
static validateApiConfig(config: any): CLIValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
if (!config) {
errors.push("Configuration is required");
return { valid: false, errors, warnings };
}
// Validate API URL
if (!config.api?.url) {
errors.push("API URL is required");
} else {
try {
const url = new URL(config.api.url);
if (!["http:", "https:"].includes(url.protocol)) {
errors.push("API URL must use http or https protocol");
}
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
warnings.push("Using localhost API - ensure the server is running");
}
} catch {
errors.push("Invalid API URL format");
}
}
// Validate API key
if (!config.apiKey) {
warnings.push(
"No API key configured - authentication required for most operations",
);
} else if (typeof config.apiKey !== "string") {
errors.push("API key must be a string");
} else if (config.apiKey.length < 10) {
errors.push("API key appears to be too short");
}
// Validate timeout
if (config.api?.timeout) {
if (typeof config.api.timeout !== "number" || config.api.timeout <= 0) {
errors.push("API timeout must be a positive number");
} else if (config.api.timeout < 5000) {
warnings.push("API timeout is very low (< 5 seconds)");
} else if (config.api.timeout > 300000) {
warnings.push("API timeout is very high (> 5 minutes)");
}
}
return { valid: errors.length === 0, errors, warnings };
}
/**
* Comprehensive validation runner
*/
static async runFullValidation(
files: string[],
options: any,
config: any,
): Promise<CLIValidationResult> {
const allErrors: string[] = [];
const allWarnings: string[] = [];
// Validate files
const fileValidation = await this.validateFilePaths(files);
allErrors.push(...fileValidation.errors);
allWarnings.push(...fileValidation.warnings);
// Validate options
const optionsValidation = this.validateOptions(options);
allErrors.push(...optionsValidation.errors);
allWarnings.push(...optionsValidation.warnings);
// Validate layers if specified
if (options?.layers) {
const layersValidation = this.validateLayers(options.layers);
allErrors.push(...layersValidation.errors);
allWarnings.push(...layersValidation.warnings);
}
// Validate config
const configValidation = this.validateApiConfig(config);
allErrors.push(...configValidation.errors);
allWarnings.push(...configValidation.warnings);
return {
valid: allErrors.length === 0,
errors: allErrors,
warnings: allWarnings,
};
}
/**
* Display validation results to user
*/
static displayValidationResults(validation: CLIValidationResult): void {
if (validation.errors.length > 0) {
console.log(chalk.red("\nValidation Errors:"));
validation.errors.forEach((error) => {
console.log(chalk.red(` ERROR: ${error}`));
});
}
if (validation.warnings.length > 0) {
console.log(chalk.yellow("\nValidation Warnings:"));
validation.warnings.forEach((warning) => {
console.log(chalk.yellow(` WARNING: ${warning}`));
});
}
}
}