validate.ts
1 /** Validate CLI definitions from the registry (JS-first). */ 2 import { getRegistry, fullName, type CliCommand, type InternalCliCommand } from './registry.js'; 3 4 /** All recognized pipeline step names */ 5 const KNOWN_STEP_NAMES = new Set([ 6 'navigate', 'click', 'type', 'wait', 'press', 'snapshot', 7 'fetch', 'evaluate', 8 'select', 'map', 'filter', 'sort', 'limit', 9 'intercept', 'tap', 'download', 10 ]); 11 12 export interface CommandValidationResult { 13 /** Display label: "site/name" or source path if available */ 14 label: string; 15 errors: string[]; 16 warnings: string[]; 17 } 18 19 export interface ValidationReport { 20 ok: boolean; 21 results: CommandValidationResult[]; 22 errors: number; 23 warnings: number; 24 commands: number; 25 } 26 27 /** 28 * Validate registered CLI commands from the in-memory registry. 29 * 30 * The `_dirs` parameter is kept for call-site compatibility but is no longer 31 * used — validation now operates on the registry populated by `discoverClis()`. 32 */ 33 export function validateClisWithTarget(_dirs: string[], target?: string): ValidationReport { 34 const registry = getRegistry(); 35 const results: CommandValidationResult[] = []; 36 let errors = 0; let warnings = 0; 37 38 if (registry.size === 0) { 39 const r: CommandValidationResult = { 40 label: '(registry)', 41 errors: [], 42 warnings: ['Registry is empty — no commands discovered. Did discoverClis() run?'], 43 }; 44 return { ok: true, results: [r], errors: 0, warnings: 1, commands: 0 }; 45 } 46 47 // Resolve alias target: if target is "site/alias", resolve to canonical "site/name" 48 let resolvedTarget = target; 49 if (target?.includes('/')) { 50 const cmd = registry.get(target); 51 if (cmd) resolvedTarget = fullName(cmd); 52 } 53 54 // Deduplicate: registry maps both canonical "site/name" and aliases to the same command 55 const seen = new Set<CliCommand>(); 56 57 for (const [key, cmd] of registry) { 58 if (seen.has(cmd)) continue; 59 // Only validate via canonical key to avoid duplicates from aliases 60 if (key !== fullName(cmd)) continue; 61 seen.add(cmd); 62 63 // Target filter: "site" or "site/name" 64 if (resolvedTarget) { 65 if (resolvedTarget.includes('/')) { 66 if (key !== resolvedTarget) continue; 67 } else { 68 if (cmd.site !== resolvedTarget) continue; 69 } 70 } 71 72 const r = validateCommand(cmd); 73 results.push(r); 74 errors += r.errors.length; 75 warnings += r.warnings.length; 76 } 77 78 return { ok: errors === 0, results, errors, warnings, commands: results.length }; 79 } 80 81 function validateCommand(cmd: CliCommand): CommandValidationResult { 82 const label = fullName(cmd); 83 const errors: string[] = []; 84 const warnings: string[] = []; 85 86 if (!cmd.description) warnings.push('Missing description'); 87 88 // Browser commands should specify a domain for cookie/header context 89 if (cmd.browser && !cmd.domain) { 90 warnings.push('Browser command without "domain" — cookie/header context may not work'); 91 } 92 93 // Pipeline validation: check step names for typos 94 if (Array.isArray(cmd.pipeline)) { 95 for (let i = 0; i < cmd.pipeline.length; i++) { 96 const step = cmd.pipeline[i]; 97 if (step && typeof step === 'object') { 98 for (const key of Object.keys(step)) { 99 if (!KNOWN_STEP_NAMES.has(key)) { 100 warnings.push( 101 `Pipeline step ${i}: unknown step name "${key}" (did you mean one of: ${[...KNOWN_STEP_NAMES].join(', ')}?)` 102 ); 103 } 104 } 105 } 106 } 107 } 108 109 // Commands should have either func, pipeline, or be a lazy-loaded module 110 const internal = cmd as InternalCliCommand; 111 if (!cmd.func && !cmd.pipeline && !internal._lazy) { 112 errors.push('Command has neither "func" nor "pipeline" — it cannot execute'); 113 } 114 115 // Arg validation 116 if (cmd.args && cmd.args.length > 0) { 117 const argNames = new Set<string>(); 118 let seenNonPositional = false; 119 for (const arg of cmd.args) { 120 if (argNames.has(arg.name)) { 121 errors.push(`Duplicate arg name "${arg.name}"`); 122 } 123 argNames.add(arg.name); 124 125 if (arg.positional && seenNonPositional) { 126 warnings.push(`Positional arg "${arg.name}" appears after named args`); 127 } 128 if (!arg.positional) seenNonPositional = true; 129 } 130 } 131 132 return { label, errors, warnings }; 133 } 134 135 export function renderValidationReport(report: ValidationReport): string { 136 const lines = [ 137 `opencli validate: ${report.ok ? 'PASS' : 'FAIL'}`, 138 `Checked ${report.commands} command(s)`, 139 `Errors: ${report.errors} Warnings: ${report.warnings}`, 140 ]; 141 for (const r of report.results) { 142 if (r.errors.length > 0 || r.warnings.length > 0) { 143 lines.push(`\n${r.label}:`); 144 for (const e of r.errors) lines.push(` ❌ ${e}`); 145 for (const w of r.warnings) lines.push(` ⚠️ ${w}`); 146 } 147 } 148 return lines.join('\n'); 149 }