All files / src/output renderer.ts

96.12% Statements 124/129
95.12% Branches 78/82
100% Functions 23/23
96% Lines 120/125

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302                78x     78x 78x 78x 10x       78x 78x       78x 78x 78x   78x                     78x 78x       78x     78x 28x 2x             26x 2x           24x     50x 59x 59x     59x   59x 1x     59x 1x       59x 3x       3x 5x 5x   3x       59x     59x 59x 59x   59x   59x                     50x       78x                                         78x   13x     78x 14x   78x 13x             1x   5x 1x   1x       1x       2x       4x       67x 67x                   78x   78x 78x 78x 78x   78x 26x   52x 52x 52x 52x       156x 6x 58x     52x   52x 52x   52x 52x 52x 52x 52x 52x 59x   52x     62x 52x 3x 3x 3x 3x                     78x 4x 4x       78x 78x 3x     78x       59x 2x   57x       62x 62x     62x         8x 8x 9x     9x 9x 9x 9x 9x 1x 1x     8x 8x       52x 52x 62x 59x 59x 59x     52x    
import { SEVERITY_ORDER, filterFindings } from '../types/index.js';
import type { SkillReport, Finding, Severity, SeverityThreshold } from '../types/index.js';
import type { RenderResult, RenderOptions, GitHubReview, GitHubComment } from './types.js';
import { capitalize, formatStatsCompact, countBySeverity, pluralize } from '../cli/output/formatters.js';
import { generateContentHash, generateMarker } from './dedup.js';
import { escapeHtml } from '../utils/index.js';
 
export function renderSkillReport(report: SkillReport, options: RenderOptions = {}): RenderResult {
  const { includeSuggestions = true, maxFindings, groupByFile = true, reportOn, minConfidence, failOn, requestChanges, checkRunUrl, totalFindings, allFindings } = options;
 
  // Filter by reportOn threshold and confidence, then apply maxFindings limit
  const filteredFindings = filterFindings(report.findings, reportOn, minConfidence);
  const findings = maxFindings ? filteredFindings.slice(0, maxFindings) : filteredFindings;
  const sortedFindings = [...findings].sort(
    (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
  );
 
  // Calculate how many findings were filtered out
  const total = totalFindings ?? report.findings.length;
  const hiddenCount = total - sortedFindings.length;
 
  // Use allFindings for failOn evaluation if provided (e.g., when report.findings was modified for dedup)
  // Apply confidence filtering to failOn evaluation too
  const findingsForFailOn = filterFindings(allFindings ?? report.findings, undefined, minConfidence);
  const review = renderReview(sortedFindings, report, includeSuggestions, failOn, findingsForFailOn, requestChanges);
  const summaryComment = renderSummaryComment(report, sortedFindings, groupByFile, checkRunUrl, hiddenCount);
 
  return { review, summaryComment };
}
 
function renderReview(
  findings: Finding[],
  report: SkillReport,
  includeSuggestions: boolean,
  failOn?: SeverityThreshold,
  allFindings?: Finding[],
  requestChanges?: boolean,
): GitHubReview | undefined {
  const findingsWithLocation = findings.filter((f) => f.location);
  const findingsWithoutLocation = findings.filter((f) => !f.location);
 
  // Determine review event type based on failOn threshold against ALL findings.
  // Use allFindings (or report.findings) so failOn operates independently of reportOn and deduplication.
  const event = determineReviewEvent(allFindings ?? report.findings, failOn, requestChanges);
 
  // No inline comments to post. Create a review only for REQUEST_CHANGES or locationless findings.
  if (findingsWithLocation.length === 0) {
    if (findingsWithoutLocation.length > 0) {
      return {
        event,
        body: renderFindingsBody(findingsWithoutLocation, report.skill),
        comments: [],
      };
    }
    // Generic fallback for REQUEST_CHANGES when failOn triggers on findings below reportOn threshold
    if (event === 'REQUEST_CHANGES') {
      return {
        event,
        body: 'Findings exceed the configured threshold. See the GitHub Check for details.',
        comments: [],
      };
    }
    return undefined;
  }
 
  const comments: GitHubComment[] = findingsWithLocation.map((finding) => {
    const location = finding.location;
    Iif (!location) {
      throw new Error('Unexpected: finding without location in filtered list');
    }
    let body = `**${escapeHtml(finding.title)}**\n\n${escapeHtml(finding.description)}`;
 
    if (finding.verification) {
      body += `\n\n${renderVerification(finding.verification)}`;
    }
 
    if (includeSuggestions && finding.suggestedFix) {
      body += `\n\n${renderSuggestion(finding.suggestedFix.description, finding.suggestedFix.diff)}`;
    }
 
    // Additional locations section
    if (finding.additionalLocations?.length) {
      body += '\n\n<details><summary>Also found at ' +
        `${finding.additionalLocations.length} additional ` +
        pluralize(finding.additionalLocations.length, 'location') +
        '</summary>\n\n';
      for (const loc of finding.additionalLocations) {
        const range = loc.endLine ? `${loc.startLine}-${loc.endLine}` : `${loc.startLine}`;
        body += `- \`${loc.path}:${range}\`\n`;
      }
      body += '\n</details>';
    }
 
    // Add attribution footer with skill name and finding ID
    body += `\n\n${renderAttributionFooter(report.skill, finding.id)}`;
 
    // Add deduplication marker
    const contentHash = generateContentHash(finding.title, finding.description);
    const line = location.endLine ?? location.startLine;
    body += `\n${generateMarker(location.path, line, contentHash)}`;
 
    const isMultiLine = location.endLine && location.startLine !== location.endLine;
 
    return {
      body,
      path: location.path,
      line: location.endLine ?? location.startLine,
      side: 'RIGHT' as const,
      start_line: isMultiLine ? location.startLine : undefined,
      start_side: isMultiLine ? ('RIGHT' as const) : undefined,
    };
  });
 
  // Include locationless findings in the review body when mixed with inline comments
  const body = findingsWithoutLocation.length > 0
    ? renderFindingsBody(findingsWithoutLocation, report.skill)
    : '';
 
  return {
    event,
    body,
    comments,
  };
}
 
/**
 * Determine the PR review event type based on failOn threshold.
 * Returns:
 * - REQUEST_CHANGES if failOn is set and findings meet/exceed the threshold
 * - COMMENT otherwise
 *
 * Clearing a previous REQUEST_CHANGES is handled by dismissing the review
 * in the PR workflow, not by posting an APPROVE.
 */
function determineReviewEvent(
  findings: Finding[],
  failOn?: SeverityThreshold,
  requestChanges?: boolean,
): GitHubReview['event'] {
  if (!requestChanges) return 'COMMENT';
 
  const hasActiveThreshold = failOn && failOn !== 'off';
 
  const hasBlockingFinding =
    hasActiveThreshold &&
    findings.some((f) => SEVERITY_ORDER[f.severity] <= SEVERITY_ORDER[failOn]);
 
  if (hasBlockingFinding) {
    return 'REQUEST_CHANGES';
  }
 
  return 'COMMENT';
}
 
function renderSuggestion(description: string, diff: string): string {
  const suggestionLines = diff
    .split('\n')
    .filter((line) => line.startsWith('+') && !line.startsWith('+++'))
    .map((line) => line.slice(1));
 
  Iif (suggestionLines.length === 0) {
    return `**Suggested fix:** ${escapeHtml(description)}`;
  }
 
  return `**Suggested fix:** ${escapeHtml(description)}\n\n\`\`\`suggestion\n${suggestionLines.join('\n')}\n\`\`\``;
}
 
function renderVerification(verification: string): string {
  return `<details><summary>Verification</summary>\n\n${escapeHtml(verification)}\n\n</details>`;
}
 
function renderHiddenFindingsLink(hiddenCount: number, checkRunUrl: string): string {
  return `[View ${hiddenCount} additional ${pluralize(hiddenCount, 'finding')} in Checks](${checkRunUrl})`;
}
 
function renderAttributionFooter(skill: string, findingId?: string): string {
  const idSuffix = findingId ? ` · ${escapeHtml(findingId)}` : '';
  return `<sub>Identified by Warden ${escapeHtml(skill)}${idSuffix}</sub>`;
}
 
function renderSummaryComment(
  report: SkillReport,
  findings: Finding[],
  groupByFile: boolean,
  checkRunUrl?: string,
  hiddenCount?: number
): string {
  const lines: string[] = [];
 
  lines.push(`## ${report.skill}`);
  lines.push('');
  lines.push(escapeHtml(report.summary));
  lines.push('');
 
  if (findings.length === 0) {
    lines.push('No findings to report.');
  } else {
    const counts = countBySeverity(findings);
    lines.push('### Summary');
    lines.push('');
    lines.push(
      `| Severity | Count |
|----------|-------|
${Object.entries(counts)
  .filter(([, count]) => count > 0)
  .sort(([a], [b]) => SEVERITY_ORDER[a as Severity] - SEVERITY_ORDER[b as Severity])
  .map(([severity, count]) => `| ${capitalize(severity)} | ${count} |`)
  .join('\n')}`
    );
    lines.push('');
 
    lines.push('### Findings');
    lines.push('');
 
    if (groupByFile) {
      const byFile = groupFindingsByFile(findings);
      for (const [file, fileFindings] of Object.entries(byFile)) {
        lines.push(`#### \`${file}\``);
        lines.push('');
        for (const finding of fileFindings) {
          lines.push(renderFindingItem(finding));
        }
        lines.push('');
      }
 
      const noLocation = findings.filter((f) => !f.location);
      if (noLocation.length > 0) {
        lines.push('#### General');
        lines.push('');
        for (const finding of noLocation) {
          lines.push(renderFindingItem(finding));
        }
      }
    } else E{
      for (const finding of findings) {
        lines.push(renderFindingItem(finding));
      }
    }
  }
 
  // Add link to full report if there are hidden findings
  if (hiddenCount && hiddenCount > 0 && checkRunUrl) {
    lines.push('');
    lines.push(renderHiddenFindingsLink(hiddenCount, checkRunUrl));
  }
 
  // Add stats footer
  const statsLine = formatStatsCompact(report.durationMs, report.usage, report.auxiliaryUsage);
  if (statsLine) {
    lines.push('', '---', `<sub>${statsLine}</sub>`);
  }
 
  return lines.join('\n');
}
 
function formatLineRange(loc: { startLine: number; endLine?: number }): string {
  if (loc.endLine && loc.endLine !== loc.startLine) {
    return `L${loc.startLine}-${loc.endLine}`;
  }
  return `L${loc.startLine}`;
}
 
function renderFindingItem(finding: Finding): string {
  const location = finding.location ? ` (${formatLineRange(finding.location)})` : '';
  const extra = finding.additionalLocations?.length
    ? ` (+${finding.additionalLocations.length} more ${pluralize(finding.additionalLocations.length, 'location')})`
    : '';
  return `- \`${finding.id}\` **${escapeHtml(finding.title)}**${location}${extra} · ${finding.severity}: ${escapeHtml(finding.description)}`;
}
 
/** Render findings as markdown for inclusion in a review body. */
export function renderFindingsBody(findings: Finding[], skill: string): string {
  const lines: string[] = [];
  for (const finding of findings) {
    const location = finding.location
      ? ` (\`${finding.location.path}:${finding.location.startLine}\`)`
      : '';
    lines.push(`**${escapeHtml(finding.title)}**${location}`);
    lines.push('');
    lines.push(escapeHtml(finding.description));
    lines.push('');
    if (finding.verification) {
      lines.push(renderVerification(finding.verification));
      lines.push('');
    }
  }
  lines.push(renderAttributionFooter(skill));
  return lines.join('\n');
}
 
function groupFindingsByFile(findings: Finding[]): Record<string, Finding[]> {
  const groups: Record<string, Finding[]> = {};
  for (const finding of findings) {
    if (finding.location) {
      const path = finding.location.path;
      groups[path] ??= [];
      groups[path].push(finding);
    }
  }
  return groups;
}