/ src / validate.ts
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  }