UNPKG

rynex

Version:

A minimalist TypeScript framework for building reactive web applications with no virtual DOM

816 lines 27.6 kB
import * as ts from 'typescript'; import * as fs from 'fs'; import * as path from 'path'; const DEFAULT_CONFIG = { strict: true, checkOptionalProps: true, checkPerformance: true, maxComplexity: 10, enableCache: true }; const RYNEX_VALIDATION_RULES = { createElement: { minArgs: 1, maxArgs: 3, category: 'DOM', description: 'Create DOM element with props and children', rules: [ { argIndex: 0, type: 'string', notEmpty: true, message: 'Tag name cannot be empty', severity: 'error' }, { argIndex: 1, type: 'object', optionalProps: ['class', 'id', 'style', 'onClick', 'onChange', 'onHover', 'onInput', 'onFocus', 'onBlur'], message: 'Props should be a valid object', severity: 'warning' } ] }, image: { minArgs: 1, maxArgs: 1, category: 'Elements', description: 'Create image element with src and optional alt', rules: [ { argIndex: 0, type: 'object', requiredProps: ['src'], optionalProps: ['alt', 'lazy', 'loading', 'width', 'height'], message: 'image() requires src property', severity: 'error' } ] }, link: { minArgs: 1, maxArgs: 2, category: 'Elements', description: 'Create anchor/link element', rules: [ { argIndex: 0, type: 'object', requiredProps: ['href'], optionalProps: ['target', 'rel', 'title', 'class'], message: 'link() requires href property', severity: 'error' } ] }, list: { minArgs: 1, maxArgs: 1, category: 'Elements', description: 'Render list with items and renderItem function', rules: [ { argIndex: 0, type: 'object', requiredProps: ['items', 'renderItem'], optionalProps: ['keyExtractor'], message: 'list() requires items and renderItem properties', severity: 'error' } ] }, abbr: { minArgs: 1, maxArgs: 2, category: 'Typography', description: 'Create abbreviation element', rules: [ { argIndex: 0, type: 'object', requiredProps: ['title'], message: 'abbr() requires title property', severity: 'error' } ] }, vbox: { minArgs: 1, category: 'Layout', description: 'Vertical flex layout container', rules: [ { argIndex: 0, type: 'object', optionalProps: ['style', 'class', 'gap', 'onClick', 'onHover', 'onChange', 'onInput'], message: 'vbox() expects props object', severity: 'warning' } ] }, hbox: { minArgs: 1, category: 'Layout', description: 'Horizontal flex layout container', rules: [ { argIndex: 0, type: 'object', optionalProps: ['style', 'class', 'gap', 'onClick', 'onHover', 'onChange', 'onInput'], message: 'hbox() expects props object', severity: 'warning' } ] }, grid: { minArgs: 1, category: 'Layout', description: 'Grid layout container', rules: [ { argIndex: 0, type: 'object', optionalProps: ['columns', 'gap', 'style', 'class', 'onClick', 'onHover'], message: 'grid() expects props object', severity: 'warning' } ] }, div: { minArgs: 1, category: 'Elements', description: 'Div element', rules: [ { argIndex: 0, type: 'object', optionalProps: ['class', 'id', 'style', 'onClick', 'onHover', 'onChange', 'onInput'], message: 'div() expects props object', severity: 'warning' } ] }, card: { minArgs: 1, category: 'Components', description: 'Card component container', rules: [ { argIndex: 0, type: 'object', optionalProps: ['style', 'class', 'onClick', 'onHover'], message: 'card() expects props object', severity: 'warning' } ] }, button: { minArgs: 1, category: 'Elements', description: 'Button element', rules: [ { argIndex: 0, type: 'object', optionalProps: ['onClick', 'disabled', 'type', 'class', 'style', 'onHover'], message: 'button() expects props object', severity: 'warning' } ] }, modal: { minArgs: 1, category: 'Components', description: 'Modal dialog component', rules: [ { argIndex: 0, type: 'object', optionalProps: ['open', 'onClose', 'style', 'class', 'onClick', 'onHover'], message: 'modal() expects props object', severity: 'warning' } ] }, tabs: { minArgs: 1, category: 'Components', description: 'Tabs component', rules: [ { argIndex: 0, type: 'object', requiredProps: ['tabs'], optionalProps: ['defaultIndex', 'onChange'], message: 'tabs() requires tabs property', severity: 'error' } ] }, accordion: { minArgs: 1, category: 'Components', description: 'Accordion component', rules: [ { argIndex: 0, type: 'object', requiredProps: ['items'], optionalProps: ['allowMultiple', 'defaultOpen'], message: 'accordion() requires items property', severity: 'error' } ] }, createStore: { minArgs: 2, maxArgs: 3, category: 'State', description: 'Create global reactive store', rules: [ { argIndex: 0, type: 'string', notEmpty: true, message: 'Store name must be a non-empty string', severity: 'error' }, { argIndex: 1, type: 'object', message: 'Initial state must be an object', severity: 'error' } ] }, useStore: { minArgs: 1, maxArgs: 1, category: 'State', description: 'Use existing global store', rules: [ { argIndex: 0, type: 'string', notEmpty: true, message: 'Store name must be a non-empty string', severity: 'error' } ] }, createContext: { minArgs: 0, maxArgs: 1, category: 'State', description: 'Create context for state sharing', rules: [] }, useContext: { minArgs: 1, maxArgs: 1, category: 'State', description: 'Use context value', rules: [ { argIndex: 0, type: 'object', requiredProps: ['key'], message: 'useContext() requires context object with key', severity: 'error' } ] }, onMount: { minArgs: 2, maxArgs: 2, category: 'Lifecycle', description: 'Execute callback on element mount', rules: [ { argIndex: 0, type: 'object', message: 'First argument must be HTMLElement', severity: 'error' }, { argIndex: 1, type: 'function', message: 'Second argument must be a function', severity: 'error' } ] }, onUnmount: { minArgs: 2, maxArgs: 2, category: 'Lifecycle', description: 'Execute callback on element unmount', rules: [ { argIndex: 0, type: 'object', message: 'First argument must be HTMLElement', severity: 'error' }, { argIndex: 1, type: 'function', message: 'Second argument must be a function', severity: 'error' } ] }, watch: { minArgs: 2, maxArgs: 3, category: 'Lifecycle', description: 'Watch reactive value for changes', rules: [ { argIndex: 0, type: 'function', message: 'First argument must be a getter function', severity: 'error' }, { argIndex: 1, type: 'function', message: 'Second argument must be a callback function', severity: 'error' } ] }, debounce: { minArgs: 2, maxArgs: 2, category: 'Performance', description: 'Debounce function execution', rules: [ { argIndex: 0, type: 'function', message: 'First argument must be a function', severity: 'error' }, { argIndex: 1, type: 'number', message: 'Second argument must be a number (wait time in ms)', severity: 'error' } ] }, throttle: { minArgs: 2, maxArgs: 2, category: 'Performance', description: 'Throttle function execution', rules: [ { argIndex: 0, type: 'function', message: 'First argument must be a function', severity: 'error' }, { argIndex: 1, type: 'number', message: 'Second argument must be a number (limit in ms)', severity: 'error' } ] }, animate: { minArgs: 2, maxArgs: 2, category: 'Animation', description: 'Animate element using Web Animations API', rules: [ { argIndex: 0, type: 'object', message: 'First argument must be HTMLElement', severity: 'error' }, { argIndex: 1, type: 'object', requiredProps: ['keyframes'], optionalProps: ['duration', 'easing', 'delay', 'iterations'], message: 'Second argument must be animation config with keyframes', severity: 'error' } ] }, transition: { minArgs: 1, maxArgs: 2, category: 'Animation', description: 'Apply CSS transition to element', rules: [ { argIndex: 0, type: 'object', message: 'First argument must be HTMLElement', severity: 'error' } ] } }; const validationCache = new Map(); export function validateRynexCode(projectRoot, config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; const context = { config: finalConfig, cache: validationCache, stats: { filesChecked: 0, functionsValidated: 0, errorsFound: 0, warningsFound: 0 } }; const errors = []; const srcDir = path.join(projectRoot, 'src'); if (!fs.existsSync(srcDir)) { return errors; } const files = getAllTypeScriptFiles(srcDir); for (const file of files) { const fileErrors = validateFile(file, context); errors.push(...fileErrors); context.stats.filesChecked++; } return errors; } function getAllTypeScriptFiles(dir) { const files = []; const excludeDirs = new Set(['node_modules', 'dist', '.git', 'build', 'coverage']); function scan(currentDir) { try { const items = fs.readdirSync(currentDir); for (const item of items) { if (item.startsWith('.')) continue; const fullPath = path.join(currentDir, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { if (!excludeDirs.has(item)) { scan(fullPath); } } else if (item.endsWith('.ts') || item.endsWith('.tsx')) { files.push(fullPath); } } } catch (error) { console.warn(`Warning: Could not scan directory ${currentDir}`); } } scan(dir); return files; } function validateFile(filePath, context) { if (context.config.enableCache && validationCache.has(filePath)) { return validationCache.get(filePath) || []; } const errors = []; try { const source = fs.readFileSync(filePath, 'utf-8'); const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); function visit(node) { if (ts.isCallExpression(node)) { const functionName = getFunctionName(node); if (functionName && RYNEX_VALIDATION_RULES[functionName]) { const error = validateRynexFunctionCall(node, functionName, filePath, sourceFile, context); if (error) { errors.push(error); context.stats.functionsValidated++; if (error.severity === 'error') { context.stats.errorsFound++; } else if (error.severity === 'warning') { context.stats.warningsFound++; } } } } ts.forEachChild(node, visit); } visit(sourceFile); } catch (error) { console.warn(`Warning: Could not validate file ${filePath}`); } if (context.config.enableCache) { validationCache.set(filePath, errors); } return errors; } function getFunctionName(node) { if (ts.isIdentifier(node.expression)) { return node.expression.text; } if (ts.isPropertyAccessExpression(node.expression)) { return node.expression.name.text; } return null; } function getArgumentType(arg) { if (ts.isStringLiteral(arg)) return 'string'; if (ts.isNumericLiteral(arg)) return 'number'; if (arg.kind === ts.SyntaxKind.TrueKeyword || arg.kind === ts.SyntaxKind.FalseKeyword) return 'boolean'; if (ts.isObjectLiteralExpression(arg)) return 'object'; if (ts.isArrayLiteralExpression(arg)) return 'array'; if (ts.isFunctionExpression(arg) || ts.isArrowFunction(arg)) return 'function'; if (ts.isIdentifier(arg)) return 'identifier'; return 'unknown'; } function extractObjectProperties(arg) { if (!ts.isObjectLiteralExpression(arg)) return []; return arg.properties .map((p) => { if (ts.isPropertyAssignment(p)) { if (ts.isIdentifier(p.name)) return p.name.text; if (ts.isStringLiteral(p.name)) return p.name.text; } return null; }) .filter((p) => p !== null); } function validateRynexFunctionCall(node, functionName, filePath, sourceFile, context) { const rules = RYNEX_VALIDATION_RULES[functionName]; if (!rules) return null; const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); const codeSnippet = sourceFile.text.substring(node.getStart(), Math.min(node.getEnd(), node.getStart() + 100)); if (node.arguments.length < rules.minArgs) { return { file: filePath, line: line + 1, column: character + 1, message: `${functionName}() requires at least ${rules.minArgs} argument(s), got ${node.arguments.length}`, code: codeSnippet, severity: 'error', ruleId: `${functionName}/min-args`, suggestion: `Add missing arguments to ${functionName}() call` }; } if (rules.maxArgs !== undefined && node.arguments.length > rules.maxArgs) { return { file: filePath, line: line + 1, column: character + 1, message: `${functionName}() accepts at most ${rules.maxArgs} argument(s), got ${node.arguments.length}`, code: codeSnippet, severity: 'warning', ruleId: `${functionName}/max-args`, suggestion: `Remove extra arguments from ${functionName}() call` }; } for (const rule of rules.rules) { const arg = node.arguments[rule.argIndex]; if (!arg) continue; const argType = getArgumentType(arg); const { line: argLine, character: argChar } = sourceFile.getLineAndCharacterOfPosition(arg.getStart()); if (rule.type === 'string' && rule.notEmpty && ts.isStringLiteral(arg)) { if (arg.text === '') { return { file: filePath, line: argLine + 1, column: argChar + 1, message: rule.message, code: codeSnippet, severity: rule.severity || 'error', ruleId: `${functionName}/empty-string`, suggestion: `Provide a non-empty string value` }; } } if (rule.type === 'object' && ts.isObjectLiteralExpression(arg)) { const props = extractObjectProperties(arg); if (rule.requiredProps) { const missingProps = rule.requiredProps.filter(p => !props.includes(p)); if (missingProps.length > 0) { return { file: filePath, line: argLine + 1, column: argChar + 1, message: `${rule.message} (missing: ${missingProps.join(', ')})`, code: codeSnippet, severity: rule.severity || 'error', ruleId: `${functionName}/missing-props`, suggestion: `Add required properties: ${missingProps.join(', ')}` }; } } if (context.config.checkOptionalProps && rule.optionalProps) { const unusedProps = props.filter(p => !rule.requiredProps?.includes(p) && !rule.optionalProps?.includes(p)); if (unusedProps.length > 0 && context.config.strict) { return { file: filePath, line: argLine + 1, column: argChar + 1, message: `${functionName}() has unexpected properties: ${unusedProps.join(', ')}`, code: codeSnippet, severity: 'warning', ruleId: `${functionName}/unexpected-props`, suggestion: `Remove or verify these properties: ${unusedProps.join(', ')}` }; } } } if (rule.type === 'function' && !ts.isFunctionExpression(arg) && !ts.isArrowFunction(arg) && !ts.isIdentifier(arg)) { return { file: filePath, line: argLine + 1, column: argChar + 1, message: rule.message, code: codeSnippet, severity: rule.severity || 'error', ruleId: `${functionName}/invalid-function`, suggestion: `Provide a function reference or arrow function` }; } if (rule.type === 'number' && !ts.isNumericLiteral(arg) && !ts.isIdentifier(arg)) { return { file: filePath, line: argLine + 1, column: argChar + 1, message: rule.message, code: codeSnippet, severity: rule.severity || 'error', ruleId: `${functionName}/invalid-number`, suggestion: `Provide a numeric value` }; } } return null; } function generateValidationReport(errors, duration) { const report = { totalErrors: 0, totalWarnings: 0, totalInfos: 0, errorsByFile: {}, errorsByCategory: {}, errorsByRule: {}, timestamp: new Date().toISOString(), duration }; for (const error of errors) { if (error.severity === 'error') report.totalErrors++; else if (error.severity === 'warning') report.totalWarnings++; else report.totalInfos++; if (!report.errorsByFile[error.file]) { report.errorsByFile[error.file] = []; } report.errorsByFile[error.file].push(error); if (!report.errorsByRule[error.ruleId]) { report.errorsByRule[error.ruleId] = []; } report.errorsByRule[error.ruleId].push(error); } return report; } export function printValidationErrors(errors, verbose = false) { if (errors.length === 0) { console.log('\n ✓ No Rynex validation errors found\n'); return; } const errorCount = errors.filter(e => e.severity === 'error').length; const warningCount = errors.filter(e => e.severity === 'warning').length; const infoCount = errors.filter(e => e.severity === 'info').length; // Color codes for terminal output const colors = { reset: '\x1b[0m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', green: '\x1b[32m', gray: '\x1b[90m', bold: '\x1b[1m' }; console.log(`\n ${colors.bold}Found ${errors.length} issue(s): ${colors.red}${errorCount} error(s)${colors.reset}${colors.bold}, ${colors.yellow}${warningCount} warning(s)${colors.reset}${colors.bold}, ${colors.blue}${infoCount} info(s)${colors.reset}${colors.bold}${colors.reset}\n`); const errorsByFile = {}; for (const error of errors) { if (!errorsByFile[error.file]) { errorsByFile[error.file] = []; } errorsByFile[error.file].push(error); } for (const [file, fileErrors] of Object.entries(errorsByFile)) { const relativePath = path.relative(process.cwd(), file); console.log(` ${colors.cyan}${relativePath}${colors.reset}`); for (const error of fileErrors) { let severityColor = colors.blue; let severityLabel = 'INFO'; if (error.severity === 'error') { severityColor = colors.red; severityLabel = 'ERROR'; } else if (error.severity === 'warning') { severityColor = colors.yellow; severityLabel = 'WARN'; } console.log(` ${colors.gray}${error.line}:${error.column}${colors.reset} ${colors.bold}[${severityColor}${severityLabel}${colors.reset}${colors.bold}]${colors.reset} ${error.message}`); // Show code location if (error.code) { const codePreview = error.code.substring(0, 60).replace(/\n/g, ' '); console.log(` ${colors.gray}${codePreview}${error.code.length > 60 ? '...' : ''}${colors.reset}`); } if (error.suggestion && verbose) { console.log(` ${colors.green}💡 Suggestion: ${error.suggestion}${colors.reset}`); } if (verbose) { console.log(` ${colors.gray}Rule: ${error.ruleId}${colors.reset}`); } } console.log(''); } } export function printDetailedReport(errors) { const startTime = Date.now(); const report = generateValidationReport(errors, 0); report.duration = Date.now() - startTime; console.log('\n Rynex Validation Report'); console.log(' ' + '='.repeat(50)); console.log(` Timestamp: ${report.timestamp}`); console.log(` Duration: ${report.duration}ms`); console.log(` Total Issues: ${report.totalErrors + report.totalWarnings + report.totalInfos}`); console.log(` Errors: ${report.totalErrors}`); console.log(` Warnings: ${report.totalWarnings}`); console.log(` Infos: ${report.totalInfos}`); console.log(' ' + '='.repeat(50)); if (Object.keys(report.errorsByFile).length > 0) { console.log('\n By File:'); for (const [file, fileErrors] of Object.entries(report.errorsByFile)) { const relativePath = path.relative(process.cwd(), file); console.log(` ${relativePath}: ${fileErrors.length} issue(s)`); } } if (Object.keys(report.errorsByRule).length > 0) { console.log('\n By Rule:'); for (const [rule, ruleErrors] of Object.entries(report.errorsByRule)) { console.log(` ${rule}: ${ruleErrors.length} occurrence(s)`); } } console.log('\n'); } export function clearValidationCache() { validationCache.clear(); } export function getValidationStats(projectRoot, config = {}) { const finalConfig = { ...DEFAULT_CONFIG, ...config }; const context = { config: finalConfig, cache: validationCache, stats: { filesChecked: 0, functionsValidated: 0, errorsFound: 0, warningsFound: 0 } }; const srcDir = path.join(projectRoot, 'src'); if (!fs.existsSync(srcDir)) { return context.stats; } const files = getAllTypeScriptFiles(srcDir); for (const file of files) { validateFile(file, context); context.stats.filesChecked++; } return context.stats; } export function runRynexValidation(projectRoot, options = {}) { const startTime = Date.now(); console.log('\n Running Rynex validation...\n'); const errors = validateRynexCode(projectRoot, options.config); const duration = Date.now() - startTime; if (options.detailed) { printDetailedReport(errors); } else { printValidationErrors(errors, options.verbose); } console.log(` Validation completed in ${duration}ms\n`); return errors.filter(e => e.severity === 'error').length === 0; } //# sourceMappingURL=rynex-validator.js.map