// 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)
}));
}
}