Skip to main content
Glama
cbunting99

MCP Code Analysis & Quality Server

by cbunting99
PreCommitHookService.ts14.1 kB
// Copyright 2025 Chris Bunting // Brief: Pre-commit hook service for Static Analysis MCP Server // Scope: Provides Git pre-commit hook integration for automatic code quality checks import { readFileSync, existsSync, writeFileSync, unlinkSync, chmodSync } from 'fs'; import { join, dirname, basename, relative } from 'path'; import { execSync, exec } from 'child_process'; import { AnalysisResult, AnalysisIssue, AnalysisMetrics, AnalysisOptions, SeverityLevel, IssueCategory, Language, FixSuggestion } from '@mcp-code-analysis/shared-types'; import { Logger } from '../utils/Logger.js'; import { StaticAnalysisService } from './StaticAnalysisService.js'; import { LanguageDetector } from './LanguageDetector.js'; export interface PreCommitHookOptions { enabled: boolean; blockOnErrors: boolean; blockOnWarnings: boolean; maxIssues: number; excludePatterns: string[]; includePatterns: string[]; autoFix: boolean; timeout: number; } export interface PreCommitResult { success: boolean; issuesFound: number; errorsFound: number; warningsFound: number; blocked: boolean; message: string; results: AnalysisResult[]; fixedFiles: string[]; } export interface HookConfig { projectPath: string; hookPath: string; options: PreCommitHookOptions; } export class PreCommitHookService { private logger: Logger; private analysisService: StaticAnalysisService; private languageDetector: LanguageDetector; private hooks: Map<string, HookConfig> = new Map(); constructor( analysisService: StaticAnalysisService, languageDetector: LanguageDetector, logger: Logger ) { this.analysisService = analysisService; this.languageDetector = languageDetector; this.logger = logger; this.logger.info('Pre-commit Hook Service initialized'); } async installHook(projectPath: string, options: Partial<PreCommitHookOptions> = {}): Promise<boolean> { try { this.logger.info(`Installing pre-commit hook for: ${projectPath}`); // Check if it's a Git repository const gitDir = this.findGitDirectory(projectPath); if (!gitDir) { throw new Error('Not a Git repository'); } const hookPath = join(gitDir, 'hooks', 'pre-commit'); const hookConfig: HookConfig = { projectPath, hookPath, options: { enabled: true, blockOnErrors: true, blockOnWarnings: false, maxIssues: 100, excludePatterns: [ '**/node_modules/**', '**/dist/**', '**/build/**', '**/target/**', '**/.git/**', '**/*.min.js', '**/*.bundle.js', ], includePatterns: [ '**/*.{js,jsx,ts,tsx,py,java,c,cpp,go,rs}', '**/*.{h,hpp,cpp,cxx,cc}', ], autoFix: false, timeout: 30000, ...options } }; // Generate hook script const hookScript = this.generateHookScript(hookConfig); // Write hook file writeFileSync(hookPath, hookScript); // Make hook executable chmodSync(hookPath, '755'); // Store hook configuration this.hooks.set(projectPath, hookConfig); this.logger.info(`Pre-commit hook installed successfully at: ${hookPath}`); return true; } catch (error) { this.logger.error(`Error installing pre-commit hook for ${projectPath}:`, error); return false; } } async uninstallHook(projectPath: string): Promise<boolean> { try { this.logger.info(`Uninstalling pre-commit hook for: ${projectPath}`); const hookConfig = this.hooks.get(projectPath); if (!hookConfig) { this.logger.warn(`No pre-commit hook found for: ${projectPath}`); return false; } // Remove hook file if (existsSync(hookConfig.hookPath)) { unlinkSync(hookConfig.hookPath); } // Remove from hooks map this.hooks.delete(projectPath); this.logger.info(`Pre-commit hook uninstalled successfully for: ${projectPath}`); return true; } catch (error) { this.logger.error(`Error uninstalling pre-commit hook for ${projectPath}:`, error); return false; } } async runPreCommitCheck(projectPath: string, stagedFiles?: string[]): Promise<PreCommitResult> { try { this.logger.info(`Running pre-commit check for: ${projectPath}`); const hookConfig = this.hooks.get(projectPath); if (!hookConfig) { throw new Error('No pre-commit hook configured for this project'); } // Get staged files if not provided const filesToCheck = stagedFiles || await this.getStagedFiles(projectPath); // Filter files based on patterns const filteredFiles = this.filterFiles(filesToCheck, hookConfig.options); if (filteredFiles.length === 0) { return { success: true, issuesFound: 0, errorsFound: 0, warningsFound: 0, blocked: false, message: 'No files to check', results: [], fixedFiles: [] }; } this.logger.info(`Checking ${filteredFiles.length} files`); // Analyze files const results: AnalysisResult[] = []; const fixedFiles: string[] = []; for (const file of filteredFiles) { try { const result = await this.analysisService.analyzeFile(file, undefined, { exclude: hookConfig.options.excludePatterns }); results.push(result); // Apply auto-fix if enabled and there are fixable issues if (hookConfig.options.autoFix && result.issues.some(issue => issue.fix)) { const fixed = await this.applyAutoFix(file, result); if (fixed) { fixedFiles.push(file); } } } catch (error) { this.logger.warn(`Failed to analyze file ${file}:`, error); } } // Calculate statistics const totalIssues = results.reduce((sum, result) => sum + result.issues.length, 0); const errorsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.ERROR).length, 0); const warningsFound = results.reduce((sum, result) => sum + result.issues.filter(issue => issue.severity === SeverityLevel.WARNING).length, 0); // Determine if commit should be blocked let blocked = false; let message = 'Pre-commit check passed'; if (totalIssues > hookConfig.options.maxIssues) { blocked = true; message = `Too many issues found (${totalIssues} > ${hookConfig.options.maxIssues})`; } else if (hookConfig.options.blockOnErrors && errorsFound > 0) { blocked = true; message = `Found ${errorsFound} error(s) that must be fixed`; } else if (hookConfig.options.blockOnWarnings && warningsFound > 0) { blocked = true; message = `Found ${warningsFound} warning(s) that should be addressed`; } const result: PreCommitResult = { success: !blocked, issuesFound: totalIssues, errorsFound, warningsFound, blocked, message, results, fixedFiles }; this.logger.info(`Pre-commit check completed: ${message}`); return result; } catch (error) { this.logger.error(`Error running pre-commit check for ${projectPath}:`, error); return { success: false, issuesFound: 0, errorsFound: 0, warningsFound: 0, blocked: true, message: `Pre-commit check failed: ${error instanceof Error ? error.message : 'Unknown error'}`, results: [], fixedFiles: [] }; } } async getHookStatus(projectPath: string): Promise<any> { try { const hookConfig = this.hooks.get(projectPath); if (!hookConfig) { return { installed: false, message: 'No pre-commit hook installed' }; } const hookExists = existsSync(hookConfig.hookPath); return { installed: hookExists, hookPath: hookConfig.hookPath, options: hookConfig.options, message: hookExists ? 'Pre-commit hook is installed and active' : 'Hook configuration exists but file is missing' }; } catch (error) { this.logger.error(`Error getting hook status for ${projectPath}:`, error); return { installed: false, message: `Error checking hook status: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } async updateHookOptions(projectPath: string, options: Partial<PreCommitHookOptions>): Promise<boolean> { try { const hookConfig = this.hooks.get(projectPath); if (!hookConfig) { throw new Error('No pre-commit hook configured for this project'); } // Update options hookConfig.options = { ...hookConfig.options, ...options }; // Regenerate and reinstall hook const hookScript = this.generateHookScript(hookConfig); writeFileSync(hookConfig.hookPath, hookScript); chmodSync(hookConfig.hookPath, '755'); this.logger.info(`Pre-commit hook options updated for: ${projectPath}`); return true; } catch (error) { this.logger.error(`Error updating hook options for ${projectPath}:`, error); return false; } } private findGitDirectory(projectPath: string): string | null { try { let currentDir = projectPath; while (currentDir !== dirname(currentDir)) { if (existsSync(join(currentDir, '.git'))) { return currentDir; } currentDir = dirname(currentDir); } return null; } catch (error) { this.logger.error('Error finding Git directory:', error); return null; } } private generateHookScript(config: HookConfig): string { const serverPath = process.argv[0]; // Path to Node.js const scriptPath = process.argv[1]; // Path to this script return `#!/bin/bash # Pre-commit hook for static analysis # Generated by MCP Static Analysis Server echo "Running pre-commit static analysis..." # Get the root directory of the Git repository GIT_ROOT=$(git rev-parse --show-toplevel) # Run the pre-commit check NODE_PATH="${serverPath}" \\ "${scriptPath}" \\ --pre-commit \\ --project-path "${config.projectPath}" \\ --git-root "$GIT_ROOT" # Check the exit code if [ $? -ne 0 ]; then echo "❌ Pre-commit check failed. Please fix the issues before committing." exit 1 fi echo "✅ Pre-commit check passed." exit 0 `; } private async getStagedFiles(projectPath: string): Promise<string[]> { try { const result = execSync('git diff --cached --name-only', { cwd: projectPath, encoding: 'utf-8' }); return result.split('\n') .filter(line => line.trim()) .map(file => join(projectPath, file)); } catch (error) { this.logger.error('Error getting staged files:', error); return []; } } private filterFiles(files: string[], options: PreCommitHookOptions): string[] { try { const { minimatch } = require('minimatch'); return files.filter(file => { // Check include patterns if (options.includePatterns.length > 0) { const isIncluded = options.includePatterns.some(pattern => minimatch(file, pattern, { dot: true }) ); if (!isIncluded) return false; } // Check exclude patterns if (options.excludePatterns.length > 0) { const isExcluded = options.excludePatterns.some(pattern => minimatch(file, pattern, { dot: true }) ); if (isExcluded) return false; } // Check if file exists if (!existsSync(file)) return false; // Check if file is supported by our analyzers const language = this.languageDetector.detectLanguage(file); return this.languageDetector.isLanguageSupported(language); }); } catch (error) { this.logger.error('Error filtering files:', error); return []; } } private async applyAutoFix(filePath: string, result: AnalysisResult): Promise<boolean> { try { // This is a simplified auto-fix implementation // In a real implementation, you would apply the actual fixes suggested by the analyzers const fixableIssues = result.issues.filter(issue => issue.fix); if (fixableIssues.length === 0) { return false; } this.logger.info(`Applying auto-fixes to ${filePath}: ${fixableIssues.length} fixable issues`); // Read current file content const content = readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); // Apply fixes (simplified - in reality you'd need proper AST manipulation) for (const issue of fixableIssues) { if (issue.fix && issue.line > 0 && issue.line <= lines.length) { const lineIndex = issue.line - 1; // Simple replacement (this is very basic) if (issue.fix.replacement) { lines[lineIndex] = issue.fix.replacement; } } } // Write fixed content back writeFileSync(filePath, lines.join('\n')); // Stage the fixed file try { execSync(`git add "${filePath}"`, { cwd: dirname(filePath) }); } catch (gitError) { this.logger.warn(`Failed to stage fixed file ${filePath}:`, gitError); } return true; } catch (error) { this.logger.error(`Error applying auto-fix to ${filePath}:`, error); return false; } } getInstalledHooks(): any[] { return Array.from(this.hooks.entries()).map(([projectPath, config]) => ({ projectPath, hookPath: config.hookPath, options: config.options, installed: existsSync(config.hookPath) })); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cbunting99/mcp-code-analysis-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server