ccguard
Version:
Automated enforcement of net-negative LOC, complexity constraints, and quality standards for Claude code
546 lines (527 loc) • 26.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SnapshotHookProcessor = void 0;
const contracts_1 = require("../contracts");
const SnapshotManager_1 = require("../snapshot/SnapshotManager");
const RevertManager_1 = require("../snapshot/RevertManager");
const FileScanner_1 = require("../snapshot/FileScanner");
const ConfigLoader_1 = require("../config/ConfigLoader");
const GuardManager_1 = require("../ccguard/GuardManager");
const userPromptHandler_1 = require("./userPromptHandler");
class SnapshotHookProcessor {
storage;
snapshotManager;
revertManager;
fileScanner;
configLoader;
guardManager;
userPromptHandler;
rootDir;
constructor(deps) {
this.storage = deps.storage;
this.rootDir = deps.rootDir ?? process.cwd();
this.configLoader = deps.configLoader ?? new ConfigLoader_1.ConfigLoader();
const config = this.configLoader.getConfig();
this.snapshotManager = new SnapshotManager_1.SnapshotManager(this.rootDir, this.storage, config.enforcement.ignoreEmptyLines);
this.revertManager = new RevertManager_1.RevertManager(this.rootDir);
this.fileScanner = new FileScanner_1.FileScanner(this.rootDir, config.enforcement.ignoreEmptyLines);
this.guardManager = new GuardManager_1.GuardManager(this.storage, this.configLoader, this.rootDir);
this.userPromptHandler = new userPromptHandler_1.UserPromptHandler(this.guardManager);
}
async processHookData(inputData) {
try {
const parsedData = JSON.parse(inputData);
// Process user commands (on/off/status)
const commandResult = await this.userPromptHandler.processUserCommand(inputData);
if (commandResult) {
return commandResult;
}
// Check if guard is disabled
const disabledResult = await this.userPromptHandler.getDisabledResult();
if (disabledResult) {
return disabledResult;
}
// Parse hook data
const hookResult = contracts_1.HookDataSchema.safeParse(parsedData);
if (!hookResult.success) {
return {
decision: 'approve',
reason: 'No validation required',
};
}
const hookData = hookResult.data;
// Handle UserPromptSubmit to ensure baseline is initialized early
if (hookData.hook_event_name === 'UserPromptSubmit' && hookData.session_id) {
// Initialize baseline for new sessions
await this.snapshotManager.getBaseline(hookData.session_id);
return {
decision: 'approve',
reason: 'Session initialized',
};
}
// Only process file modification operations
if (!this.shouldValidateOperation(hookData)) {
return {
decision: 'approve',
reason: 'Operation does not modify files',
};
}
// Route to appropriate handler
if (hookData.hook_event_name === 'PreToolUse') {
return await this.handlePreToolUse(hookData);
}
else if (hookData.hook_event_name === 'PostToolUse') {
return await this.handlePostToolUse(hookData);
}
return {
decision: 'approve',
reason: 'No validation required - not a file operation',
};
}
catch (error) {
console.error('Error processing hook data:', error);
return {
decision: 'block',
reason: 'Error processing hook data. Please try again.',
};
}
}
async handlePreToolUse(hookData) {
try {
// Check if any files are locked first (applies to all modes)
const filePath = this.getFilePath(hookData);
if (filePath) {
const isLocked = await this.guardManager.isFileLocked(filePath);
if (isLocked) {
return {
decision: 'block',
reason: `File is locked and cannot be modified: ${filePath}\n\nTo unlock this file, use: ccguard unlock @${filePath}`,
};
}
}
// In snapshot mode, we don't need full pre-operation snapshots
if (this.isSnapshotMode()) {
// Ensure baseline exists for the session
await this.snapshotManager.getBaseline(hookData.session_id);
// Store minimal pre-state for potential revert
// We only need to store affected files for revert capability
const affectedFiles = this.fileScanner.getAffectedFiles(hookData);
const minimalSnapshot = await this.snapshotManager.takeOperationSnapshot(hookData.session_id, affectedFiles);
// Store only the minimal snapshot for revert
await this.storage.set(`snapshot:pre:${hookData.session_id}:minimal`, {
snapshot: {
...minimalSnapshot,
files: Array.from(minimalSnapshot.files.entries())
},
affectedFiles,
});
return {
decision: 'approve',
reason: 'Operation approved - will validate after completion',
};
}
// Cumulative mode: continue with existing logic
// Get affected files
const affectedFiles = this.fileScanner.getAffectedFiles(hookData);
// For operations that will create new files, we need to ensure baseline exists
// but we must initialize it BEFORE any files are created
await this.snapshotManager.getBaseline(hookData.session_id);
// Take a snapshot of current state (before operation)
// For new file operations, this won't include the new file yet
const snapshot = await this.snapshotManager.takeOperationSnapshot(hookData.session_id, affectedFiles);
// Convert Map to serializable format before storing
const serializableSnapshot = {
...snapshot,
files: Array.from(snapshot.files.entries())
};
// Store snapshot reference for PostToolUse
await this.storage.set(`snapshot:pre:${hookData.session_id}:latest`, {
snapshot: serializableSnapshot,
affectedFiles,
operation: hookData,
});
// Always approve PreToolUse (validation happens in PostToolUse)
return {
decision: 'approve',
reason: 'Operation approved - will validate after completion',
};
}
catch (error) {
console.error('Error in PreToolUse:', error);
return {
decision: 'approve',
reason: 'Pre-operation snapshot failed, but allowing operation',
};
}
}
async handlePostToolUse(hookData) {
try {
// Route to mode-specific handler
if (this.isSnapshotMode()) {
return await this.handleSnapshotModePostToolUse(hookData);
}
// Cumulative mode: continue with existing logic
// Get the pre-operation snapshot
const preData = await this.storage.get(`snapshot:pre:${hookData.session_id}:latest`);
if (!preData) {
// No pre-snapshot, can't validate
return {
decision: 'approve',
reason: 'No pre-operation snapshot available',
};
}
const { affectedFiles } = preData;
// Take post-operation snapshot
const postSnapshot = await this.snapshotManager.takePostOperationSnapshot(hookData.session_id, affectedFiles);
// Reconstruct the pre-operation snapshot with proper Map structure
const preSnapshot = {
...preData.snapshot,
files: new Map(preData.snapshot.files)
};
// Compare pre and post operation snapshots to get actual changes
const operationDiff = this.snapshotManager.compareSnapshots(preSnapshot, postSnapshot);
// Calculate lines added/removed from operation diff details
let linesAdded = 0;
let linesRemoved = 0;
for (const fileDiff of operationDiff.details.values()) {
if (fileDiff.delta > 0) {
linesAdded += fileDiff.delta;
}
else if (fileDiff.delta < 0) {
linesRemoved += Math.abs(fileDiff.delta);
}
}
// Get current session stats
const sessionStats = await this.guardManager.getSessionStats() || {
totalLinesAdded: 0,
totalLinesRemoved: 0,
netChange: 0,
operationCount: 0,
lastUpdated: new Date().toISOString()
};
// Calculate what the session totals would be after this operation
const projectedLinesAdded = sessionStats.totalLinesAdded + linesAdded;
const projectedLinesRemoved = sessionStats.totalLinesRemoved + linesRemoved;
const projectedNetChange = projectedLinesAdded - projectedLinesRemoved;
// Check threshold based on session stats
const config = this.configLoader.getConfig();
const threshold = config.thresholds?.allowedPositiveLines ?? 0;
const isHardLimit = config.enforcement.limitType !== 'soft';
if (projectedNetChange > threshold) {
// Threshold exceeded - handle based on limit type
if (isHardLimit) {
// Hard limit: revert changes
// If affectedFiles is empty, use the files that actually changed from operationDiff
const filesToRevert = affectedFiles.length > 0
? affectedFiles
: Array.from(operationDiff.details.keys());
const revertResult = await this.revertManager.revertToSnapshot(filesToRevert, preSnapshot);
if (!revertResult.success) {
return {
decision: 'block',
reason: `LOC threshold exceeded (session would have: +${projectedNetChange} lines, allowed: +${threshold} lines).\n\nFailed to revert: ${revertResult.error}\n\nPlease manually revert the changes.`,
};
}
// Track blocked operation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded,
linesRemoved,
netChange: operationDiff.locDelta,
decision: 'block',
reason: 'LOC threshold exceeded - changes reverted'
});
}
// Use baseline comparison for the error message (for display purposes)
const thresholdCheck = await this.snapshotManager.checkThreshold(hookData.session_id, postSnapshot, threshold);
return {
decision: 'block',
reason: this.createThresholdExceededMessage(thresholdCheck, threshold),
};
}
else {
// Soft limit: allow but provide guidance
// Update session stats even though limit exceeded
await this.guardManager.updateSessionStats(linesAdded, linesRemoved);
// Track soft limit violation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded,
linesRemoved,
netChange: operationDiff.locDelta,
decision: 'approve',
reason: 'Soft limit exceeded - operation allowed with warning'
});
}
// Create cumulative stats info for the warning message
const cumulativeInfo = {
baseline: sessionStats.totalLinesAdded - sessionStats.totalLinesRemoved,
current: projectedNetChange,
delta: projectedNetChange,
exceeded: true
};
return {
decision: 'approve',
reason: this.createSoftLimitExceededMessage(cumulativeInfo, threshold, operationDiff, sessionStats, projectedNetChange),
};
}
}
// Update last valid snapshot
this.snapshotManager.updateLastValidSnapshot(postSnapshot);
// Update session stats with the operation changes
await this.guardManager.updateSessionStats(linesAdded, linesRemoved);
// Get updated session stats
const updatedStats = await this.guardManager.getSessionStats();
// Track operation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded,
linesRemoved,
netChange: operationDiff.locDelta,
decision: 'approve',
reason: 'Operation completed successfully'
});
}
return {
decision: 'approve',
reason: `Operation completed successfully.\n\nLOC change: ${operationDiff.locDelta >= 0 ? '+' : ''}${operationDiff.locDelta} lines\nSession total: ${(updatedStats?.netChange ?? 0) >= 0 ? '+' : ''}${updatedStats?.netChange ?? 0} lines`,
};
}
catch (error) {
console.error('Error in PostToolUse:', error);
return {
decision: 'approve',
reason: 'Post-operation validation failed, but changes were already applied',
};
}
}
shouldValidateOperation(_hookData) {
// Validate all tools to track any file system changes
return true;
}
createThresholdExceededMessage(thresholdCheck, threshold) {
// Cumulative mode message (when changes are reverted)
return `Operation reverted: LOC threshold exceeded!
Session cumulative LOC status:
• Session start baseline: ${thresholdCheck.baseline} lines
• Would be after operation: ${thresholdCheck.current} lines
• Change from baseline: ${thresholdCheck.delta >= 0 ? '+' : ''}${thresholdCheck.delta} lines
• Allowed cumulative change: +${threshold} lines
The changes have been reverted to maintain the LOC limit.
Suggestions:
• Remove or refactor existing code before adding new features
• Use MultiEdit to batch additions with removals
• Consider if all new code is truly necessary
• Look for opportunities to consolidate duplicate code`;
}
createSoftLimitExceededMessage(thresholdCheck, threshold, operationDiff, sessionStats, projectedNetChange) {
// For cumulative mode, use session stats if provided
const isSessionMode = sessionStats !== undefined && projectedNetChange !== undefined;
const violationAmount = isSessionMode ? projectedNetChange - threshold : thresholdCheck.delta - threshold;
const entries = Array.from(operationDiff.details.entries());
const filesWithAdditions = entries
.filter(([, diff]) => diff.delta > 0)
.sort(([, a], [, b]) => b.delta - a.delta)
.slice(0, 3)
.map(([path, diff]) => ` • ${path}: +${diff.delta} lines`)
.join('\n');
if (isSessionMode) {
// Cumulative mode message
const sessionStart = 0; // Sessions start at 0
return `⚠️ SOFT LIMIT EXCEEDED - Operation completed with warning
LOC threshold violation detected:
• Session baseline: ${sessionStart} lines
• Current total: ${projectedNetChange} lines
• Net change: +${projectedNetChange} lines
• Allowed limit: +${threshold} lines
• Exceeded by: +${violationAmount} lines
Operation changes:
• This operation: ${operationDiff.locDelta >= 0 ? '+' : ''}${operationDiff.locDelta} lines
• Files modified: ${operationDiff.details.size}
${filesWithAdditions ? '\nLargest additions:\n' + filesWithAdditions : ''}
🎯 RECOMMENDED ACTIONS:
1. Consider refactoring to reduce the added lines
2. Look for opportunities to remove unused code
3. Review if all new functionality is essential
4. Use 'ccguard reset' if you need to reset session tracking
ℹ️ The operation was allowed to complete, but please address this violation to maintain code quality.`;
}
else {
// Snapshot mode message (original)
return `⚠️ SOFT LIMIT EXCEEDED - Operation completed with warning
LOC threshold violation detected:
• Session baseline: ${thresholdCheck.baseline} lines
• Current total: ${thresholdCheck.current} lines
• Net change: ${thresholdCheck.delta >= 0 ? '+' : ''}${thresholdCheck.delta} lines
• Allowed limit: +${threshold} lines
• Exceeded by: +${violationAmount} lines
Operation changes:
• This operation: ${operationDiff.locDelta >= 0 ? '+' : ''}${operationDiff.locDelta} lines
• Files modified: ${operationDiff.details.size}
${filesWithAdditions ? '\nLargest additions:\n' + filesWithAdditions : ''}
🎯 RECOMMENDED ACTIONS:
1. Consider refactoring to reduce the added lines
2. Look for opportunities to remove unused code
3. Review if all new functionality is essential
4. Use 'ccguard reset' if you need to reset session tracking
ℹ️ The operation was allowed to complete, but please address this violation to maintain code quality.`;
}
}
createSnapshotSoftLimitMessage(thresholdCheck) {
return `⚠️ SOFT LIMIT EXCEEDED - Operation completed with warning
Snapshot mode LOC threshold violation:
• Baseline threshold: ${thresholdCheck.baseline} lines
• Current LOC: ${thresholdCheck.current} lines
• Exceeded by: ${thresholdCheck.delta} lines
🎯 RECOMMENDED ACTIONS:
1. Refactor existing code to reduce overall LOC
2. Remove unused or redundant code
3. Consider if all additions are necessary
4. Run 'ccguard snapshot' to update the baseline if the increase is justified
ℹ️ The operation was allowed to complete, but the codebase now exceeds the baseline threshold.
In snapshot mode, the baseline represents your target maximum LOC.`;
}
/**
* Check if the system is in snapshot mode
*/
isSnapshotMode() {
const config = this.configLoader.getConfig();
return config.enforcement.strategy === 'snapshot';
}
/**
* Handle PostToolUse in snapshot mode
* Validates against baseline threshold instead of cumulative stats
*/
async handleSnapshotModePostToolUse(hookData) {
try {
// Get affected files from the operation
const affectedFiles = this.fileScanner.getAffectedFiles(hookData);
// Take current snapshot after operation
const currentSnapshot = await this.snapshotManager.takePostOperationSnapshot(hookData.session_id, affectedFiles);
// Check against baseline threshold
const thresholdCheck = await this.snapshotManager.checkSnapshotThreshold(hookData.session_id, currentSnapshot.totalLoc);
// Get config to check limit type
const config = this.configLoader.getConfig();
const isHardLimit = config.enforcement.limitType !== 'soft';
// If threshold exceeded, handle based on limit type
if (thresholdCheck.exceeded) {
if (isHardLimit) {
// Hard limit: revert changes
// Get the minimal pre-state for revert
const preData = await this.storage.get(`snapshot:pre:${hookData.session_id}:minimal`);
if (preData) {
// Reconstruct snapshot with Map
const preSnapshot = {
...preData.snapshot,
files: new Map(preData.snapshot.files)
};
// Revert to pre-operation state
const revertResult = await this.revertManager.revertToSnapshot(affectedFiles, preSnapshot);
if (!revertResult.success) {
return {
decision: 'block',
reason: `Operation reverted: LOC threshold exceeded!
Baseline threshold: ${thresholdCheck.baseline} lines
Current LOC: ${thresholdCheck.current} lines
Exceeded by: ${thresholdCheck.delta} lines
Failed to automatically revert: ${revertResult.error}
Please manually revert the changes.`
};
}
}
// Track blocked operation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded: 0, // In snapshot mode, we don't track individual operation changes
linesRemoved: 0,
netChange: thresholdCheck.delta,
decision: 'block',
reason: 'Snapshot threshold exceeded - changes reverted'
});
}
return {
decision: 'block',
reason: `Operation reverted: LOC threshold exceeded!
Baseline threshold: ${thresholdCheck.baseline} lines
Current LOC: ${thresholdCheck.current} lines
Exceeded by: ${thresholdCheck.delta} lines
The baseline threshold was set by 'ccguard snapshot'.
To update the threshold, run 'ccguard snapshot' again.`
};
}
else {
// Soft limit: allow but provide guidance
// Update last valid snapshot even though limit exceeded
await this.snapshotManager.updateLastValidSnapshot(currentSnapshot);
// Track soft limit violation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded: 0, // In snapshot mode, we track overall LOC
linesRemoved: 0,
netChange: thresholdCheck.delta,
decision: 'approve',
reason: 'Snapshot soft limit exceeded - operation allowed with warning'
});
}
// Clean up minimal snapshot storage
await this.storage.delete(`snapshot:pre:${hookData.session_id}:minimal`);
return {
decision: 'approve',
reason: this.createSnapshotSoftLimitMessage(thresholdCheck),
};
}
}
// Update last valid snapshot
await this.snapshotManager.updateLastValidSnapshot(currentSnapshot);
// Track approved operation in history
const toolInput = hookData.tool_input;
if (toolInput?.file_path) {
await this.guardManager.addOperationToHistory({
toolName: hookData.tool_name,
filePath: toolInput.file_path,
linesAdded: 0, // In snapshot mode, we track overall LOC
linesRemoved: 0,
netChange: 0, // No net change from baseline
decision: 'approve',
reason: 'Operation completed successfully within snapshot limits'
});
}
// Clean up minimal snapshot storage
await this.storage.delete(`snapshot:pre:${hookData.session_id}:minimal`);
return {
decision: 'approve',
reason: `Operation completed successfully.
Current LOC: ${thresholdCheck.current} lines
Baseline threshold: ${thresholdCheck.baseline} lines`
};
}
catch (error) {
console.error('Error in snapshot mode PostToolUse:', error);
return {
decision: 'approve',
reason: 'Post-operation validation failed, but changes were already applied',
};
}
}
getFilePath(hookData) {
const input = hookData.tool_input;
return input?.file_path ?? null;
}
}
exports.SnapshotHookProcessor = SnapshotHookProcessor;
//# sourceMappingURL=snapshotHookProcessor.js.map