// Copyright 2025 Chris Bunting
// Brief: Maintainability Assessor for Code Complexity Analyzer MCP Server
// Scope: Assesses code maintainability including technical debt, code churn, and documentation quality
import { ASTNode } from './ASTProcessor.js';
import { Logger } from '../utils/Logger.js';
import { Language } from '@mcp-code-analysis/shared-types';
import { ComplexityMetrics } from './MetricCalculator.js';
export interface TechnicalDebt {
type: 'code_complexity' | 'code_duplication' | 'lack_of_documentation' | 'outdated_dependencies' | 'test_coverage';
severity: 'low' | 'medium' | 'high' | 'critical';
amount: number; // Estimated effort in person-hours
description: string;
location: { line: number; column: number };
suggestion: string;
priority: number; // 1-10, where 10 is highest priority
}
export interface CodeChurn {
filePath: string;
totalChanges: number;
recentChanges: number; // Changes in last 30 days
hotspots: Array<{ line: number; changeCount: number }>;
stability: number; // 0-1, where 1 is most stable
lastModified: Date;
}
export interface DocumentationQuality {
coverage: number; // 0-1, percentage of documented elements
quality: number; // 0-1, quality of documentation
missingDocs: Array<{ element: string; type: string; location: { line: number; column: number } }>;
outdatedDocs: Array<{ element: string; issue: string; location: { line: number; column: number } }>;
}
export interface MaintainabilityAssessment {
filePath: string;
language: Language;
overallScore: number; // 0-100, where 100 is most maintainable
technicalDebt: TechnicalDebt[];
codeChurn?: CodeChurn;
documentationQuality: DocumentationQuality;
maintainabilityFactors: {
complexity: number;
duplication: number;
documentation: number;
testability: number;
readability: number;
};
recommendations: string[];
assessmentTime: number;
errors: string[];
}
export class MaintainabilityAssessor {
private logger: Logger;
constructor() {
this.logger = new Logger();
}
public async assessMaintainability(
ast: ASTNode,
filePath: string,
language: Language,
metrics: ComplexityMetrics,
sourceCode?: string,
gitHistory?: any[]
): Promise<MaintainabilityAssessment> {
const startTime = Date.now();
const errors: string[] = [];
try {
// Assess technical debt
const technicalDebt = this.assessTechnicalDebt(ast, metrics, sourceCode || '');
// Analyze code churn (if git history is available)
const codeChurn = gitHistory ? this.analyzeCodeChurn(filePath, gitHistory) : undefined;
// Assess documentation quality
const documentationQuality = this.assessDocumentationQuality(ast, sourceCode || '');
// Calculate maintainability factors
const maintainabilityFactors = this.calculateMaintainabilityFactors(metrics, documentationQuality);
// Calculate overall maintainability score
const overallScore = this.calculateOverallScore(maintainabilityFactors);
// Generate recommendations
const recommendations = this.generateRecommendations(technicalDebt, maintainabilityFactors);
const assessmentTime = Date.now() - startTime;
this.logger.info(`Successfully assessed maintainability for: ${filePath}`, {
assessmentTime,
overallScore,
technicalDebtItems: technicalDebt.length,
documentationCoverage: documentationQuality.coverage
});
return {
filePath,
language,
overallScore,
technicalDebt,
codeChurn,
documentationQuality,
maintainabilityFactors,
recommendations,
assessmentTime,
errors
};
} catch (error) {
const assessmentTime = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
errors.push(errorMessage);
this.logger.error(`Failed to assess maintainability for: ${filePath}`, { error: errorMessage });
return {
filePath,
language,
overallScore: 50, // Default middle score
technicalDebt: [],
codeChurn: undefined,
documentationQuality: { coverage: 0, quality: 0, missingDocs: [], outdatedDocs: [] },
maintainabilityFactors: {
complexity: 0.5,
duplication: 0.5,
documentation: 0.5,
testability: 0.5,
readability: 0.5
},
recommendations: ['Error in maintainability assessment'],
assessmentTime,
errors
};
}
}
private assessTechnicalDebt(ast: ASTNode, metrics: ComplexityMetrics, sourceCode: string): TechnicalDebt[] {
const technicalDebt: TechnicalDebt[] = [];
// Code complexity debt
if (metrics.cyclomaticComplexity > 10) {
technicalDebt.push({
type: 'code_complexity',
severity: metrics.cyclomaticComplexity > 20 ? 'high' : 'medium',
amount: Math.max(2, (metrics.cyclomaticComplexity - 10) * 0.5),
description: `High cyclomatic complexity (${metrics.cyclomaticComplexity}) indicates complex control flow`,
location: { line: 1, column: 0 },
suggestion: 'Refactor complex methods into smaller, more focused functions',
priority: Math.min(10, metrics.cyclomaticComplexity)
});
}
if (metrics.cognitiveComplexity > 15) {
technicalDebt.push({
type: 'code_complexity',
severity: metrics.cognitiveComplexity > 30 ? 'high' : 'medium',
amount: Math.max(2, (metrics.cognitiveComplexity - 15) * 0.3),
description: `High cognitive complexity (${metrics.cognitiveComplexity}) makes code hard to understand`,
location: { line: 1, column: 0 },
suggestion: 'Simplify nested conditions and reduce cognitive load',
priority: Math.min(10, Math.floor(metrics.cognitiveComplexity / 3))
});
}
// Code duplication debt
const duplications = this.findCodeDuplicates(sourceCode);
for (const dup of duplications) {
technicalDebt.push({
type: 'code_duplication',
severity: dup.lines > 10 ? 'high' : 'medium',
amount: dup.lines * 0.2,
description: `Code duplication detected (${dup.lines} lines)`,
location: { line: dup.startLine, column: 0 },
suggestion: 'Extract duplicated code into reusable functions',
priority: Math.min(10, dup.lines)
});
}
// Lack of documentation debt
const undocumentedElements = this.findUndocumentedElements(ast, sourceCode);
if (undocumentedElements.length > 3) {
technicalDebt.push({
type: 'lack_of_documentation',
severity: undocumentedElements.length > 10 ? 'high' : 'medium',
amount: undocumentedElements.length * 0.25,
description: `${undocumentedElements.length} elements lack proper documentation`,
location: { line: 1, column: 0 },
suggestion: 'Add comprehensive documentation for all public APIs and complex logic',
priority: Math.min(10, undocumentedElements.length)
});
}
// Test coverage debt (simplified - would need actual test data)
const testCoverage = this.estimateTestCoverage(ast, sourceCode);
if (testCoverage < 0.7) {
technicalDebt.push({
type: 'test_coverage',
severity: testCoverage < 0.3 ? 'high' : 'medium',
amount: (1 - testCoverage) * 10,
description: `Low test coverage estimated at ${(testCoverage * 100).toFixed(1)}%`,
location: { line: 1, column: 0 },
suggestion: 'Increase test coverage to ensure code reliability and maintainability',
priority: Math.floor((1 - testCoverage) * 10)
});
}
// Sort by priority (highest first)
technicalDebt.sort((a, b) => b.priority - a.priority);
return technicalDebt;
}
private analyzeCodeChurn(filePath: string, gitHistory: any[]): CodeChurn {
const fileHistory = gitHistory.filter(commit =>
commit.files?.some((f: any) => f.filePath === filePath)
);
const totalChanges = fileHistory.reduce((sum, commit) => {
const fileCommit = commit.files.find((f: any) => f.filePath === filePath);
return sum + (fileCommit?.additions || 0) + (fileCommit?.deletions || 0);
}, 0);
// Calculate recent changes (last 30 days)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentChanges = fileHistory
.filter(commit => new Date(commit.date) > thirtyDaysAgo)
.reduce((sum, commit) => {
const fileCommit = commit.files.find((f: any) => f.filePath === filePath);
return sum + (fileCommit?.additions || 0) + (fileCommit?.deletions || 0);
}, 0);
// Identify hotspots (frequently changed lines)
const hotspots: Array<{ line: number; changeCount: number }> = [];
const lineChanges = new Map<number, number>();
fileHistory.forEach(commit => {
const fileCommit = commit.files.find((f: any) => f.filePath === filePath);
if (fileCommit?.hunks) {
fileCommit.hunks.forEach((hunk: any) => {
for (let line = hunk.oldStart; line < hunk.oldStart + hunk.oldLines; line++) {
lineChanges.set(line, (lineChanges.get(line) || 0) + 1);
}
});
}
});
// Find lines with high change frequency
lineChanges.forEach((changeCount, line) => {
if (changeCount > 3) {
hotspots.push({ line, changeCount });
}
});
// Sort hotspots by change count
hotspots.sort((a, b) => b.changeCount - a.changeCount);
// Calculate stability (inverse of change frequency)
const maxPossibleChanges = fileHistory.length * 10; // Arbitrary baseline
const stability = Math.max(0, 1 - (totalChanges / maxPossibleChanges));
const lastModified = fileHistory.length > 0
? new Date(Math.max(...fileHistory.map(c => new Date(c.date).getTime())))
: new Date();
return {
filePath,
totalChanges,
recentChanges,
hotspots: hotspots.slice(0, 10), // Top 10 hotspots
stability,
lastModified
};
}
private assessDocumentationQuality(ast: ASTNode, sourceCode: string): DocumentationQuality {
const totalElements = this.countDocumentableElements(ast);
const documentedElements = this.countDocumentedElements(ast, sourceCode);
const coverage = totalElements > 0 ? documentedElements / totalElements : 0;
const quality = this.assessDocumentationQualityDetailed(ast, sourceCode);
const missingDocs = this.findMissingDocumentation(ast, sourceCode);
const outdatedDocs = this.findOutdatedDocumentation(ast, sourceCode);
return {
coverage,
quality,
missingDocs,
outdatedDocs
};
}
private calculateMaintainabilityFactors(
metrics: ComplexityMetrics,
documentationQuality: DocumentationQuality
) {
// Complexity factor (inverse of complexity metrics)
const complexityScore = Math.max(0, 1 - (
(metrics.cyclomaticComplexity / 20) * 0.4 +
(metrics.cognitiveComplexity / 30) * 0.3 +
(metrics.nestingDepth / 5) * 0.3
));
// Duplication factor (simplified - would need actual duplication analysis)
const duplicationScore = 0.8; // Default, would be calculated from actual duplication metrics
// Documentation factor
const documentationScore = (documentationQuality.coverage + documentationQuality.quality) / 2;
// Testability factor (based on complexity and structure)
const testabilityScore = Math.max(0, 1 - (
(metrics.cyclomaticComplexity / 15) * 0.5 +
(metrics.coupling / 10) * 0.3 +
(metrics.cohesion > 0 ? (1 - metrics.cohesion) * 0.2 : 0.2)
));
// Readability factor (based on various metrics)
const readabilityScore = Math.max(0, 1 - (
(metrics.nestingDepth / 6) * 0.3 +
(metrics.averageFunctionSize / 50) * 0.3 +
(metrics.linesOfCode > 1000 ? 0.2 : 0) +
(metrics.commentLines / metrics.linesOfCode < 0.1 ? 0.2 : 0)
));
return {
complexity: complexityScore,
duplication: duplicationScore,
documentation: documentationScore,
testability: testabilityScore,
readability: readabilityScore
};
}
private calculateOverallScore(factors: { [key: string]: number }): number {
const weights = {
complexity: 0.3,
duplication: 0.2,
documentation: 0.2,
testability: 0.15,
readability: 0.15
};
let weightedSum = 0;
let totalWeight = 0;
for (const [factor, score] of Object.entries(factors)) {
const weight = weights[factor as keyof typeof weights] || 0.2;
weightedSum += score * weight;
totalWeight += weight;
}
return Math.round((weightedSum / totalWeight) * 100);
}
private generateRecommendations(technicalDebt: TechnicalDebt[], factors: { [key: string]: number }): string[] {
const recommendations: string[] = [];
// Recommendations based on technical debt
for (const debt of technicalDebt.slice(0, 3)) { // Top 3 debts
recommendations.push(debt.suggestion);
}
// Recommendations based on maintainability factors
if (factors.complexity < 0.6) {
recommendations.push('Focus on reducing code complexity through refactoring and simplification');
}
if (factors.documentation < 0.6) {
recommendations.push('Improve documentation coverage and quality to enhance code maintainability');
}
if (factors.testability < 0.6) {
recommendations.push('Improve code testability by reducing dependencies and increasing modularity');
}
if (factors.readability < 0.6) {
recommendations.push('Enhance code readability through better naming, reduced nesting, and improved structure');
}
// General recommendations
if (technicalDebt.length > 5) {
recommendations.push('Consider implementing a technical debt repayment plan to systematically address issues');
}
recommendations.push('Establish code review processes to prevent maintainability issues');
recommendations.push('Set up automated monitoring of maintainability metrics');
return recommendations;
}
// Helper methods
private findCodeDuplicates(sourceCode: string): Array<{ startLine: number; lines: number }> {
const duplications: Array<{ startLine: number; lines: number }> = [];
const lines = sourceCode.split('\n');
const minBlockSize = 3;
for (let i = 0; i < lines.length - minBlockSize; i++) {
for (let blockSize = minBlockSize; blockSize <= Math.min(10, lines.length - i); blockSize++) {
const block = lines.slice(i, i + blockSize).join('\n');
// Look for the same block elsewhere
for (let j = i + blockSize; j < lines.length - blockSize + 1; j++) {
const otherBlock = lines.slice(j, j + blockSize).join('\n');
if (block === otherBlock && block.trim().length > 0) {
duplications.push({ startLine: i + 1, lines: blockSize });
break; // Only count each duplication once
}
}
}
}
return duplications;
}
private findUndocumentedElements(ast: ASTNode, sourceCode: string): Array<{ element: string; type: string; line: number }> {
const undocumented: Array<{ element: string; type: string; line: number }> = [];
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method' || node.type === 'class') && node.name) {
const hasDocumentation = this.hasDocumentation(node, sourceCode);
if (!hasDocumentation) {
undocumented.push({
element: node.name,
type: node.type,
line: node.start.line
});
}
}
});
return undocumented;
}
private hasDocumentation(node: ASTNode, sourceCode: string): boolean {
const lines = sourceCode.split('\n');
const nodeLine = node.start.line - 1;
// Check for documentation comments before the node
if (nodeLine > 0) {
const prevLine = lines[nodeLine - 1].trim();
return prevLine.startsWith('/**') ||
prevLine.startsWith('/*') ||
prevLine.startsWith('///') ||
prevLine.startsWith('#') ||
prevLine.startsWith('--');
}
return false;
}
private countDocumentableElements(ast: ASTNode): number {
let count = 0;
this.traverseAST(ast, (node) => {
if (node.type === 'function' || node.type === 'method' || node.type === 'class') {
count++;
}
});
return count;
}
private countDocumentedElements(ast: ASTNode, sourceCode: string): number {
let count = 0;
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method' || node.type === 'class') && node.name) {
if (this.hasDocumentation(node, sourceCode)) {
count++;
}
}
});
return count;
}
private assessDocumentationQualityDetailed(ast: ASTNode, sourceCode: string): number {
let totalScore = 0;
let elementCount = 0;
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method' || node.type === 'class') && node.name) {
const score = this.assessElementDocumentationQuality(node, sourceCode);
totalScore += score;
elementCount++;
}
});
return elementCount > 0 ? totalScore / elementCount : 0;
}
private assessElementDocumentationQuality(node: ASTNode, sourceCode: string): number {
if (!this.hasDocumentation(node, sourceCode)) {
return 0;
}
const lines = sourceCode.split('\n');
const nodeLine = node.start.line - 1;
const docLines: string[] = [];
// Extract documentation
for (let i = nodeLine - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line.startsWith('/**') || line.startsWith('/*') || line.startsWith('///') || line.startsWith('#') || line.startsWith('--')) {
docLines.unshift(line);
} else if (line.length > 0 && !line.startsWith('*') && !line.startsWith('//')) {
break;
}
}
const docText = docLines.join('\n');
// Quality assessment criteria
let score = 0;
// Has description
if (docText.length > 20) {
score += 0.3;
}
// Has parameter documentation
if (docText.includes('@param') || docText.includes('Parameter') || docText.includes('Args:')) {
score += 0.3;
}
// Has return value documentation
if (docText.includes('@return') || docText.includes('Returns') || docText.includes('=>')) {
score += 0.2;
}
// Has examples
if (docText.includes('@example') || docText.includes('Example') || docText.includes('e.g.')) {
score += 0.1;
}
// Has version/author information
if (docText.includes('@version') || docText.includes('@author') || docText.includes('Since:')) {
score += 0.1;
}
return Math.min(1, score);
}
private findMissingDocumentation(ast: ASTNode, sourceCode: string): Array<{ element: string; type: string; location: { line: number; column: number } }> {
const missing: Array<{ element: string; type: string; location: { line: number; column: number } }> = [];
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method' || node.type === 'class') && node.name) {
if (!this.hasDocumentation(node, sourceCode)) {
missing.push({
element: node.name,
type: node.type,
location: { line: node.start.line, column: node.start.column }
});
}
}
});
return missing;
}
private findOutdatedDocumentation(ast: ASTNode, sourceCode: string): Array<{ element: string; issue: string; location: { line: number; column: number } }> {
// Simplified implementation - in practice, this would require more sophisticated analysis
const outdated: Array<{ element: string; issue: string; location: { line: number; column: number } }> = [];
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method') && node.name) {
if (this.hasDocumentation(node, sourceCode)) {
// Check for common signs of outdated documentation
const lines = sourceCode.split('\n');
const nodeLine = node.start.line - 1;
const docLines: string[] = [];
for (let i = nodeLine - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line.startsWith('/**') || line.startsWith('/*') || line.startsWith('///') || line.startsWith('#') || line.startsWith('--')) {
docLines.unshift(line);
} else if (line.length > 0 && !line.startsWith('*') && !line.startsWith('//')) {
break;
}
}
const docText = docLines.join('\n');
// Look for TODO markers or outdated version references
if (docText.includes('TODO') || docText.includes('FIXME')) {
outdated.push({
element: node.name,
issue: 'Documentation contains TODO/FIXME markers',
location: { line: node.start.line, column: node.start.column }
});
}
if (docText.match(/\b(19|20)\d{2}\b/)) {
outdated.push({
element: node.name,
issue: 'Documentation contains outdated year references',
location: { line: node.start.line, column: node.start.column }
});
}
}
}
});
return outdated;
}
private estimateTestCoverage(ast: ASTNode, sourceCode: string): number {
// Simplified test coverage estimation
// In practice, this would use actual test coverage data
let testableElements = 0;
let testIndicators = 0;
this.traverseAST(ast, (node) => {
if ((node.type === 'function' || node.type === 'method') && node.name) {
testableElements++;
// Look for test indicators in the code
if (node.name.toLowerCase().includes('test') ||
node.name.toLowerCase().includes('spec') ||
(sourceCode.toLowerCase().includes('assert') && sourceCode.toLowerCase().includes(node.name.toLowerCase()))) {
testIndicators++;
}
}
});
return testableElements > 0 ? testIndicators / testableElements : 0.5; // Default 50%
}
private traverseAST(ast: ASTNode, visitor: (node: ASTNode) => void): void {
visitor(ast);
if (ast.body) {
for (const child of ast.body) {
this.traverseAST(child, visitor);
}
}
if (ast.children) {
for (const child of ast.children) {
this.traverseAST(child, visitor);
}
}
}
}