claudekit
Version:
CLI tools for Claude Code development workflow
1,108 lines (991 loc) • 31.5 kB
text/typescript
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { z } from 'zod';
import { pathExists, normalizePath } from './filesystem.js';
import type { ComponentCategory, ComponentType } from '../types/config.js';
/**
* Comprehensive Validation Module
*
* Provides security checks, prerequisite validation, input sanitization,
* and clear error reporting for ClaudeKit CLI operations.
*/
// ============================================================================
// Types and Interfaces
// ============================================================================
export interface ValidationError {
field: string;
message: string;
severity: 'error' | 'warning' | 'info';
suggestions?: string[];
code?: string;
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
sanitized?: unknown;
}
export interface PrerequisiteCheck {
name: string;
description: string;
required: boolean;
check: () => Promise<boolean>;
installHint?: string;
}
export interface ProjectValidationOptions {
allowSystemPaths?: boolean;
maxDepth?: number;
requireGitRepository?: boolean;
requireNodeProject?: boolean;
}
export interface ComponentValidationOptions {
maxComponents?: number;
allowedCategories?: ComponentCategory[];
allowedTypes?: ComponentType[];
requireDescriptions?: boolean;
}
// ============================================================================
// Path Validation with Enhanced Security
// ============================================================================
/**
* Enhanced project path validation with comprehensive security checks
*/
export function validateProjectPathSecure(
input: string,
options: ProjectValidationOptions = {}
): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
// Basic input validation
if (!input || typeof input !== 'string') {
errors.push({
field: 'path',
message: 'Path must be a non-empty string',
severity: 'error',
code: 'INVALID_INPUT',
});
return { isValid: false, errors, warnings };
}
// Normalize and expand the path
let normalizedPath: string;
try {
normalizedPath = normalizePath(input);
} catch (error) {
errors.push({
field: 'path',
message: `Failed to normalize path: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: 'error',
code: 'PATH_NORMALIZATION_ERROR',
});
return { isValid: false, errors, warnings };
}
// Check for directory traversal attempts
if (input.includes('..') || normalizedPath.includes('..')) {
errors.push({
field: 'path',
message: 'Directory traversal detected in path',
severity: 'error',
code: 'DIRECTORY_TRAVERSAL',
suggestions: ['Use absolute paths or paths relative to current directory without ".."'],
});
}
// Path length validation
if (normalizedPath.length > 1000) {
errors.push({
field: 'path',
message: 'Path exceeds maximum length of 1000 characters',
severity: 'error',
code: 'PATH_TOO_LONG',
});
}
// Check for control characters
const hasControlChars = ((): boolean => {
for (let i = 0; i < normalizedPath.length; i++) {
const charCode = normalizedPath.charCodeAt(i);
if (charCode <= 31 || charCode === 127) {
return true;
}
}
return false;
})();
if (hasControlChars) {
errors.push({
field: 'path',
message: 'Path contains invalid control characters',
severity: 'error',
code: 'INVALID_CHARACTERS',
});
}
// System path protection (unless explicitly allowed)
if (options.allowSystemPaths !== true) {
const systemPaths = ['/', '/usr', '/bin', '/sbin', '/etc', '/boot', '/dev', '/proc', '/sys'];
// Allow temporary directories which may be under /var or /tmp
const tempDir = os.tmpdir();
const isInTempDir = normalizedPath.startsWith(`${tempDir}/`) || normalizedPath === tempDir;
if (
!isInTempDir &&
systemPaths.some(
(sysPath) => normalizedPath === sysPath || normalizedPath.startsWith(`${sysPath}/`)
)
) {
errors.push({
field: 'path',
message: 'Access to system directories is not allowed',
severity: 'error',
code: 'SYSTEM_PATH_FORBIDDEN',
suggestions: ['Use a directory in your home folder or project workspace'],
});
}
}
// Critical user directory protection
const homeDir = os.homedir();
const criticalUserPaths = [
homeDir,
path.join(homeDir, 'Library'),
path.join(homeDir, '.ssh'),
path.join(homeDir, '.gnupg'),
path.join(homeDir, 'Desktop'),
path.join(homeDir, 'Documents'),
path.join(homeDir, 'Downloads'),
];
if (criticalUserPaths.includes(normalizedPath)) {
errors.push({
field: 'path',
message: 'Direct access to critical user directories is not allowed',
severity: 'error',
code: 'CRITICAL_DIRECTORY_FORBIDDEN',
suggestions: ['Create a subdirectory or use a dedicated project folder'],
});
}
// Hidden directory warning (except for .claude and common dev directories)
const basename = path.basename(normalizedPath);
if (
basename.startsWith('.') &&
!['..', '.', '.claude', '.git', '.vscode', '.idea'].includes(basename)
) {
warnings.push({
field: 'path',
message: 'Working with hidden directories may cause unexpected behavior',
severity: 'warning',
code: 'HIDDEN_DIRECTORY',
});
}
// Maximum nesting depth check
const maxDepth = options.maxDepth ?? 10;
const pathSegments = normalizedPath.split(path.sep).filter((segment) => segment.length > 0);
if (pathSegments.length > maxDepth) {
warnings.push({
field: 'path',
message: `Path depth (${pathSegments.length}) exceeds recommended maximum (${maxDepth})`,
severity: 'warning',
code: 'EXCESSIVE_NESTING',
});
}
return {
isValid: errors.length === 0,
errors,
warnings,
sanitized: normalizedPath,
};
}
/**
* Validates that a path exists and is accessible for the intended operation
*/
export async function validatePathAccessibility(
targetPath: string,
operation: 'read' | 'write' | 'execute' = 'read'
): Promise<ValidationResult> {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
try {
const stats = await fs.stat(targetPath);
// Check if path is a directory when expected to be a file (and vice versa)
if (targetPath.endsWith('/') && !stats.isDirectory()) {
errors.push({
field: 'path',
message: 'Path appears to reference a directory but points to a file',
severity: 'error',
code: 'TYPE_MISMATCH',
});
}
// Check permissions based on operation
try {
if (operation === 'read') {
await fs.access(targetPath, fs.constants.R_OK);
} else if (operation === 'write') {
await fs.access(targetPath, fs.constants.W_OK);
} else if (operation === 'execute') {
await fs.access(targetPath, fs.constants.X_OK);
}
} catch {
errors.push({
field: 'path',
message: `Insufficient ${operation} permissions for path`,
severity: 'error',
code: 'PERMISSION_DENIED',
suggestions: [`Check file permissions and ownership of ${targetPath}`],
});
}
// Warn about unusual file sizes for configuration files
if (stats.isFile() && targetPath.endsWith('.json') && stats.size > 1024 * 1024) {
warnings.push({
field: 'path',
message: 'Configuration file is unusually large (>1MB)',
severity: 'warning',
code: 'LARGE_CONFIG_FILE',
});
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// For write operations, check if parent directory exists and is writable
if (operation === 'write') {
const parentDir = path.dirname(targetPath);
try {
await fs.access(parentDir, fs.constants.W_OK);
// Parent is writable, this is OK for write operations
} catch {
errors.push({
field: 'path',
message: 'Parent directory does not exist or is not writable',
severity: 'error',
code: 'PARENT_NOT_WRITABLE',
suggestions: ['Create the parent directory first', `mkdir -p ${parentDir}`],
});
}
} else {
errors.push({
field: 'path',
message: 'Path does not exist',
severity: 'error',
code: 'PATH_NOT_FOUND',
...(operation === 'read'
? { suggestions: ['Check if the path is correct', 'Ensure the file was created'] }
: {}),
});
}
} else {
errors.push({
field: 'path',
message: `Failed to access path: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
code: 'ACCESS_ERROR',
});
}
}
return { isValid: errors.length === 0, errors, warnings };
}
// ============================================================================
// Component Validation
// ============================================================================
/**
* Component name validation schema
*/
const ComponentNameSchema = z
.string()
.min(1, 'Component name cannot be empty')
.max(100, 'Component name cannot exceed 100 characters')
.regex(
/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/,
'Component name must start and end with alphanumeric characters and contain only lowercase letters, numbers, and hyphens'
);
/**
* Validates component names with enhanced rules
*/
export function validateComponentName(name: string): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
try {
ComponentNameSchema.parse(name);
} catch (error) {
if (error instanceof z.ZodError) {
error.errors.forEach((err) => {
errors.push({
field: 'componentName',
message: err.message,
severity: 'error',
code: 'INVALID_COMPONENT_NAME',
suggestions: [
'Use lowercase letters, numbers, and hyphens only',
'Start and end with alphanumeric characters',
'Examples: "my-hook", "git-utils", "validator"',
],
});
});
}
}
// Reserved name check
const reservedNames = [
'init',
'validate',
'install',
'remove',
'list',
'update',
'add',
'system',
'config',
'settings',
'main',
'index',
'default',
'test',
'spec',
'example',
'sample',
'demo',
];
if (reservedNames.includes(name.toLowerCase())) {
errors.push({
field: 'componentName',
message: `"${name}" is a reserved name and cannot be used`,
severity: 'error',
code: 'RESERVED_NAME',
suggestions: [`Try "${name}-custom"`, `Try "my-${name}"`, `Try "${name}-hook"`],
});
}
// Naming convention warnings
if (name.includes('_')) {
warnings.push({
field: 'componentName',
message: 'Underscores in component names may cause compatibility issues',
severity: 'warning',
code: 'UNDERSCORE_IN_NAME',
suggestions: ['Use hyphens instead of underscores'],
});
}
if (name.length > 50) {
warnings.push({
field: 'componentName',
message: 'Component name is quite long and may be difficult to work with',
severity: 'warning',
code: 'LONG_NAME',
});
}
return { isValid: errors.length === 0, errors, warnings };
}
/**
* Sanitizes and validates a list of component identifiers
*/
export function sanitizeComponentList(
components: unknown,
options: ComponentValidationOptions = {}
): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
const sanitized: string[] = [];
// Basic type validation
let componentArray: unknown[];
if (!Array.isArray(components)) {
if (typeof components === 'string') {
// Try to split string by common delimiters
componentArray = components.split(/[,\s;]+/).filter((c) => c.length > 0);
} else {
errors.push({
field: 'components',
message: 'Components must be provided as an array or comma-separated string',
severity: 'error',
code: 'INVALID_TYPE',
});
return { isValid: false, errors, warnings };
}
} else {
componentArray = components;
}
// Validate each component
for (let i = 0; i < componentArray.length; i++) {
const component = componentArray[i];
if (typeof component !== 'string') {
warnings.push({
field: `components[${i}]`,
message: `Component at index ${i} is not a string and will be skipped`,
severity: 'warning',
code: 'NON_STRING_COMPONENT',
});
continue;
}
const trimmed = component.trim();
if (!trimmed) {
warnings.push({
field: `components[${i}]`,
message: `Empty component at index ${i} will be skipped`,
severity: 'warning',
code: 'EMPTY_COMPONENT',
});
continue;
}
// Validate component name
const nameValidation = validateComponentName(trimmed);
if (!nameValidation.isValid) {
nameValidation.errors.forEach((error) => {
errors.push({
...error,
field: `components[${i}]`,
});
});
continue;
}
// Check for duplicates
if (sanitized.includes(trimmed)) {
warnings.push({
field: `components[${i}]`,
message: `Duplicate component "${trimmed}" will be ignored`,
severity: 'warning',
code: 'DUPLICATE_COMPONENT',
});
continue;
}
sanitized.push(trimmed);
}
// Apply limits
const maxComponents = options.maxComponents ?? 50;
if (sanitized.length > maxComponents) {
warnings.push({
field: 'components',
message: `Component list truncated to ${maxComponents} items (had ${sanitized.length})`,
severity: 'warning',
code: 'LIST_TRUNCATED',
});
sanitized.splice(maxComponents);
}
return {
isValid: errors.length === 0,
errors,
warnings,
sanitized,
};
}
// ============================================================================
// Prerequisite Checking
// ============================================================================
/**
* Checks if Node.js is available and meets version requirements
*/
export async function checkNodePrerequisite(minVersion = '18.0.0'): Promise<PrerequisiteCheck> {
return {
name: 'Node.js',
description: `Node.js runtime (>= ${minVersion})`,
required: true,
installHint: 'Install Node.js from https://nodejs.org/',
check: async (): Promise<boolean> => {
try {
const nodeVersion = process.version;
const currentVersion = nodeVersion.slice(1); // Remove 'v' prefix
// Simple version comparison (works for semantic versions)
const currentParts = currentVersion.split('.').map(Number);
const requiredParts = minVersion.split('.').map(Number);
for (let i = 0; i < Math.max(currentParts.length, requiredParts.length); i++) {
const current = currentParts[i] ?? 0;
const required = requiredParts[i] ?? 0;
if (current > required) {
return true;
}
if (current < required) {
return false;
}
}
return true;
} catch {
return false;
}
},
};
}
/**
* Checks if TypeScript is available (globally or locally)
*/
export async function checkTypeScriptPrerequisite(): Promise<PrerequisiteCheck> {
return {
name: 'TypeScript',
description: 'TypeScript compiler (tsc)',
required: false,
installHint: 'npm install -g typescript',
check: async (): Promise<boolean> => {
try {
// Check for local TypeScript first
const localTsc = path.join(process.cwd(), 'node_modules', '.bin', 'tsc');
if (await pathExists(localTsc)) {
return true;
}
// Check for global TypeScript
const { exec } = await import('node:child_process');
return new Promise((resolve) => {
exec('tsc --version', (error) => {
resolve(!error);
});
});
} catch {
return false;
}
},
};
}
/**
* Checks if ESLint is available with proper configuration
*/
export async function checkESLintPrerequisite(): Promise<PrerequisiteCheck> {
return {
name: 'ESLint',
description: 'ESLint linter with valid configuration',
required: false,
installHint: 'npm install eslint',
check: async (): Promise<boolean> => {
try {
// Check for ESLint binary
const localESLint = path.join(process.cwd(), 'node_modules', '.bin', 'eslint');
if (!(await pathExists(localESLint))) {
// Check global ESLint
const { exec } = await import('node:child_process');
const hasGlobalESLint = await new Promise<boolean>((resolve) => {
exec('eslint --version', (error) => {
resolve(!error);
});
});
if (!hasGlobalESLint) {
return false;
}
}
// Check for ESLint configuration
const configFiles = [
'.eslintrc.js',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'eslint.config.js',
'eslint.config.mjs',
];
for (const configFile of configFiles) {
if (await pathExists(path.join(process.cwd(), configFile))) {
return true;
}
}
// Check package.json for eslintConfig
const packageJsonPath = path.join(process.cwd(), 'package.json');
if (await pathExists(packageJsonPath)) {
try {
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
return packageJson.eslintConfig !== undefined && packageJson.eslintConfig !== null;
} catch {
// Ignore JSON parsing errors
}
}
return false;
} catch {
return false;
}
},
};
}
/**
* Checks if Git is available and the current directory is a Git repository
*/
export async function checkGitPrerequisite(requireRepository = false): Promise<PrerequisiteCheck> {
return {
name: 'Git',
description: requireRepository
? 'Git with initialized repository'
: 'Git version control system',
required: false,
installHint: 'Install Git from https://git-scm.com/',
check: async (): Promise<boolean> => {
try {
const { exec } = await import('node:child_process');
// Check if git is available
const hasGit = await new Promise<boolean>((resolve) => {
exec('git --version', (error) => {
resolve(!error);
});
});
if (!hasGit) {
return false;
}
if (requireRepository) {
// Check if current directory is a Git repository
return new Promise((resolve) => {
exec('git rev-parse --git-dir', (error) => {
resolve(!error);
});
});
}
return true;
} catch {
return false;
}
},
};
}
/**
* Runs all relevant prerequisite checks for ClaudeKit operations
*/
export async function checkAllPrerequisites(
options: {
requireTypeScript?: boolean;
requireESLint?: boolean;
requireGitRepository?: boolean;
nodeMinVersion?: string;
} = {}
): Promise<ValidationResult> {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
const checks: PrerequisiteCheck[] = [
await checkNodePrerequisite(options.nodeMinVersion),
await checkTypeScriptPrerequisite(),
await checkESLintPrerequisite(),
await checkGitPrerequisite(options.requireGitRepository),
];
for (const check of checks) {
const isAvailable = await check.check();
if (!isAvailable) {
const error: ValidationError = {
field: 'prerequisites',
message: `${check.name}: ${check.description} is not available`,
severity: check.required ? 'error' : 'warning',
code: `MISSING_${check.name.toUpperCase().replace(/[^A-Z]/g, '_')}`,
...(check.installHint !== undefined && check.installHint !== ''
? { suggestions: [check.installHint] }
: {}),
};
if (check.required) {
errors.push(error);
} else {
warnings.push(error);
}
}
}
return { isValid: errors.length === 0, errors, warnings };
}
// ============================================================================
// Input Sanitization
// ============================================================================
/**
* Sanitizes user input for safe use in shell commands
*/
export function sanitizeShellInput(input: string): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
if (typeof input !== 'string') {
errors.push({
field: 'input',
message: 'Input must be a string',
severity: 'error',
code: 'INVALID_TYPE',
});
return { isValid: false, errors, warnings };
}
// Check for dangerous characters and patterns
const dangerousChars = /[;&|`$(){}[\]<>\\]/;
if (dangerousChars.test(input)) {
errors.push({
field: 'input',
message: 'Input contains potentially dangerous shell characters',
severity: 'error',
code: 'DANGEROUS_CHARACTERS',
suggestions: ['Remove or escape special characters: ; & | ` $ ( ) { } [ ] < > \\'],
});
}
// Check for dangerous command patterns
const dangerousPatterns = [
/\brm\s+(-[rf]+\s*)*\/+/i, // rm -rf / variants
/\bsudo\s+rm\b/i, // sudo rm
/\bmkfs\b/i, // mkfs (format filesystem)
/\bdd\s+if=/i, // dd if= (dangerous copy)
/\bchmod\s+777\b/i, // chmod 777
/\b(cat|grep|awk|sed)\s+.*\/etc\/passwd/i, // reading passwd
/\b(wget|curl).*http/i, // downloading from internet
];
for (const pattern of dangerousPatterns) {
if (pattern.test(input)) {
errors.push({
field: 'input',
message: 'Input contains potentially dangerous command patterns',
severity: 'error',
code: 'DANGEROUS_COMMAND',
suggestions: ['Avoid destructive commands or system access patterns'],
});
break;
}
}
// Check for null bytes
if (input.includes('\0')) {
errors.push({
field: 'input',
message: 'Input contains null bytes',
severity: 'error',
code: 'NULL_BYTES',
});
}
// Length check
if (input.length > 1000) {
warnings.push({
field: 'input',
message: 'Input is very long and may cause issues',
severity: 'warning',
code: 'LONG_INPUT',
});
}
// Sanitize the input
const sanitized = input
.replace(/./g, (char) => {
const charCode = char.charCodeAt(0);
return charCode <= 31 || charCode === 127 ? '' : char;
}) // Remove control characters
.trim();
return {
isValid: errors.length === 0,
errors,
warnings,
sanitized,
};
}
/**
* Validates and sanitizes configuration objects
*/
export function sanitizeConfigInput(config: unknown): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
if (config === null || config === undefined) {
errors.push({
field: 'config',
message: 'Configuration cannot be null or undefined',
severity: 'error',
code: 'NULL_CONFIG',
});
return { isValid: false, errors, warnings };
}
if (typeof config !== 'object' || Array.isArray(config)) {
errors.push({
field: 'config',
message: 'Configuration must be a plain object',
severity: 'error',
code: 'INVALID_CONFIG_TYPE',
});
return { isValid: false, errors, warnings };
}
// Check for functions and other non-serializable values before cloning
function hasNonSerializableValues(obj: unknown, visited = new Set()): boolean {
if (visited.has(obj)) {
return true;
} // Circular reference
if (obj === null || typeof obj !== 'object') {
return false;
}
if (typeof obj === 'function') {
return true;
}
visited.add(obj);
for (const value of Object.values(obj)) {
if (typeof value === 'function' || typeof value === 'symbol') {
return true;
}
if (typeof value === 'object' && value !== null) {
if (hasNonSerializableValues(value, visited)) {
return true;
}
}
}
visited.delete(obj);
return false;
}
if (hasNonSerializableValues(config)) {
errors.push({
field: 'config',
message: 'Configuration contains non-serializable values',
severity: 'error',
code: 'NON_SERIALIZABLE',
suggestions: ['Remove functions, symbols, or circular references from configuration'],
});
return { isValid: false, errors, warnings };
}
// Deep clone to avoid mutation
let sanitized: unknown;
try {
sanitized = JSON.parse(JSON.stringify(config));
} catch {
errors.push({
field: 'config',
message: 'Configuration contains non-serializable values',
severity: 'error',
code: 'NON_SERIALIZABLE',
suggestions: ['Remove functions, symbols, or circular references from configuration'],
});
return { isValid: false, errors, warnings };
}
// Check for overly nested configuration
function getMaxDepth(obj: unknown, currentDepth = 0): number {
if (typeof obj !== 'object' || obj === null) {
return currentDepth;
}
let maxDepth = currentDepth;
for (const value of Object.values(obj)) {
maxDepth = Math.max(maxDepth, getMaxDepth(value, currentDepth + 1));
}
return maxDepth;
}
const maxDepth = getMaxDepth(sanitized);
if (maxDepth > 10) {
warnings.push({
field: 'config',
message: `Configuration is deeply nested (depth: ${maxDepth}). This may impact performance.`,
severity: 'warning',
code: 'DEEP_NESTING',
});
}
return {
isValid: errors.length === 0,
errors,
warnings,
sanitized,
};
}
// ============================================================================
// Project Validation
// ============================================================================
/**
* Comprehensive project validation including structure and prerequisites
*/
export async function validateProject(
projectPath: string,
options: ProjectValidationOptions = {}
): Promise<ValidationResult> {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
// Path validation
const pathValidation = validateProjectPathSecure(projectPath, options);
errors.push(...pathValidation.errors);
warnings.push(...pathValidation.warnings);
if (!pathValidation.isValid) {
return { isValid: false, errors, warnings };
}
const sanitizedPath = pathValidation.sanitized as string;
// Check path accessibility
const accessValidation = await validatePathAccessibility(sanitizedPath, 'read');
errors.push(...accessValidation.errors);
warnings.push(...accessValidation.warnings);
// Check if it's a valid project directory
try {
const stats = await fs.stat(sanitizedPath);
if (!stats.isDirectory()) {
errors.push({
field: 'projectPath',
message: 'Project path must be a directory',
severity: 'error',
code: 'NOT_DIRECTORY',
});
return { isValid: false, errors, warnings };
}
} catch (error: unknown) {
errors.push({
field: 'projectPath',
message: `Cannot access project directory: ${error instanceof Error ? error.message : String(error)}`,
severity: 'error',
code: 'ACCESS_ERROR',
});
return { isValid: false, errors, warnings };
}
// Optional: Check for Node.js project
if (options.requireNodeProject === true) {
const packageJsonPath = path.join(sanitizedPath, 'package.json');
if (!(await pathExists(packageJsonPath))) {
errors.push({
field: 'projectPath',
message: 'Project directory must contain a package.json file',
severity: 'error',
code: 'NOT_NODE_PROJECT',
suggestions: ['Run "npm init" to create a package.json file'],
});
}
}
// Optional: Check for Git repository
if (options.requireGitRepository === true) {
const gitPath = path.join(sanitizedPath, '.git');
if (!(await pathExists(gitPath))) {
errors.push({
field: 'projectPath',
message: 'Project directory must be a Git repository',
severity: 'error',
code: 'NOT_GIT_REPOSITORY',
suggestions: ['Run "git init" to initialize a Git repository'],
});
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
sanitized: sanitizedPath,
};
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
* Formats validation results into a human-readable error message
*/
export function formatValidationErrors(result: ValidationResult): string {
const messages: string[] = [];
if (result.errors.length > 0) {
messages.push('Validation Errors:');
result.errors.forEach((error) => {
let message = ` ✗ ${error.field}: ${error.message}`;
if (error.code !== undefined && error.code !== '') {
message += ` [${error.code}]`;
}
messages.push(message);
if (error.suggestions) {
error.suggestions.forEach((suggestion) => {
messages.push(` → ${suggestion}`);
});
}
});
}
if (result.warnings.length > 0) {
if (messages.length > 0) {
messages.push('');
}
messages.push('Validation Warnings:');
result.warnings.forEach((warning) => {
let message = ` ⚠ ${warning.field}: ${warning.message}`;
if (warning.code !== undefined && warning.code !== '') {
message += ` [${warning.code}]`;
}
messages.push(message);
});
}
return messages.join('\n');
}
/**
* Creates a validation error with consistent formatting
*/
export function createValidationError(
field: string,
message: string,
options: {
severity?: 'error' | 'warning' | 'info';
code?: string;
suggestions?: string[];
} = {}
): ValidationError {
return {
field,
message,
severity: options.severity || 'error',
...(options.code !== undefined && options.code !== '' ? { code: options.code } : {}),
...(options.suggestions ? { suggestions: options.suggestions } : {}),
};
}
/**
* Combines multiple validation results
*/
export function combineValidationResults(...results: ValidationResult[]): ValidationResult {
const allErrors: ValidationError[] = [];
const allWarnings: ValidationError[] = [];
for (const result of results) {
allErrors.push(...result.errors);
allWarnings.push(...result.warnings);
}
return {
isValid: allErrors.length === 0,
errors: allErrors,
warnings: allWarnings,
};
}