code-auditor-mcp
Version:
Multi-language code quality auditor with MCP server - Analyze TypeScript, JavaScript, and Go code for SOLID principles, DRY violations, security patterns, and more
321 lines • 13.9 kB
JavaScript
/**
* Documentation Quality Analyzer
* Assesses JSDoc coverage and documentation quality across the codebase
*/
import * as ts from 'typescript';
import { promises as fs } from 'fs';
import { getNodePosition, findNodesOfType, getNodeName, processFiles } from './analyzerUtils.js';
import { isReactComponent, getComponentName } from '../utils/reactDetection.js';
export const DEFAULT_DOCUMENTATION_CONFIG = {
requireFunctionDocs: true,
requireComponentDocs: true,
requireFileDocs: true,
requireParamDocs: true,
requireReturnDocs: true,
minDescriptionLength: 10,
checkExportedOnly: false,
exemptPatterns: [
'test', 'spec', '\.d\.ts$', 'mock', 'fixture'
]
};
/**
* Extracts JSDoc comment for a node
*/
function getJSDoc(node, sourceFile) {
const jsDocTags = ts.getJSDocTags(node);
const jsDocComments = ts.getJSDocCommentsAndTags(node);
if (jsDocComments.length > 0) {
const comment = jsDocComments[0];
if (ts.isJSDoc(comment)) {
return comment.comment ? ts.getTextOfJSDocComment(comment.comment) || '' : '';
}
}
// Fallback: look for leading comments
const fullText = sourceFile.getFullText();
const leadingComments = ts.getLeadingCommentRanges(fullText, node.getFullStart());
if (leadingComments) {
for (const comment of leadingComments) {
const commentText = fullText.substring(comment.pos, comment.end);
if (commentText.includes('/**')) {
return commentText.replace(/\/\*\*|\*\/|\s*\*/g, '').trim();
}
}
}
return null;
}
/**
* Checks if a node is exported
*/
function isExported(node) {
if ('modifiers' in node && node.modifiers) {
return node.modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
}
return false;
}
/**
* Extracts file-level purpose comment
*/
function getFilePurpose(sourceFile) {
const fullText = sourceFile.getFullText();
const leadingComments = ts.getLeadingCommentRanges(fullText, 0);
if (leadingComments) {
for (const comment of leadingComments) {
const commentText = fullText.substring(comment.pos, comment.end);
if (commentText.includes('@fileoverview') || commentText.includes('@purpose')) {
return commentText.replace(/\/\*\*|\*\/|\s*\*/g, '').trim();
}
}
}
return null;
}
/**
* Analyzes JSDoc parameters documentation
*/
function analyzeParamDocumentation(node) {
const parameters = node.parameters || [];
const jsDocTags = ts.getJSDocTags(node);
const paramTags = jsDocTags.filter(tag => tag.tagName.text === 'param');
return {
totalParams: parameters.length,
documentedParams: paramTags.length
};
}
/**
* Checks if function has return documentation
*/
function hasReturnDocumentation(node) {
const jsDocTags = ts.getJSDocTags(node);
return jsDocTags.some(tag => tag.tagName.text === 'returns' || tag.tagName.text === 'return');
}
/**
* Analyzes a single file for documentation quality
*/
function analyzeFileDocumentation(sourceFile, config) {
const violations = [];
const fileName = sourceFile.fileName;
let totalFunctions = 0;
let documentedFunctions = 0;
let totalComponents = 0;
let documentedComponents = 0;
let functionsWithParams = 0;
let paramsDocumented = 0;
let functionsWithReturns = 0;
let returnsDocumented = 0;
// Check file-level documentation
const filePurpose = getFilePurpose(sourceFile);
const hasFileDocs = !!filePurpose;
if (config.requireFileDocs && !hasFileDocs) {
violations.push({
file: fileName,
line: 1,
column: 1,
severity: 'suggestion',
message: 'File missing purpose documentation',
details: 'Consider adding @fileoverview or @purpose comment at the top of the file',
suggestion: 'Add file-level documentation explaining the module\'s purpose'
});
}
// Analyze functions and components
ts.forEachChild(sourceFile, function visit(node) {
if (ts.isFunctionDeclaration(node) ||
ts.isFunctionExpression(node) ||
ts.isArrowFunction(node) ||
ts.isMethodDeclaration(node)) {
totalFunctions++;
const isExportedNode = isExported(node);
const shouldCheck = !config.checkExportedOnly || isExportedNode;
if (shouldCheck) {
const jsDoc = getJSDoc(node, sourceFile);
const hasGoodDoc = jsDoc && jsDoc.length >= config.minDescriptionLength;
if (hasGoodDoc) {
documentedFunctions++;
}
else if (config.requireFunctionDocs) {
const functionName = getNodeName(node) || 'anonymous function';
const position = getNodePosition(sourceFile, node);
violations.push({
file: fileName,
line: position.line,
column: position.column,
severity: 'suggestion',
message: `Function '${functionName}' lacks documentation`,
details: 'Functions should have JSDoc comments describing their purpose',
suggestion: 'Add JSDoc comment with function description and parameter/return documentation'
});
}
// Check parameter documentation
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)) {
const paramAnalysis = analyzeParamDocumentation(node);
if (paramAnalysis.totalParams > 0) {
functionsWithParams++;
if (paramAnalysis.documentedParams === paramAnalysis.totalParams) {
paramsDocumented++;
}
else if (config.requireParamDocs && hasGoodDoc) {
const functionName = getNodeName(node) || 'function';
const position = getNodePosition(sourceFile, node);
violations.push({
file: fileName,
line: position.line,
column: position.column,
severity: 'suggestion',
message: `Function '${functionName}' has undocumented parameters`,
details: `${paramAnalysis.documentedParams}/${paramAnalysis.totalParams} parameters documented`,
suggestion: 'Add @param tags for all function parameters'
});
}
}
// Check return documentation
const hasReturn = node.type ||
(ts.isFunctionDeclaration(node) && node.body &&
findNodesOfType(node.body, ts.isReturnStatement).length > 0);
if (hasReturn) {
functionsWithReturns++;
if (hasReturnDocumentation(node)) {
returnsDocumented++;
}
else if (config.requireReturnDocs && hasGoodDoc) {
const functionName = getNodeName(node) || 'function';
const position = getNodePosition(sourceFile, node);
violations.push({
file: fileName,
line: position.line,
column: position.column,
severity: 'suggestion',
message: `Function '${functionName}' missing return documentation`,
details: 'Functions with return values should document what they return',
suggestion: 'Add @returns tag describing the return value'
});
}
}
}
}
}
// Check React components
if (isReactComponent(node)) {
totalComponents++;
const isExportedComponent = isExported(node);
const shouldCheck = !config.checkExportedOnly || isExportedComponent;
if (shouldCheck) {
const jsDoc = getJSDoc(node, sourceFile);
const hasGoodDoc = jsDoc && jsDoc.length >= config.minDescriptionLength;
if (hasGoodDoc) {
documentedComponents++;
}
else if (config.requireComponentDocs) {
const componentName = getComponentName(node) || 'Component';
const position = getNodePosition(sourceFile, node);
violations.push({
file: fileName,
line: position.line,
column: position.column,
severity: 'suggestion',
message: `Component '${componentName}' lacks documentation`,
details: 'React components should have JSDoc comments describing their purpose and props',
suggestion: 'Add JSDoc comment with component description and @param tags for props'
});
}
}
}
ts.forEachChild(node, visit);
});
return {
violations,
metrics: {
totalFunctions,
documentedFunctions,
totalComponents,
documentedComponents,
functionsWithParams,
paramsDocumented,
functionsWithReturns,
returnsDocumented,
totalFiles: 1,
filesWithPurpose: hasFileDocs ? 1 : 0
}
};
}
/**
* Main documentation analyzer function
*/
export async function analyzeDocumentation(files, config = {}, options = {}, progressCallback) {
const finalConfig = { ...DEFAULT_DOCUMENTATION_CONFIG, ...config };
const startTime = Date.now();
// Filter out exempt files
const filteredFiles = files.filter(file => {
return !finalConfig.exemptPatterns.some(pattern => new RegExp(pattern, 'i').test(file));
});
const progressReporter = progressCallback ? (current, total, file) => {
progressCallback({ current, total, analyzer: 'documentation', file });
} : undefined;
const result = await processFiles(filteredFiles, (filePath, sourceFile, config) => analyzeFileDocumentation(sourceFile, finalConfig).violations, 'documentation', finalConfig, progressReporter);
// Get metrics separately by re-running the analysis (not ideal but works for now)
const metricsResults = await Promise.all(filteredFiles.map(async (file) => {
const sourceFile = ts.createSourceFile(file, await fs.readFile(file, 'utf-8'), ts.ScriptTarget.Latest, true);
return analyzeFileDocumentation(sourceFile, finalConfig).metrics;
}));
// Aggregate metrics
const aggregatedMetrics = {
totalFunctions: 0,
documentedFunctions: 0,
totalComponents: 0,
documentedComponents: 0,
totalFiles: filteredFiles.length,
filesWithPurpose: 0,
functionsWithParams: 0,
paramsDocumented: 0,
functionsWithReturns: 0,
returnsDocumented: 0,
coverageScore: 0,
wellDocumentedFiles: [],
poorlyDocumentedFiles: []
};
// Combine metrics from all files
metricsResults.forEach((fileMetrics) => {
if (fileMetrics) {
Object.keys(fileMetrics).forEach(key => {
if (typeof aggregatedMetrics[key] === 'number' &&
typeof fileMetrics[key] === 'number') {
aggregatedMetrics[key] += fileMetrics[key];
}
});
}
});
// Calculate coverage score
const totalItems = aggregatedMetrics.totalFunctions + aggregatedMetrics.totalComponents + aggregatedMetrics.totalFiles;
const documentedItems = aggregatedMetrics.documentedFunctions + aggregatedMetrics.documentedComponents + aggregatedMetrics.filesWithPurpose;
aggregatedMetrics.coverageScore = totalItems > 0 ? Math.round((documentedItems / totalItems) * 100) : 100;
// Identify well/poorly documented files
filteredFiles.forEach((file, index) => {
const fileMetrics = metricsResults[index];
if (fileMetrics) {
const fileTotal = (fileMetrics.totalFunctions || 0) + (fileMetrics.totalComponents || 0) + 1;
const fileDocumented = (fileMetrics.documentedFunctions || 0) + (fileMetrics.documentedComponents || 0) + (fileMetrics.filesWithPurpose || 0);
const fileCoverage = fileTotal > 0 ? (fileDocumented / fileTotal) : 0;
if (fileCoverage >= 0.8) {
aggregatedMetrics.wellDocumentedFiles.push(file);
}
else if (fileCoverage < 0.3) {
aggregatedMetrics.poorlyDocumentedFiles.push(file);
}
}
});
return {
violations: result.violations,
filesProcessed: result.filesProcessed,
executionTime: Date.now() - startTime,
errors: result.errors,
analyzerName: 'documentation',
metrics: aggregatedMetrics
};
}
/**
* Documentation analyzer definition for the registry
*/
export const documentationAnalyzer = {
name: 'documentation',
analyze: analyzeDocumentation,
description: 'Analyzes JSDoc coverage and documentation quality',
category: 'quality'
};
//# sourceMappingURL=documentationAnalyzer.js.map