/** * rigstate check - Validate code against Guardian rules * * Usage: * rigstate check # Check current directory * rigstate check ./src # Check specific path * rigstate check --strict # Exit 1 on any violation * rigstate check --strict=critical # Exit 1 only on critical * rigstate check --staged # Only check staged files (for pre-commit) * rigstate check --json # Output as JSON */ import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import axios from 'axios'; import { glob } from 'glob'; import fs from 'fs/promises'; import path from 'path'; import { execSync } from 'child_process'; import { getApiKey, getApiUrl, getProjectId } from '../utils/config.js'; import { loadManifest } from '../utils/manifest.js'; import { checkFile, formatViolations, summarizeResults, type EffectiveRule, type CheckResult, type Violation } from '../utils/rule-engine.js'; // Cache settings const CACHE_FILE = '.rigstate/rules-cache.json'; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours (offline limit) interface CachedRules { timestamp: string; projectId: string; rules: EffectiveRule[]; settings: { lmax: number; lmax_warning: number }; } export function createCheckCommand(): Command { return new Command('check') .description('Validate code against Guardian architectural rules') .argument('[path]', 'Directory or file to check', '.') .option('--project ', 'Project ID (or use .rigstate manifest)') .option('--strict [level]', 'Exit 1 on violations. Level: "all" (default) or "critical"') .option('--staged', 'Only check git staged files (for pre-commit hooks)') .option('--json', 'Output results as JSON') .option('--no-cache', 'Skip rule cache and fetch fresh from API') .action(async (targetPath: string, options: { project?: string; strict?: boolean | string; staged?: boolean; json?: boolean; cache?: boolean; }) => { const spinner = ora(); try { // 1. Resolve project context let projectId = options.project; let apiUrl = getApiUrl(); if (!projectId) { const manifest = await loadManifest(); if (manifest) { projectId = manifest.project_id; if (manifest.api_url) apiUrl = manifest.api_url; } } if (!projectId) { projectId = getProjectId(); } if (!projectId) { console.log(chalk.red('āŒ No project context found.')); console.log(chalk.dim(' Run "rigstate link" or pass --project ')); process.exit(2); } // 2. Get API key let apiKey: string; try { apiKey = getApiKey(); } catch { console.log(chalk.red('āŒ Not authenticated. Run "rigstate login" first.')); process.exit(2); } // 3. Fetch rules (with caching) spinner.start('Fetching Guardian rules...'); let rules: EffectiveRule[]; let settings: { lmax: number; lmax_warning: number }; try { const cached = options.cache !== false ? await loadCachedRules(projectId) : null; if (cached && !isStale(cached.timestamp, CACHE_TTL_MS)) { rules = cached.rules; settings = cached.settings; spinner.text = 'Using cached rules...'; } else { // Fetch from API const response = await axios.get(`${apiUrl}/api/v1/guardian/rules`, { params: { project_id: projectId }, headers: { Authorization: `Bearer ${apiKey}` }, timeout: 10000 }); if (!response.data.success) { throw new Error(response.data.error || 'Unknown API error'); } rules = response.data.data.rules; settings = response.data.data.settings; // Save to cache await saveCachedRules(projectId, rules, settings); } } catch (apiError: any) { // Fallback to cache if API fails const cached = await loadCachedRules(projectId); if (cached && !isStale(cached.timestamp, CACHE_MAX_AGE_MS)) { spinner.warn(chalk.yellow('Using cached rules (API unavailable)')); rules = cached.rules; settings = cached.settings; } else { spinner.fail(chalk.red('Failed to fetch rules and no valid cache')); console.log(chalk.dim(` Error: ${apiError.message}`)); process.exit(2); } } spinner.succeed(`Loaded ${rules.length} Guardian rules`); // 4. Get files to check const scanPath = path.resolve(process.cwd(), targetPath); let filesToCheck: string[]; if (options.staged) { // Only staged files spinner.start('Getting staged files...'); try { const stagedOutput = execSync('git diff --cached --name-only --diff-filter=ACMR', { encoding: 'utf-8', cwd: process.cwd() }); filesToCheck = stagedOutput .split('\n') .filter(f => f.trim()) .filter(f => isCodeFile(f)) .map(f => path.resolve(process.cwd(), f)); } catch { spinner.fail('Not a git repository or no staged files'); process.exit(2); } } else { // All code files in path spinner.start(`Scanning ${chalk.cyan(targetPath)}...`); const pattern = path.join(scanPath, '**/*'); const allFiles = await glob(pattern, { nodir: true, dot: false, ignore: [ '**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.next/**', '**/coverage/**' ] }); filesToCheck = allFiles.filter(f => isCodeFile(f)); } if (filesToCheck.length === 0) { spinner.warn(chalk.yellow('No code files found to check.')); outputResults([], !!options.json); process.exit(0); } spinner.succeed(`Found ${filesToCheck.length} files to check`); // 5. Run checks spinner.start('Running Guardian checks...'); const results: CheckResult[] = []; for (let i = 0; i < filesToCheck.length; i++) { const file = filesToCheck[i]; spinner.text = `Checking ${i + 1}/${filesToCheck.length}: ${path.basename(file)}`; const result = await checkFile(file, rules, process.cwd()); results.push(result); } spinner.stop(); // 6. Output results const summary = summarizeResults(results); if (options.json) { outputResults(results, true); } else { outputResults(results, false); // Summary console.log('\n' + chalk.bold('šŸ“Š Summary')); console.log(chalk.dim('─'.repeat(50))); console.log(`Files checked: ${chalk.cyan(summary.totalFiles)}`); console.log(`Total violations: ${summary.totalViolations > 0 ? chalk.red(summary.totalViolations) : chalk.green(0)}`); if (summary.totalViolations > 0) { console.log(` ${chalk.red('Critical:')} ${summary.criticalCount}`); console.log(` ${chalk.yellow('Warning:')} ${summary.warningCount}`); console.log(` ${chalk.blue('Info:')} ${summary.infoCount}`); } console.log(chalk.dim('─'.repeat(50))); } // 7. Exit code logic if (options.strict !== undefined) { const strictLevel = typeof options.strict === 'string' ? options.strict : 'all'; if (strictLevel === 'critical' && summary.criticalCount > 0) { console.log(chalk.red('\nāŒ Check failed: Critical violations found')); process.exit(1); } else if (strictLevel === 'all' && summary.totalViolations > 0) { console.log(chalk.red('\nāŒ Check failed: Violations found')); process.exit(1); } } if (summary.totalViolations === 0) { console.log(chalk.green('\nāœ… All checks passed!')); } process.exit(0); } catch (error: any) { spinner.fail(chalk.red('Check failed')); console.error(chalk.red('Error:'), error.message); process.exit(2); } }); } // Helper functions function isCodeFile(filePath: string): boolean { const codeExtensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']; const ext = path.extname(filePath).toLowerCase(); return codeExtensions.includes(ext); } async function loadCachedRules(projectId: string): Promise { try { const cachePath = path.join(process.cwd(), CACHE_FILE); const content = await fs.readFile(cachePath, 'utf-8'); const cached: CachedRules = JSON.parse(content); if (cached.projectId !== projectId) { return null; } return cached; } catch { return null; } } async function saveCachedRules( projectId: string, rules: EffectiveRule[], settings: { lmax: number; lmax_warning: number } ): Promise { try { const cacheDir = path.join(process.cwd(), '.rigstate'); await fs.mkdir(cacheDir, { recursive: true }); const cached: CachedRules = { timestamp: new Date().toISOString(), projectId, rules, settings }; await fs.writeFile( path.join(cacheDir, 'rules-cache.json'), JSON.stringify(cached, null, 2) ); } catch { // Silently fail cache write } } function isStale(timestamp: string, maxAge: number): boolean { const age = Date.now() - new Date(timestamp).getTime(); return age > maxAge; } function outputResults(results: CheckResult[], json: boolean): void { if (json) { console.log(JSON.stringify({ results, summary: summarizeResults(results) }, null, 2)); return; } const hasViolations = results.some(r => r.violations.length > 0); if (!hasViolations) { return; } console.log('\n' + chalk.bold('šŸ” Violations Found')); console.log(chalk.dim('─'.repeat(50))); for (const result of results) { if (result.violations.length > 0) { formatViolations(result.violations); } } }