UNPKG

citty-test-utils

Version:

Unified testing framework for CLI applications with auto-detecting local/cleanroom execution, vitest config integration, and simplified scenario DSL.

1,696 lines (1,490 loc) โ€ข 55.8 kB
#!/usr/bin/env node /** * @fileoverview Enhanced AST-Based CLI Coverage Analyzer * @description Uses AST parsing to analyze actual CLI definition files for accurate coverage */ import { readFileSync, existsSync, readdirSync, statSync } from 'fs' import { join, basename } from 'path' import { parse } from 'acorn' import { simple as walk } from 'acorn-walk' import { safeReadFile, isFile, isDirectory } from '../utils/file-utils.js' /** * Enhanced AST-Based CLI Coverage Analyzer * Uses AST parsing to analyze actual CLI definition files for accurate coverage */ export class EnhancedASTCLIAnalyzer { constructor(options = {}) { this.options = { cliPath: options.cliPath || 'src/cli.mjs', testDir: options.testDir || 'test', includePatterns: options.includePatterns || [ '.test.mjs', '.test.js', '.spec.mjs', '.spec.js', ], excludePatterns: options.excludePatterns || ['node_modules', '.git', 'coverage'], verbose: options.verbose || false, ...options, } } /** * Analyze CLI coverage using enhanced AST parsing * @param {Object} options - Analysis options * @returns {Promise<Object>} Coverage analysis results */ async analyze(options = {}) { const analysisOptions = { ...this.options, ...options } if (analysisOptions.verbose) { console.log('๐Ÿš€ Starting Enhanced AST-based CLI coverage analysis...') } try { // Step 1: Discover CLI structure using new hierarchy detection const cliHierarchy = await this.discoverCLIStructureEnhanced(analysisOptions) // Step 2: Discover and analyze test files using new pattern recognition const testPatterns = await this.discoverTestPatternsAST(analysisOptions, cliHierarchy) // Step 3: Map test patterns to coverage const testCoverage = this.mapTestCoverage(testPatterns, cliHierarchy) // Step 4: Calculate coverage const coverage = this.calculateCoverage(testCoverage, cliHierarchy) // Step 5: Generate report const report = this.generateReport(cliHierarchy, testCoverage, coverage, analysisOptions) if (analysisOptions.verbose) { console.log('โœ… Enhanced AST-based CLI coverage analysis complete') } return report } catch (error) { throw new Error(`Enhanced AST CLI coverage analysis failed: ${error.message}`) } } /** * Discover CLI structure by parsing CLI definition files with enhanced AST * @param {Object} options - Analysis options * @returns {Promise<Object>} CLI hierarchy structure */ async discoverCLIStructureEnhanced(options) { const cliPath = options.cliPath || 'src/cli.mjs' if (options.verbose) { console.log(`๐Ÿ” Discovering CLI structure via Enhanced AST: ${cliPath}`) } try { const content = safeReadFile(cliPath, { encoding: 'utf8', maxSize: 10 * 1024 * 1024, // 10MB throwOnError: false, verbose: options.verbose, }) if (!content) { throw new Error(`Could not read CLI file: ${cliPath}`) } const ast = this.parseJavaScriptFileSafe(content, cliPath) if (!ast) { // Return empty hierarchy on parse failure return { mainCommand: { name: 'unknown', description: 'Parse failed', fullPath: 'unknown', subcommands: new Map(), flags: new Map(), options: new Map(), }, subcommands: new Map(), globalOptions: new Map(), } } // Step 1: Detect main command const mainCommand = await this.detectMainCommand(ast) // Step 2: Build command hierarchy const cliHierarchy = this.buildHierarchy(mainCommand) if (options.verbose) { console.log(`๐Ÿ“‹ Discovered ${cliHierarchy.subcommands.size} subcommands via Enhanced AST`) console.log(`๐Ÿ“‹ Found ${cliHierarchy.mainCommand.flags.size + cliHierarchy.mainCommand.options.size} main command options via Enhanced AST`) } return cliHierarchy } catch (error) { throw new Error(`Enhanced AST CLI structure discovery failed: ${error.message}`) } } /** * Detect the main command from AST * @param {Object} ast - Parsed AST * @returns {Promise<Object>} Main command node */ async detectMainCommand(ast) { if (this.options.verbose) { console.log('๐Ÿ” Detecting main command from AST...') } try { // Strategy 1: Look for exported default command const defaultExport = this.findDefaultExport(ast) if (defaultExport && this.isDefineCommand(defaultExport)) { if (this.options.verbose) { console.log('โœ… Found main command via export default') } return defaultExport } // Strategy 2: Look for main CLI variable const mainVariable = this.findMainCLIVariable(ast) if (mainVariable && this.isDefineCommand(mainVariable)) { if (this.options.verbose) { console.log('โœ… Found main command via main CLI variable') } return mainVariable } // Strategy 3: Look for largest defineCommand with subCommands const largestCommand = this.findLargestCommand(ast) if (largestCommand) { if (this.options.verbose) { console.log('โœ… Found main command via largest command with subcommands') } return largestCommand } throw new Error('Could not identify main command from AST') } catch (error) { throw new Error(`Main command detection failed: ${error.message}`) } } /** * Build command hierarchy from main command node * @param {Object} mainCommandNode - Main command AST node * @returns {Object} Command hierarchy structure */ buildHierarchy(mainCommandNode) { if (this.options.verbose) { console.log('๐Ÿ—๏ธ Building command hierarchy...') } const hierarchy = { mainCommand: { name: this.extractCommandName(mainCommandNode), description: this.extractDescription(mainCommandNode), fullPath: this.extractCommandName(mainCommandNode), subcommands: new Map(), flags: new Map(), options: new Map(), }, subcommands: new Map(), globalOptions: new Map(), } // Extract flags and options from main command this.extractFlagsAndOptions(mainCommandNode, hierarchy.mainCommand) // Recursively build subcommand tree this.buildSubcommandTree(mainCommandNode, hierarchy) if (this.options.verbose) { console.log(`๐Ÿ“‹ Built hierarchy with ${hierarchy.subcommands.size} subcommands`) } return hierarchy } /** * Extract imported commands from import statements * @param {Object} node - AST ImportDeclaration node * @param {Object} options - Options */ extractImportedCommands(node, options) { if (!node.source || node.source.type !== 'Literal') return const sourcePath = node.source.value if (!sourcePath.startsWith('./commands/')) return for (const specifier of node.specifiers) { if (specifier.type === 'ImportSpecifier') { const commandName = specifier.imported.name const localName = specifier.local.name this.importedCommands.set(localName, { name: commandName, sourcePath: sourcePath, localName: localName, }) if (options.verbose) { console.log(`๐Ÿ” Found imported command: ${commandName} from ${sourcePath}`) } } } } /** * Extract CLI definition from CallExpression node (enhanced) * @param {Object} node - AST CallExpression node * @param {Object} structure - Structure to populate * @param {Object} options - Options */ extractCLIDefinitionEnhanced(node, structure, options) { const callee = node.callee // Look for defineCommand calls if (callee.type === 'Identifier' && callee.name === 'defineCommand') { this.extractDefineCommandEnhanced(node, structure, options) } } /** * Extract defineCommand definition (enhanced) * @param {Object} node - AST CallExpression node * @param {Object} structure - Structure to populate * @param {Object} options - Options */ extractDefineCommandEnhanced(node, structure, options) { const args = node.arguments if (!args || args.length === 0) return const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') return const command = this.parseCommandDefinitionEnhanced(commandDef) if (command) { structure.commands.set(command.name, command) if (options.verbose) { console.log(`๐Ÿ” Found command: ${command.name}`) } } } /** * Parse command definition object (enhanced) * @param {Object} objNode - AST ObjectExpression node * @returns {Object|null} Parsed command */ parseCommandDefinitionEnhanced(objNode) { const command = { name: null, description: '', subcommands: new Map(), arguments: new Map(), flags: new Map(), options: new Map(), tested: false, testFiles: [], } for (const prop of objNode.properties) { if (prop.type !== 'Property') continue const key = this.getPropertyKey(prop) const value = prop.value switch (key) { case 'meta': if (value.type === 'ObjectExpression') { this.parseMetaObject(value, command) } break case 'subCommands': if (value.type === 'ObjectExpression') { this.parseSubCommandsEnhanced(value, command) } break case 'args': if (value.type === 'ObjectExpression') { this.parseArgs(value, command) } break } } return command.name ? command : null } /** * Parse meta object for command name and description * @param {Object} metaNode - AST ObjectExpression node * @param {Object} command - Command to populate */ parseMetaObject(metaNode, command) { for (const prop of metaNode.properties) { if (prop.type !== 'Property') continue const key = this.getPropertyKey(prop) const value = prop.value if (key === 'name' && value.type === 'Literal') { command.name = value.value } else if (key === 'description' && value.type === 'Literal') { command.description = value.value } } } /** * Parse subcommands object (enhanced) * @param {Object} subCommandsNode - AST ObjectExpression node * @param {Object} command - Command to populate */ parseSubCommandsEnhanced(subCommandsNode, command) { for (const prop of subCommandsNode.properties) { if (prop.type !== 'Property') continue const subcommandName = this.getPropertyKey(prop) const subcommandValue = prop.value // Handle imported command references if (subcommandValue.type === 'Identifier') { const importedCommand = this.importedCommands.get(subcommandValue.name) if (importedCommand) { command.subcommands.set(subcommandName, { name: subcommandName, description: `Imported from ${importedCommand.sourcePath}`, tested: false, testFiles: [], imported: true, sourcePath: importedCommand.sourcePath, }) } } // Handle direct defineCommand calls else if ( subcommandValue.type === 'CallExpression' && subcommandValue.callee.type === 'Identifier' && subcommandValue.callee.name === 'defineCommand' ) { const subcommand = this.parseCommandDefinitionEnhanced(subcommandValue.arguments[0]) if (subcommand) { subcommand.name = subcommandName command.subcommands.set(subcommandName, subcommand) } } } } /** * Parse args object for flags and options * @param {Object} argsNode - AST ObjectExpression node * @param {Object} command - Command to populate */ parseArgs(argsNode, command) { for (const prop of argsNode.properties) { if (prop.type !== 'Property') continue const argName = this.getPropertyKey(prop) const argDef = prop.value if (argDef.type === 'ObjectExpression') { const arg = this.parseArgumentDefinition(argDef) if (arg) { if (arg.isFlag) { command.flags.set(argName, arg) } else { command.options.set(argName, arg) } } } } } /** * Parse argument definition * @param {Object} argDefNode - AST ObjectExpression node * @returns {Object|null} Parsed argument */ parseArgumentDefinition(argDefNode) { const arg = { name: '', description: '', type: 'string', isFlag: false, tested: false, testFiles: [], } for (const prop of argDefNode.properties) { if (prop.type !== 'Property') continue const key = this.getPropertyKey(prop) const value = prop.value switch (key) { case 'type': if (value.type === 'Literal') { arg.type = value.value arg.isFlag = value.value === 'boolean' } break case 'description': if (value.type === 'Literal') { arg.description = value.value } break case 'default': // Check if default is boolean if (value.type === 'Literal' && typeof value.value === 'boolean') { arg.isFlag = true } break } } return arg } /** * Extract exported commands * @param {Object} node - AST ExportNamedDeclaration node * @param {Object} structure - Structure to populate * @param {Object} options - Options */ extractExportedCommands(node, structure, options) { if (!node.declaration || node.declaration.type !== 'VariableDeclaration') return for (const declarator of node.declaration.declarations) { if (declarator.init && declarator.init.type === 'CallExpression') { this.extractCLIDefinitionEnhanced(declarator.init, structure, options) } } } /** * Extract default export * @param {Object} node - AST ExportDefaultDeclaration node * @param {Object} structure - Structure to populate * @param {Object} options - Options */ extractDefaultExport(node, structure, options) { if (node.declaration && node.declaration.type === 'CallExpression') { this.extractCLIDefinitionEnhanced(node.declaration, structure, options) } } /** * Get property key from AST Property node * @param {Object} prop - AST Property node * @returns {string} Property key */ getPropertyKey(prop) { if (prop.key.type === 'Identifier') { return prop.key.name } else if (prop.key.type === 'Literal') { return prop.key.value } return null } /** * Safely parse JavaScript file with error recovery * @param {string} content - File content * @param {string} filePath - File path * @returns {Object|null} AST or null on failure */ parseJavaScriptFileSafe(content, filePath) { try { // Validate content if (!content || typeof content !== 'string') { if (this.options.verbose) { console.warn(`โš ๏ธ Skipping ${filePath}: Invalid content`) } return null } // Validate file size (prevent memory exhaustion) const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB if (content.length > MAX_FILE_SIZE) { if (this.options.verbose) { console.warn(`โš ๏ธ Skipping ${filePath}: File too large (${content.length} bytes)`) } return null } // Remove shebang if present let cleanContent = content if (content.startsWith('#!')) { const firstNewline = content.indexOf('\n') if (firstNewline !== -1) { cleanContent = content.substring(firstNewline + 1) } } // Try parsing with strict mode return parse(cleanContent, { ecmaVersion: 2022, sourceType: 'module', allowReturnOutsideFunction: true, allowImportExportEverywhere: true, allowAwaitOutsideFunction: true, }) } catch (error) { if (this.options.verbose) { console.warn(`โš ๏ธ Skipping ${filePath}: ${error.message}`) } return null } } /** * Parse JavaScript file into AST (throws on error) * @param {string} content - File content * @param {string} filePath - File path * @returns {Object} AST */ parseJavaScriptFile(content, filePath) { const ast = this.parseJavaScriptFileSafe(content, filePath) if (!ast) { throw new Error(`Failed to parse ${filePath}: Invalid JavaScript`) } return ast } /** * Discover test patterns using AST parsing * @param {Object} options - Analysis options * @param {Object} cliHierarchy - CLI hierarchy structure * @returns {Promise<Object>} Test patterns */ async discoverTestPatternsAST(options, cliHierarchy) { if (options.verbose) { console.log('๐Ÿงช Discovering test patterns via AST...') } const testFiles = this.findTestFiles(options.testDir, options) const patterns = new Map() for (const testFile of testFiles) { try { const content = safeReadFile(testFile, { encoding: 'utf8', maxSize: 10 * 1024 * 1024, throwOnError: false, verbose: options.verbose, }) if (!content) { if (options.verbose) { console.log(`โš ๏ธ Could not read ${testFile}`) } continue } const ast = this.parseJavaScriptFileSafe(content, testFile) if (!ast) { if (options.verbose) { console.log(`โš ๏ธ Could not parse ${testFile}`) } continue } walk(ast, { CallExpression: (node) => { this.extractTestCallPattern(node, patterns, testFile, cliHierarchy) }, }) } catch (error) { if (options.verbose) { console.log(`โš ๏ธ Could not analyze ${testFile}: ${error.message}`) } } } if (options.verbose) { console.log(`๐Ÿงช Analyzed ${testFiles.length} test files via AST`) console.log(`๐Ÿงช Found ${patterns.size} test patterns`) } return patterns } /** * Extract test call patterns from AST * @param {Object} node - AST CallExpression node * @param {Map} patterns - Patterns to populate * @param {string} testFile - Test file path * @param {Object} cliHierarchy - CLI hierarchy structure */ extractTestCallPattern(node, patterns, testFile, cliHierarchy) { const testPattern = this.recognizeTestPattern(node, cliHierarchy) if (testPattern.commandPath) { if (!patterns.has(testPattern.commandPath)) { patterns.set(testPattern.commandPath, { name: testPattern.commandPath, tested: true, testFiles: [], flags: new Set(), options: new Set(), }) } const pattern = patterns.get(testPattern.commandPath) pattern.testFiles.push(testFile) // Add flags for (const flag of testPattern.flags) { pattern.flags.add(flag) } // Add options for (const option of testPattern.options) { pattern.options.add(option.name) } if (this.options.verbose) { console.log(`๐Ÿ” Found test pattern: ${testPattern.commandPath} in ${testFile}`) } } } /** * Extract elements from array expression * @param {Object} arrayNode - AST array expression node * @returns {Array} Array of string values */ extractArrayElements(arrayNode) { const elements = [] for (const element of arrayNode.elements) { if (element) { if (element.type === 'Literal') { elements.push(element.value) } else if (element.type === 'TemplateLiteral') { const staticParts = element.quasis.map((q) => q.value.raw).filter((p) => p.length > 0) if (staticParts.length > 0) { elements.push(staticParts[0]) } } } } return elements } /** * Find test files in directory * @param {string} dir - Directory to search * @param {Object} options - Search options * @returns {Array} Array of test file paths */ findTestFiles(dir, options) { const testFiles = [] try { if (!existsSync(dir)) { return testFiles } if (!isDirectory(dir)) { if (options.verbose) { console.warn(`โš ๏ธ Not a directory: ${dir}`) } return testFiles } const items = readdirSync(dir) for (const item of items) { try { const fullPath = join(dir, item) // Skip excluded patterns if (options.excludePatterns.some((pattern) => item.includes(pattern))) { continue } // Check if path is accessible if (!existsSync(fullPath)) { continue } if (isDirectory(fullPath)) { // Recursively search subdirectories testFiles.push(...this.findTestFiles(fullPath, options)) } else if (isFile(fullPath)) { // Check include patterns if (options.includePatterns.some((pattern) => item.includes(pattern))) { testFiles.push(fullPath) } } } catch (error) { // Skip files that cause errors if (options.verbose) { console.warn(`โš ๏ธ Error accessing ${item}: ${error.message}`) } } } } catch (error) { if (options.verbose) { console.warn(`โš ๏ธ Error reading directory ${dir}: ${error.message}`) } } return testFiles } /** * Calculate coverage statistics * @param {Object} cliStructure - CLI structure * @param {Object} testPatterns - Test patterns * @returns {Object} Coverage statistics */ calculateCoverage(cliStructure, testPatterns) { let totalCommands = 0 let testedCommands = 0 let totalSubcommands = 0 let testedSubcommands = 0 let totalFlags = 0 let testedFlags = 0 let totalOptions = 0 let testedOptions = 0 // Calculate command coverage for (const [name, command] of cliStructure.commands) { totalCommands++ if (testPatterns.commands.has(name)) { testedCommands++ command.tested = true command.testFiles = testPatterns.commands.get(name).testFiles const testCmd = testPatterns.commands.get(name) if (testCmd && testCmd.subcommands) { for (const [subName, subcommand] of command.subcommands) { totalSubcommands++ if (testCmd.subcommands.has(subName)) { testedSubcommands++ subcommand.tested = true subcommand.testFiles = testCmd.subcommands.get(subName).testFiles } } } } else { for (const [subName, subcommand] of command.subcommands) { totalSubcommands++ } } } // Calculate global option coverage for (const [name, option] of cliStructure.globalOptions) { if (option.isFlag) { totalFlags++ if (testPatterns.flags.has(name)) { testedFlags++ option.tested = true option.testFiles = testPatterns.flags.get(name).testFiles } } else { totalOptions++ if (testPatterns.options.has(name)) { testedOptions++ option.tested = true option.testFiles = testPatterns.options.get(name).testFiles } } } // Calculate command-specific option coverage for (const [name, command] of cliStructure.commands) { for (const [flagName, flag] of command.flags) { totalFlags++ if (testPatterns.flags.has(flagName)) { testedFlags++ flag.tested = true flag.testFiles = testPatterns.flags.get(flagName).testFiles } } for (const [optionName, option] of command.options) { totalOptions++ if (testPatterns.options.has(optionName)) { testedOptions++ option.tested = true option.testFiles = testPatterns.options.get(optionName).testFiles } } } const totalItems = totalCommands + totalSubcommands + totalFlags + totalOptions const totalTested = testedCommands + testedSubcommands + testedFlags + testedOptions const overallPercentage = totalItems > 0 ? (totalTested / totalItems) * 100 : 0 return { commands: { tested: testedCommands, total: totalCommands, percentage: totalCommands > 0 ? (testedCommands / totalCommands) * 100 : 0, }, subcommands: { tested: testedSubcommands, total: totalSubcommands, percentage: totalSubcommands > 0 ? (testedSubcommands / totalSubcommands) * 100 : 0, }, flags: { tested: testedFlags, total: totalFlags, percentage: totalFlags > 0 ? (testedFlags / totalFlags) * 100 : 0, }, options: { tested: testedOptions, total: totalOptions, percentage: totalOptions > 0 ? (testedOptions / totalOptions) * 100 : 0, }, overall: { tested: totalTested, total: totalItems, percentage: overallPercentage, }, } } /** * Generate comprehensive report * @param {Object} cliHierarchy - CLI hierarchy structure * @param {Object} testCoverage - Test coverage data * @param {Object} coverage - Coverage statistics * @param {Object} options - Report options * @returns {Object} Generated report */ generateReport(cliHierarchy, testCoverage, coverage, options) { const timestamp = new Date().toISOString() return { metadata: { analyzedAt: timestamp, cliPath: options.cliPath || 'src/cli.mjs', testDir: options.testDir || 'test', analysisMethod: 'Enhanced AST-based with Command Hierarchy', totalTestFiles: this.findTestFiles(options.testDir, options).length, totalCommands: 1, // Main command totalSubcommands: cliHierarchy.subcommands.size, totalFlags: coverage.flags.total, totalOptions: coverage.options.total, }, cliHierarchy: cliHierarchy, testCoverage: testCoverage, coverage: { summary: coverage, details: this.generateCoverageDetails(testCoverage, cliHierarchy), }, recommendations: this.generateRecommendations(coverage, this.generateCoverageDetails(testCoverage, cliHierarchy)), } } /** * Convert commands Map to object * @param {Map} commands - Commands Map * @returns {Object} Converted commands object */ convertCommandsToObject(commands) { const result = {} for (const [name, command] of commands) { result[name] = { ...command, subcommands: Object.fromEntries(command.subcommands || new Map()), arguments: Object.fromEntries(command.arguments || new Map()), flags: Object.fromEntries(command.flags || new Map()), options: Object.fromEntries(command.options || new Map()), } } return result } /** * Generate detailed coverage information * @param {Object} cliStructure - CLI structure * @param {Object} coverage - Coverage statistics * @returns {Object} Detailed coverage information */ generateCoverageDetails(cliStructure, coverage) { const details = { untestedCommands: [], untestedSubcommands: [], untestedFlags: [], untestedOptions: [], } for (const [name, command] of cliStructure.commands) { if (!command.tested) { details.untestedCommands.push({ name, description: command.description, }) } for (const [subName, subcommand] of command.subcommands) { if (!subcommand.tested) { details.untestedSubcommands.push({ command: name, subcommand: subName, description: subcommand.description, imported: subcommand.imported || false, sourcePath: subcommand.sourcePath || null, }) } } } for (const [name, option] of cliStructure.globalOptions) { if (!option.tested) { if (option.isFlag) { details.untestedFlags.push({ name, description: option.description, global: true, }) } else { details.untestedOptions.push({ name, description: option.description, global: true, }) } } } return details } /** * Generate recommendations for improving coverage * @param {Object} coverage - Coverage statistics * @param {Object} cliStructure - CLI structure * @returns {Array} Array of recommendations */ generateRecommendations(coverage, cliStructure) { const recommendations = [] if (coverage.commands.percentage < 100) { recommendations.push({ type: 'command', priority: 'high', message: `Add tests for ${ coverage.commands.total - coverage.commands.tested } untested commands`, count: coverage.commands.total - coverage.commands.tested, }) } if (coverage.subcommands.percentage < 100) { recommendations.push({ type: 'subcommand', priority: 'high', message: `Add tests for ${ coverage.subcommands.total - coverage.subcommands.tested } untested subcommands`, count: coverage.subcommands.total - coverage.subcommands.tested, }) } if (coverage.flags.percentage < 100) { recommendations.push({ type: 'flag', priority: 'medium', message: `Add tests for ${coverage.flags.total - coverage.flags.tested} untested flags`, count: coverage.flags.total - coverage.flags.tested, }) } if (coverage.options.percentage < 100) { recommendations.push({ type: 'option', priority: 'medium', message: `Add tests for ${ coverage.options.total - coverage.options.tested } untested options`, count: coverage.options.total - coverage.options.tested, }) } return recommendations } /** * Format report in the specified format * @param {Object} report - Report data * @param {Object} options - Formatting options * @returns {Promise<string>} Formatted report */ async formatReport(report, options = {}) { const { format = 'text' } = options switch (format.toLowerCase()) { case 'text': return this.generateTextReport(report, options) case 'json': return JSON.stringify(report, null, 2) default: throw new Error(`Unsupported format: ${format}`) } } /** * Generate text format report * @param {Object} report - Report data * @param {Object} options - Formatting options * @returns {string} Text report */ generateTextReport(report, options) { const lines = [] lines.push('๐Ÿš€ Enhanced AST-Based CLI Test Coverage Analysis') lines.push('='.repeat(60)) lines.push('') // Summary lines.push('๐Ÿ“ˆ Summary:') // Handle new hierarchy structure if (report.coverage.summary.mainCommand) { lines.push( ` Main Command: ${report.coverage.summary.mainCommand.tested}/${ report.coverage.summary.mainCommand.total } (${report.coverage.summary.mainCommand.percentage.toFixed(1)}%)` ) } else if (report.coverage.summary.commands) { lines.push( ` Commands: ${report.coverage.summary.commands.tested}/${ report.coverage.summary.commands.total } (${report.coverage.summary.commands.percentage.toFixed(1)}%)` ) } if (report.coverage.summary.subcommands) { lines.push( ` Subcommands: ${report.coverage.summary.subcommands.tested}/${ report.coverage.summary.subcommands.total } (${report.coverage.summary.subcommands.percentage.toFixed(1)}%)` ) } lines.push( ` Flags: ${report.coverage.summary.flags.tested}/${ report.coverage.summary.flags.total } (${report.coverage.summary.flags.percentage.toFixed(1)}%)` ) lines.push( ` Options: ${report.coverage.summary.options.tested}/${ report.coverage.summary.options.total } (${report.coverage.summary.options.percentage.toFixed(1)}%)` ) lines.push( ` Overall: ${report.coverage.summary.overall.tested}/${ report.coverage.summary.overall.total } (${report.coverage.summary.overall.percentage.toFixed(1)}%)` ) lines.push('') // Analysis info lines.push('โ„น๏ธ Analysis Info:') lines.push(` Method: ${report.metadata.analysisMethod}`) lines.push(` Analyzed: ${new Date(report.metadata.analyzedAt).toLocaleString()}`) lines.push(` CLI Path: ${report.metadata.cliPath}`) lines.push(` Test Directory: ${report.metadata.testDir}`) lines.push(` Test Files: ${report.metadata.totalTestFiles}`) lines.push(` Commands: ${report.metadata.totalCommands}`) if (report.metadata.totalSubcommands > 0) { lines.push(` Subcommands: ${report.metadata.totalSubcommands}`) } lines.push(` Flags: ${report.metadata.totalFlags}`) lines.push(` Options: ${report.metadata.totalOptions}`) lines.push('') // Recommendations if (report.recommendations && report.recommendations.length > 0) { lines.push('๐Ÿ’ก Recommendations:') report.recommendations.forEach((rec, index) => { lines.push(` ${index + 1}. [${rec.priority.toUpperCase()}] ${rec.message}`) }) lines.push('') } return lines.join('\n') } // ===== NEW HIERARCHY-BASED METHODS ===== /** * Find exported default command * @param {Object} ast - Parsed AST * @returns {Object|null} Default export node */ findDefaultExport(ast) { let defaultExport = null walk(ast, { ExportDefaultDeclaration: (node) => { if (node.declaration) { defaultExport = node.declaration } }, }) return defaultExport } /** * Find main CLI variable * @param {Object} ast - Parsed AST * @returns {Object|null} Main CLI variable node */ findMainCLIVariable(ast) { let mainVariable = null const candidates = [] walk(ast, { VariableDeclaration: (node) => { for (const declarator of node.declarations) { if (declarator.init && this.isDefineCommand(declarator.init)) { const varName = declarator.id.name candidates.push({ name: varName, node: declarator.init, hasSubCommands: this.hasSubCommands(declarator.init), }) } } }, }) // Prefer variables with subCommands (likely main command) const withSubCommands = candidates.filter(c => c.hasSubCommands) if (withSubCommands.length > 0) { mainVariable = withSubCommands[0].node } else if (candidates.length > 0) { mainVariable = candidates[0].node } return mainVariable } /** * Find largest defineCommand with subCommands * @param {Object} ast - Parsed AST * @returns {Object|null} Largest command node */ findLargestCommand(ast) { let largestCommand = null let maxSubCommands = 0 walk(ast, { CallExpression: (node) => { if (this.isDefineCommand(node)) { const subCommandCount = this.countSubCommands(node) if (subCommandCount > maxSubCommands) { maxSubCommands = subCommandCount largestCommand = node } } }, }) return largestCommand } /** * Check if command has subCommands * @param {Object} commandNode - Command AST node * @returns {boolean} True if has subCommands */ hasSubCommands(commandNode) { if (!this.isDefineCommand(commandNode)) { return false } const args = commandNode.arguments if (!args || args.length === 0) { return false } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return false } for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'subCommands' && prop.value.type === 'ObjectExpression') { return prop.value.properties.length > 0 } } } return false } /** * Count subCommands in defineCommand * @param {Object} node - defineCommand node * @returns {number} Number of subCommands */ countSubCommands(node) { if (!this.isDefineCommand(node)) { return 0 } const args = node.arguments if (!args || args.length === 0) { return 0 } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return 0 } let count = 0 for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'subCommands' && prop.value.type === 'ObjectExpression') { count += prop.value.properties.length } } } return count } /** * Check if node is a defineCommand call * @param {Object} node - AST node * @returns {boolean} True if defineCommand call */ isDefineCommand(node) { if (!node || node.type !== 'CallExpression') { return false } const callee = node.callee if (callee.type === 'Identifier' && callee.name === 'defineCommand') { return true } return false } /** * Extract command name from command node * @param {Object} commandNode - Command AST node * @returns {string} Command name */ extractCommandName(commandNode) { if (!this.isDefineCommand(commandNode)) { return 'unknown' } const args = commandNode.arguments if (!args || args.length === 0) { return 'unknown' } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return 'unknown' } for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'meta' && prop.value.type === 'ObjectExpression') { for (const metaProp of prop.value.properties) { if (metaProp.type === 'Property') { const metaKey = this.getPropertyKey(metaProp) if (metaKey === 'name' && metaProp.value.type === 'Literal') { return metaProp.value.value } } } } } } return 'unknown' } /** * Extract description from command node * @param {Object} commandNode - Command AST node * @returns {string} Command description */ extractDescription(commandNode) { if (!this.isDefineCommand(commandNode)) { return 'No description' } const args = commandNode.arguments if (!args || args.length === 0) { return 'No description' } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return 'No description' } for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'meta' && prop.value.type === 'ObjectExpression') { for (const metaProp of prop.value.properties) { if (metaProp.type === 'Property') { const metaKey = this.getPropertyKey(metaProp) if (metaKey === 'description' && metaProp.value.type === 'Literal') { return metaProp.value.value } } } } } } return 'No description' } /** * Extract subcommands from command node * @param {Object} commandNode - Command AST node * @returns {Map} Map of subcommand names to nodes */ extractSubCommands(commandNode) { const subCommands = new Map() if (!this.isDefineCommand(commandNode)) { return subCommands } const args = commandNode.arguments if (!args || args.length === 0) { return subCommands } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return subCommands } for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'subCommands' && prop.value.type === 'ObjectExpression') { for (const subProp of prop.value.properties) { if (subProp.type === 'Property') { const subName = this.getPropertyKey(subProp) const subValue = subProp.value // Handle imported command references if (subValue.type === 'Identifier') { subCommands.set(subName, { type: 'imported', name: subName, importedName: subValue.name, }) } // Handle direct defineCommand calls else if (this.isDefineCommand(subValue)) { subCommands.set(subName, subValue) } } } } } } return subCommands } /** * Extract flags and options from command node * @param {Object} commandNode - Command AST node * @param {Object} command - Command object to populate */ extractFlagsAndOptions(commandNode, command) { if (!this.isDefineCommand(commandNode)) { return } const args = commandNode.arguments if (!args || args.length === 0) { return } const commandDef = args[0] if (commandDef.type !== 'ObjectExpression') { return } for (const prop of commandDef.properties) { if (prop.type === 'Property') { const key = this.getPropertyKey(prop) if (key === 'args' && prop.value.type === 'ObjectExpression') { this.parseArgs(prop.value, command) } } } } /** * Parse args object for flags and options * @param {Object} argsNode - AST ObjectExpression node * @param {Object} command - Command to populate */ parseArgs(argsNode, command) { for (const prop of argsNode.properties) { if (prop.type !== 'Property') continue const argName = this.getPropertyKey(prop) const argDef = prop.value if (argDef.type === 'ObjectExpression') { const arg = this.parseArgumentDefinition(argDef) if (arg) { if (arg.isFlag) { command.flags.set(argName, arg) } else { command.options.set(argName, arg) } } } } } /** * Parse argument definition * @param {Object} argDefNode - AST ObjectExpression node * @returns {Object|null} Parsed argument */ parseArgumentDefinition(argDefNode) { const arg = { name: '', description: '', type: 'string', isFlag: false, tested: false, testFiles: [], } for (const prop of argDefNode.properties) { if (prop.type !== 'Property') continue const key = this.getPropertyKey(prop) const value = prop.value switch (key) { case 'type': if (value.type === 'Literal') { arg.type = value.value arg.isFlag = value.value === 'boolean' } break case 'description': if (value.type === 'Literal') { arg.description = value.value } break case 'default': // Check if default is boolean if (value.type === 'Literal' && typeof value.value === 'boolean') { arg.isFlag = true } break } } return arg } /** * Build subcommand tree recursively * @param {Object} commandNode - Command AST node * @param {Object} hierarchy - Hierarchy structure to populate * @param {string} parentPath - Parent command path */ buildSubcommandTree(commandNode, hierarchy, parentPath = '') { const subCommands = this.extractSubCommands(commandNode) for (const [subName, subCommand] of subCommands) { const fullPath = parentPath ? `${parentPath} ${subName}` : subName const subcommand = { name: subName, fullPath: fullPath, parentPath: parentPath, description: this.extractDescription(subCommand), subcommands: new Map(), flags: new Map(), options: new Map(), } // Extract flags and options from subcommand this.extractFlagsAndOptions(subCommand, subcommand) hierarchy.subcommands.set(fullPath, subcommand) // Recursively handle nested subcommands if (this.hasSubCommands(subCommand)) { this.buildSubcommandTree(subCommand, hierarchy, fullPath) } } } /** * Recognize test pattern from test call node * @param {Object} testCallNode - Test call AST node * @param {Object} cliHierarchy - CLI hierarchy structure * @returns {Object} Recognized test pattern */ recognizeTestPattern(testCallNode, cliHierarchy) { const commandArgs = this.extractCommandArguments(testCallNode) const commandPath = this.determineCommandPath(commandArgs, cliHierarchy) return { commandPath: commandPath, arguments: commandArgs, flags: this.extractFlags(commandArgs), options: this.extractOptions(commandArgs), } } /** * Determine command path from command arguments * @param {Array} commandArgs - Command arguments array * @param {Object} cliHierarchy - CLI hierarchy structure * @returns {string|null} Full command path */ determineCommandPath(commandArgs, cliHierarchy) { if (!commandArgs || commandArgs.length === 0) { return null } // Strategy 1: Direct subcommand (greet, math, error, info) if (commandArgs.length >= 1) { const firstArg = commandArgs[0] // Check if it's a direct subcommand of the main command const directSubcommandPath = `${cliHierarchy.mainCommand.name} ${firstArg}` if (cliHierarchy.subcommands.has(directSubcommandPath)) { if (this.options.verbose) { console.log(`๐Ÿ” Mapped [${commandArgs.join(', ')}] to ${directSubcommandPath}`) } return directSubcommandPath } // Also check if the subcommand exists by name only for (const [subPath, subcommand] of cliHierarchy.subcommands) { if (subcommand.name === firstArg) { if (this.options.verbose) { console.log(`๐Ÿ” Mapped [${commandArgs.join(', ')}] to ${subPath}`) } return subPath } } } // Strategy 2: Nested subcommand (math add, math multiply) if (commandArgs.length >= 2) { const firstArg = commandArgs[0] const secondArg = commandArgs[1] // Check if it's a nested subcommand const nestedSubcommandPath = `${cliHierarchy.mainCommand.name} ${firstArg} ${secondArg}` if (cliHierarchy.subcommands.has(nestedSubcommandPath)) { if (this.options.verbose) { console.log(`๐Ÿ” Mapped [${commandArgs.join(', ')}] to ${nestedSubcommandPath}`) } return nestedSubcommandPath } } // Strategy 3: Main command (playground) if (commandArgs.length === 0 || (commandArgs.length === 1 && commandArgs[0].startsWith('--'))) { if (this.options.verbose) { console.log(`๐Ÿ” Mapped [${commandArgs.join(', ')}] to ${cliHierarchy.mainCommand.name}`) } return cliHierarchy.mainCommand.name } // Strategy 4: Help command patterns if (commandArgs.includes('--help') || commandArgs.includes('--show-help')) { if (commandArgs.length === 1) { return cliHierarchy.mainCommand.name } else if (commandArgs.length === 2) { const subcommandPath = `${cliHierarchy.mainCommand.name} ${commandArgs[0]}` if (cliHierarchy.subcommands.has(subcommandPath)) { return subcommandPath } } } // Strategy 5: Version command patterns if (commandArgs.includes('--version') || commandArgs.includes('--show-version')) { return cliHierarchy.mainCommand.name } if (this.options.verbose) { console.log(`โš ๏ธ Could not map [${commandArgs.join(', ')}] to any command`) } return null } /** * Extract command arguments from test call node * @param {Object} testCallNode - Test call AST node * @returns {Array} Command arguments array */ extractCommandArguments(testCallNode) { const callee = testCallNode.callee if (!callee || callee.type !== 'Identifier') { return [] } const functionName = callee.name if (!['runLocalCitty', 'runCitty'].includes(functionName)) { return [] } const args = testCallNode.arguments if (!args || args.length === 0) { return [] } const firstArg = args[0] if (!firstArg || firstArg.type !== 'ArrayExpression') { return [] } return this.extractArrayElements(firstArg) } /** * Extract flags from command arguments * @param {Array} commandArgs - Command arguments array * @returns {Array} Array of flags */ extractFlags(commandArgs) { const flags = [] for (const arg of commandArgs) { if (typeof arg === 'string' && arg.startsWith('--')) { const flagName = arg.replace('--', '') flags.push(flagName) } } return flags } /** * Extract options from command arguments * @param {Array} commandArgs - Command arguments array * @returns {Array