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
JavaScript
#!/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