citty-test-utils
Version:
Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.
363 lines (324 loc) âĸ 10.3 kB
JavaScript
// src/core/utils/analysis-report-utils.js
// Shared utilities for analysis command reports - reduces code duplication
import { existsSync } from 'node:fs'
/**
* Build standardized metadata object for analysis reports
* Used by discover, coverage, and recommend commands
*
* @param {Object} options - Metadata options
* @param {string} options.cliPath - Path to CLI file
* @param {string} [options.analysisMethod='AST-based'] - Analysis method used
* @param {Object} [options.additionalFields={}] - Command-specific fields
* @returns {Object} Standardized metadata object
*/
export function buildAnalysisMetadata(options) {
const { cliPath, analysisMethod = 'AST-based', additionalFields = {} } = options
return {
generatedAt: new Date().toISOString(),
cliPath,
analysisMethod,
...additionalFields,
}
}
/**
* Validate CLI path exists and show helpful error if not
* Used by all three analysis commands to provide consistent error messages
*
* @param {string} cliPath - Path to validate
* @param {boolean} [exitOnError=true] - Exit process if validation fails
* @returns {boolean} True if path exists, false otherwise
*/
export function validateCLIPath(cliPath, exitOnError = true) {
if (!existsSync(cliPath)) {
console.error(`â CLI file not found: ${cliPath}`)
console.error('đĄ Tip: Run from project root or use --cli-path <path>')
console.error('đ Looking for: src/cli.mjs, cli.mjs, or bin/cli.mjs')
if (exitOnError) {
process.exit(1)
}
return false
}
return true
}
/**
* Format CLI detection metadata for reports
* Standardizes how detection information is displayed
*
* @param {Object|null} detectedCLI - Detection result from SmartCLIDetector
* @returns {Object|null} Formatted detection metadata or null
*/
export function formatCLIDetection(detectedCLI) {
if (!detectedCLI) {
return null
}
return {
method: detectedCLI.detectionMethod,
confidence: detectedCLI.confidence,
...(detectedCLI.packageName && { packageName: detectedCLI.packageName }),
...(detectedCLI.searchPath && { searchPath: detectedCLI.searchPath }),
}
}
/**
* Build report header text
* Creates consistent header formatting across all analysis reports
*
* @param {string} title - Report title
* @param {string} [separator='='] - Separator character
* @param {number} [width=40] - Total width of separator line
* @returns {string} Formatted header
*/
export function buildReportHeader(title, separator = '=', width = 40) {
return `${title}\n${separator.repeat(width)}\n`
}
/**
* Build report footer with metadata
* Creates consistent footer with generation time and path info
*
* @param {Object} metadata - Report metadata
* @returns {string} Formatted footer text
*/
export function buildReportFooter(metadata) {
let footer = '\n'
if (metadata.cliPath) {
footer += `đ CLI Path: ${metadata.cliPath}\n`
}
if (metadata.generatedAt) {
footer += `â° Generated: ${metadata.generatedAt}\n`
}
if (metadata.analysisMethod) {
footer += `đ Method: ${metadata.analysisMethod}\n`
}
return footer
}
/**
* Format error for consistent error reporting
* Used when analysis operations fail
*
* @param {Error} error - The error object
* @param {Object} context - Additional context (cliPath, command, etc.)
* @returns {Object} Formatted error object for JSON output
*/
export function formatAnalysisError(error, context = {}) {
return {
error: error.message,
type: error.name || 'AnalysisError',
timestamp: new Date().toISOString(),
...context,
}
}
/**
* Merge and deduplicate arrays for recommendations/suggestions
* Helper for combining multiple analysis results
*
* @param {Array} arrays - Arrays to merge
* @param {string} [uniqueKey='id'] - Key to use for deduplication
* @returns {Array} Merged and deduplicated array
*/
export function mergeUniqueItems(arrays, uniqueKey = 'id') {
const seen = new Set()
const merged = []
for (const array of arrays) {
for (const item of array) {
const key = item[uniqueKey] || JSON.stringify(item)
if (!seen.has(key)) {
seen.add(key)
merged.push(item)
}
}
}
return merged
}
/**
* Calculate percentage with safe division
* Prevents division by zero errors in coverage calculations
*
* @param {number} value - Numerator
* @param {number} total - Denominator
* @param {number} [decimals=1] - Decimal places to round to
* @returns {number} Percentage value (0-100)
*/
export function safePercentage(value, total, decimals = 1) {
if (total === 0) return 0
const percentage = (value / total) * 100
return Number(percentage.toFixed(decimals))
}
/**
* Format file size for human readability
* Used in analysis reports for LOC and file size metrics
*
* @param {number} bytes - Size in bytes
* @returns {string} Formatted size string
*/
export function formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(1)} ${units[unitIndex]}`
}
/**
* Get priority emoji for recommendations
* Provides consistent visual indicators across reports
*
* @param {string} priority - Priority level (critical, high, medium, low)
* @returns {string} Emoji indicator
*/
export function getPriorityEmoji(priority) {
const emojiMap = {
critical: 'đ´',
high: 'đ ',
medium: 'đĄ',
low: 'đĸ',
info: 'âšī¸',
}
return emojiMap[priority?.toLowerCase()] || 'âĒ'
}
/**
* Format data as JSON string with pretty printing
* Used for JSON report generation across all analysis commands
*
* @param {any} data - Data to format
* @param {number} [indent=2] - Indentation spaces
* @returns {string} Formatted JSON string
*
* @example
* const json = formatAsJSON({ foo: 'bar', count: 42 })
* // Returns formatted JSON with 2-space indentation
*/
export function formatAsJSON(data, indent = 2) {
return JSON.stringify(data, null, indent)
}
/**
* Format data as YAML string (simple implementation)
* Provides basic YAML formatting for simple data structures
* For complex YAML needs, consider using a dedicated YAML library
*
* @param {Object} data - Data to format as YAML
* @param {number} [indent=0] - Current indentation level
* @returns {string} YAML formatted string
*
* @example
* const yaml = formatAsYAML({ name: 'test', count: 42 })
* // Returns:
* // name: test
* // count: 42
*/
export function formatAsYAML(data, indent = 0) {
const spaces = ' '.repeat(indent)
const lines = []
for (const [key, value] of Object.entries(data)) {
if (value === null || value === undefined) {
lines.push(`${spaces}${key}: null`)
} else if (typeof value === 'object' && !Array.isArray(value)) {
lines.push(`${spaces}${key}:`)
lines.push(formatAsYAML(value, indent + 1))
} else if (Array.isArray(value)) {
lines.push(`${spaces}${key}:`)
for (const item of value) {
if (typeof item === 'object') {
lines.push(`${spaces} -`)
lines.push(formatAsYAML(item, indent + 2))
} else {
lines.push(`${spaces} - ${item}`)
}
}
} else if (typeof value === 'string') {
// Escape strings with special characters
const needsQuotes = /[:\n\r]/.test(value)
const formatted = needsQuotes ? `"${value.replace(/"/g, '\\"')}"` : value
lines.push(`${spaces}${key}: ${formatted}`)
} else {
lines.push(`${spaces}${key}: ${value}`)
}
}
return lines.join('\n')
}
/**
* Universal report formatter - dispatches to format-specific functions
* Main entry point for formatting reports with multiple output formats
*
* @param {any} data - Data to format
* @param {string} format - Output format ('json', 'yaml', 'text')
* @param {Object} [options] - Format-specific options
* @returns {string} Formatted report string
* @throws {Error} If format is not supported
*
* @example
* const report = formatReport(data, 'json')
* const report = formatReport(data, 'yaml')
*/
export function formatReport(data, format, options = {}) {
const normalizedFormat = format.toLowerCase()
switch (normalizedFormat) {
case 'json':
return formatAsJSON(data, options.indent)
case 'yaml':
case 'yml':
return formatAsYAML(data, options.indent)
default:
throw new Error(
`Unsupported format: ${format}. Supported formats: json, yaml`
)
}
}
/**
* Create report summary statistics
* Generates summary statistics from analysis data for inclusion in reports
*
* @param {Object} data - Analysis data
* @param {Map|Object} data.commands - Commands map or object
* @param {Map|Object} [data.options] - Options map or object
* @param {Map|Object} [data.flags] - Flags map or object
* @returns {Object} Summary statistics
*
* @example
* const summary = createReportSummary({
* commands: new Map([['help', {...}], ['version', {...}]]),
* options: new Map([['verbose', {...}]])
* })
* // Returns: { totalCommands: 2, totalOptions: 1, totalFlags: 0 }
*/
export function createReportSummary(data) {
const summary = {}
if (data.commands) {
summary.totalCommands =
data.commands instanceof Map
? data.commands.size
: Object.keys(data.commands).length
}
if (data.options) {
summary.totalOptions =
data.options instanceof Map ? data.options.size : Object.keys(data.options).length
}
if (data.flags) {
summary.totalFlags =
data.flags instanceof Map ? data.flags.size : Object.keys(data.flags).length
}
return summary
}
/**
* Format duration in human-readable format
* Used for displaying analysis execution time
*
* @param {number} ms - Duration in milliseconds
* @returns {string} Human-readable duration
*
* @example
* formatDuration(500) // '500ms'
* formatDuration(1500) // '1.50s'
* formatDuration(65000) // '1m 5s'
*/
export function formatDuration(ms) {
if (ms < 1000) {
return `${ms}ms`
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(2)}s`
}
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes}m ${seconds}s`
}