Advertisement

← ubon

src/reporters/InteractiveReporter.ts

7.3 KB Download file
import { ScanOptions, ScanResult } from '../types';
import chalk from '../utils/colors';
import type { Styler } from '../utils/colors';
import { RULES } from '../rules';
import { getCodeContext } from './HumanReporter';

/**
 * Walks the user through issues one at a time. Optimized for triage
 * sessions where the user wants to *understand* a finding before deciding
 * to fix, suppress, or ignore it. Output uses a fixed 65-column box so it
 * renders consistently across terminal widths.
 */
export class InteractiveReporter {
  private useColor: boolean;

  constructor(colorMode: 'auto' | 'always' | 'never' = 'auto') {
    this.useColor = this.shouldUseColor(colorMode);
  }

  private shouldUseColor(mode: 'auto' | 'always' | 'never'): boolean {
    if (mode === 'always') return true;
    if (mode === 'never') return false;
    return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
  }

  private colorize(fn: Styler, text: string): string {
    return this.useColor ? fn(text) : text;
  }

  private brand(text: string): string {
    return this.useColor ? chalk.hex('#c99cb3')(text) : text;
  }

  async run(results: ScanResult[], options: ScanOptions): Promise<void> {
    console.log(`\n${this.brand('🪷')} Found ${results.length} issues. Let's walk through them together...\n`);
    if (results.length === 0) {
      console.log(`${this.brand('🪷')} Perfect! No issues found. Your app is ready to bloom! ✨\n`);
      return;
    }
    const sorted = [...results].sort((a, b) => {
      const sev = { high: 3, medium: 2, low: 1 } as Record<string, number>;
      const type = { error: 3, warning: 2, info: 1 } as Record<string, number>;
      const sevDiff = sev[b.severity] - sev[a.severity];
      if (sevDiff !== 0) return sevDiff;
      return type[b.type] - type[a.type];
    });

    for (let i = 0; i < sorted.length; i++) {
      const result = sorted[i];
      const choice = await this.presentIssue(result, i + 1, sorted.length, options);
      if (choice === 'quit') {
        console.log(`\n${this.brand('🪷')} Interactive session ended. Remaining issues can be viewed with normal scan.\n`);
        break;
      }
    }
    console.log(`\n${this.brand('🪷')} Interactive walkthrough complete! ✨\n`);
  }

  private async presentIssue(result: ScanResult, current: number, total: number, _options: ScanOptions): Promise<string> {
    const severityColor = this.getSeverityColor(result.severity);
    const typeIcon = result.type === 'error' ? '❌' : '⚠️';

    console.log(`┌${'─'.repeat(65)}┐`);
    console.log(`│ Issue ${current} of ${total} ${' '.repeat(Math.max(0, 65 - (`Issue ${current} of ${total} `).length))}│`);
    console.log(`│ ${typeIcon} ${severityColor} - ${result.ruleId} ${' '.repeat(Math.max(0, 65 - (`${typeIcon} ${result.severity.toUpperCase()} - ${result.ruleId} `).length))}│`);
    console.log(`│ ${result.message} ${' '.repeat(Math.max(0, 65 - (result.message.length + 1)))}│`);
    if (result.file) {
      const location = `${result.file}${result.line ? `:${result.line}` : ''}`;
      console.log(`│ ${this.colorize(chalk.gray, location)} ${' '.repeat(Math.max(0, 65 - (location.length + 1)))}│`);
    }
    console.log(`├${'─'.repeat(65)}┤`);

    const meta = RULES[result.ruleId];
    if (meta?.impact) {
      console.log(`│ ${this.colorize(chalk.blue, '💡 Why this matters:')} ${' '.repeat(65 - '💡 Why this matters: '.length)}│`);
      this.wrapText(meta.impact, 63).forEach((line) => {
        console.log(`│ ${line} ${' '.repeat(Math.max(0, 65 - (line.length + 1)))}│`);
      });
      console.log(`├${'─'.repeat(65)}┤`);
    }
    if (result.fix) {
      console.log(`│ ${this.colorize(chalk.green, '🔧 Suggested fix:')} ${' '.repeat(65 - '🔧 Suggested fix: '.length)}│`);
      this.wrapText(result.fix, 63).forEach((line) => {
        console.log(`│ ${line} ${' '.repeat(Math.max(0, 65 - (line.length + 1)))}│`);
      });
      console.log(`├${'─'.repeat(65)}┤`);
    }

    if (result.file && result.line) {
      const context = getCodeContext(result.file, result.line);
      if (context) {
        console.log(`│ ${this.colorize(chalk.gray, '📋 Code context:')} ${' '.repeat(65 - '📋 Code context: '.length)}│`);
        context.slice(0, 3).forEach((line, idx) => {
          const lineNum = (result.line! - 2 + idx).toString().padStart(3);
          const isTarget = idx === 1;
          const marker = isTarget ? this.colorize(chalk.red, '►') : ' ';
          const display = `${marker} ${lineNum} ${line}`.slice(0, 63);
          console.log(`│ ${display} ${' '.repeat(Math.max(0, 65 - (display.length + 1)))}│`);
        });
        console.log(`├${'─'.repeat(65)}┤`);
      }
    }

    console.log(`│ [${this.colorize(chalk.green, 'f')}]ix automatically  [${this.colorize(chalk.yellow, 's')}]kip  [${this.colorize(chalk.blue, 'b')}]aseline  [${this.colorize(chalk.cyan, 'n')}]ext ${' '.repeat(24)}│`);
    console.log(`│ [${this.colorize(chalk.red, 'q')}]uit  [${this.colorize(chalk.gray, '?')}]help ${' '.repeat(50)}│`);
    console.log(`└${'─'.repeat(65)}┘`);

    return await this.promptUserChoice();
  }

  private getSeverityColor(severity: string): string {
    const colors: Record<string, string> = {
      high: this.colorize(chalk.red, 'HIGH'),
      medium: this.colorize(chalk.yellow, 'MEDIUM'),
      low: this.colorize(chalk.green, 'LOW'),
    };
    return colors[severity] || severity.toUpperCase();
  }

  private wrapText(text: string, maxWidth: number): string[] {
    const words = text.split(' ');
    const lines: string[] = [];
    let cur = '';
    for (const w of words) {
      if ((cur + w).length <= maxWidth) cur += (cur ? ' ' : '') + w;
      else { if (cur) lines.push(cur); cur = w; }
    }
    if (cur) lines.push(cur);
    return lines;
  }

  private async promptUserChoice(): Promise<string> {
    return new Promise((resolve) => {
      const readline = require('readline');
      const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
      rl.question('Choose an action: ', (answer: string) => {
        rl.close();
        const choice = answer.toLowerCase().trim();
        switch (choice) {
          case 'f': case 'fix': resolve('fix'); break;
          case 's': case 'skip': resolve('skip'); break;
          case 'b': case 'baseline': resolve('baseline'); break;
          case 'n': case 'next': resolve('next'); break;
          case 'q': case 'quit': resolve('quit'); break;
          case '?': case 'help':
            console.log('\nAvailable actions:');
            console.log('  f, fix      - Apply automatic fix if available');
            console.log('  s, skip     - Skip this issue');
            console.log('  b, baseline - Add to baseline (suppress)');
            console.log('  n, next     - Continue to next issue');
            console.log('  q, quit     - Exit interactive mode');
            console.log('  ?, help     - Show this help\n');
            resolve('help');
            break;
          default:
            console.log('Invalid choice. Press ? for help.');
            resolve('help');
            break;
        }
      });
    });
  }
}