ccguard
Version:
Automated enforcement of net-negative LOC, complexity constraints, and quality standards for Claude code
490 lines • 18.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SnapshotManager = void 0;
const uuid_1 = require("uuid");
const FileScanner_1 = require("./FileScanner");
const fs_1 = require("fs");
const path_1 = require("path");
const os_1 = require("os");
// Debug logging - only enabled when CCGUARD_DEBUG environment variable is set
const DEBUG = process.env.CCGUARD_DEBUG === 'true' || process.env.CCGUARD_DEBUG === '1';
const debugLog = (message) => {
if (!DEBUG)
return;
const ccguardDir = (0, path_1.join)((0, os_1.homedir)(), '.ccguard');
const logPath = (0, path_1.join)(ccguardDir, 'debug.log');
// Ensure directory exists
(0, fs_1.mkdirSync)(ccguardDir, { recursive: true });
(0, fs_1.appendFileSync)(logPath, `${new Date().toISOString()} - ${JSON.stringify(message)}\n`);
};
class SnapshotManager {
fileScanner;
storage;
baselineSnapshot = null;
lastValidSnapshot = null;
// Storage key constants to avoid magic strings
static STORAGE_KEYS = {
baseline: (sessionId) => `snapshot:baseline:${sessionId}`,
baselineThreshold: (sessionId) => `snapshot:baseline:threshold:${sessionId}`,
current: (sessionId) => `snapshot:current:${sessionId}`,
};
constructor(rootDir, storage, ignoreEmptyLines = true) {
this.fileScanner = new FileScanner_1.FileScanner(rootDir, ignoreEmptyLines);
this.storage = storage;
}
/**
* Initialize or update baseline snapshot for the session
* In snapshot mode, this sets the LOC threshold
*/
async initializeBaseline(sessionId) {
debugLog({
event: 'initialize_baseline_start',
sessionId: sessionId,
});
const files = await this.fileScanner.scanProject();
const currentLoc = this.calculateTotalLoc(files);
debugLog({
event: 'project_scanned_for_baseline',
sessionId: sessionId,
fileCount: files.size,
filePaths: Array.from(files.keys()),
currentLoc: currentLoc,
});
const snapshot = {
id: (0, uuid_1.v4)(),
sessionId,
timestamp: new Date().toISOString(),
files,
totalLoc: currentLoc,
isBaseline: true,
};
debugLog({
event: 'baseline_created',
sessionId: sessionId,
snapshotId: snapshot.id,
totalLoc: snapshot.totalLoc,
timestamp: snapshot.timestamp,
isBaseline: snapshot.isBaseline,
});
this.baselineSnapshot = snapshot;
this.lastValidSnapshot = snapshot;
// Store baseline in storage (convert Map to serializable format)
await this.storage.set(SnapshotManager.STORAGE_KEYS.baseline(sessionId), this.toSerializable(snapshot));
// Store snapshot baseline threshold for snapshot mode
await this.storage.set(SnapshotManager.STORAGE_KEYS.baselineThreshold(sessionId), {
totalLoc: currentLoc,
timestamp: snapshot.timestamp,
snapshotId: snapshot.id,
});
// Also persist current state
await this.storage.set(SnapshotManager.STORAGE_KEYS.current(sessionId), {
totalLoc: currentLoc,
timestamp: snapshot.timestamp,
snapshotId: snapshot.id,
});
debugLog({
event: 'baseline_stored',
sessionId: sessionId,
storageKey: SnapshotManager.STORAGE_KEYS.baseline(sessionId),
thresholdStored: true,
});
return snapshot;
}
/**
* Get or create baseline snapshot
*/
async getBaseline(sessionId) {
debugLog({
event: 'get_baseline_start',
sessionId: sessionId,
hasInMemoryBaseline: !!this.baselineSnapshot,
inMemorySessionId: this.baselineSnapshot?.sessionId,
});
if (this.baselineSnapshot && this.baselineSnapshot.sessionId === sessionId) {
debugLog({
event: 'baseline_from_memory',
sessionId: sessionId,
snapshotId: this.baselineSnapshot.id,
});
return this.baselineSnapshot;
}
// Try to load from storage
const storageKey = SnapshotManager.STORAGE_KEYS.baseline(sessionId);
const stored = await this.storage.get(storageKey);
debugLog({
event: 'baseline_storage_check',
sessionId: sessionId,
storageKey: storageKey,
found: !!stored,
});
if (stored) {
// Convert from serialized format back to ProjectSnapshot
this.baselineSnapshot = this.fromSerializable(stored);
debugLog({
event: 'baseline_loaded_from_storage',
sessionId: sessionId,
snapshotId: this.baselineSnapshot.id,
totalLoc: this.baselineSnapshot.totalLoc,
});
return this.baselineSnapshot;
}
debugLog({
event: 'baseline_not_found_creating_new',
sessionId: sessionId,
});
// Create new baseline
return this.initializeBaseline(sessionId);
}
/**
* Take a lightweight snapshot of specific files before an operation
*/
async takeOperationSnapshot(sessionId, affectedFiles) {
// Always scan the current project state to get accurate pre-operation snapshot
// This ensures we capture the actual current state, not a stale baseline
const files = await this.fileScanner.scanProject();
const currentLoc = this.calculateTotalLoc(files);
// Verify against persisted state if available
const persistedLoc = await this.getCurrentValidLoc(sessionId);
if (persistedLoc !== null && persistedLoc !== currentLoc) {
debugLog({
event: 'loc_mismatch_detected',
sessionId: sessionId,
persistedLoc: persistedLoc,
actualLoc: currentLoc,
difference: currentLoc - persistedLoc,
warning: 'Correcting persisted state to match actual LOC',
});
// Correct the persisted state to match reality
await this.storage.set(SnapshotManager.STORAGE_KEYS.current(sessionId), {
totalLoc: currentLoc,
timestamp: new Date().toISOString(),
snapshotId: 'corrected-' + (0, uuid_1.v4)(),
correctedAt: new Date().toISOString(),
reason: 'loc_mismatch_correction'
});
}
const snapshot = {
id: (0, uuid_1.v4)(),
sessionId,
timestamp: new Date().toISOString(),
files,
totalLoc: currentLoc,
isBaseline: false,
};
debugLog({
event: 'operation_snapshot_taken',
sessionId: sessionId,
snapshotId: snapshot.id,
totalLoc: snapshot.totalLoc,
fileCount: files.size,
affectedFiles: affectedFiles,
method: 'full_scan',
persistedLoc: persistedLoc,
corrected: persistedLoc !== null && persistedLoc !== currentLoc,
});
return snapshot;
}
/**
* Take a snapshot after an operation completes
*/
async takePostOperationSnapshot(sessionId, affectedFiles) {
// If no specific files provided, scan the entire project
if (affectedFiles.length === 0) {
const files = await this.fileScanner.scanProject();
const snapshot = {
id: (0, uuid_1.v4)(),
sessionId,
timestamp: new Date().toISOString(),
files,
totalLoc: this.calculateTotalLoc(files),
isBaseline: false,
};
return snapshot;
}
// Restore last valid snapshot from storage if not already in memory
await this.restoreLastValidSnapshot(sessionId);
// Scan affected files plus any new files
const allFiles = [...new Set([
...affectedFiles,
...(await this.detectNewFiles(sessionId)),
])];
const updatedFiles = await this.fileScanner.scanFiles(allFiles);
// Start with last valid snapshot
const base = this.lastValidSnapshot || await this.getBaseline(sessionId);
const files = new Map(base.files);
// Update with scanned files
for (const [path, snapshot] of updatedFiles) {
files.set(path, snapshot);
}
// Remove deleted files
for (const path of affectedFiles) {
if (!updatedFiles.has(path)) {
files.delete(path);
}
}
const snapshot = {
id: (0, uuid_1.v4)(),
sessionId,
timestamp: new Date().toISOString(),
files,
totalLoc: this.calculateTotalLoc(files),
isBaseline: false,
};
return snapshot;
}
/**
* Update the last valid snapshot (after successful validation)
*/
async updateLastValidSnapshot(snapshot) {
debugLog({
event: 'update_last_valid_snapshot',
oldSnapshotId: this.lastValidSnapshot?.id,
newSnapshotId: snapshot.id,
sessionId: snapshot.sessionId,
totalLoc: snapshot.totalLoc,
});
this.lastValidSnapshot = snapshot;
// Persist the current valid state with version tracking
const stateData = {
totalLoc: snapshot.totalLoc,
timestamp: snapshot.timestamp,
snapshotId: snapshot.id,
version: Date.now(), // Add version for tracking updates
};
await this.storage.set(SnapshotManager.STORAGE_KEYS.current(snapshot.sessionId), stateData);
// Also store a backup of the last valid snapshot for recovery
await this.storage.set(`snapshot:lastvalid:${snapshot.sessionId}`, {
snapshot: this.toSerializable(snapshot),
updatedAt: new Date().toISOString(),
});
debugLog({
event: 'current_state_persisted',
sessionId: snapshot.sessionId,
totalLoc: snapshot.totalLoc,
storageKey: SnapshotManager.STORAGE_KEYS.current(snapshot.sessionId),
version: stateData.version,
});
}
/**
* Compare two snapshots and calculate the difference
*/
compareSnapshots(before, after) {
debugLog({
event: 'compare_snapshots_start',
beforeSnapshotId: before.id,
afterSnapshotId: after.id,
beforeTotalLoc: before.totalLoc,
afterTotalLoc: after.totalLoc,
beforeFileCount: before.files.size,
afterFileCount: after.files.size,
});
const added = [];
const removed = [];
const modified = [];
const details = new Map();
// Check for removed and modified files
for (const [path, beforeFile] of before.files) {
const afterFile = after.files.get(path);
if (!afterFile) {
removed.push(path);
details.set(path, {
before: beforeFile.locCount,
after: 0,
delta: -beforeFile.locCount,
});
}
else if (afterFile.hash !== beforeFile.hash) {
modified.push(path);
details.set(path, {
before: beforeFile.locCount,
after: afterFile.locCount,
delta: afterFile.locCount - beforeFile.locCount,
});
}
}
// Check for added files
for (const [path, afterFile] of after.files) {
if (!before.files.has(path)) {
added.push(path);
details.set(path, {
before: 0,
after: afterFile.locCount,
delta: afterFile.locCount,
});
}
}
const locDelta = after.totalLoc - before.totalLoc;
debugLog({
event: 'compare_snapshots_complete',
locDelta: locDelta,
filesAdded: added.length,
filesRemoved: removed.length,
filesModified: modified.length,
addedFiles: added,
removedFiles: removed,
modifiedFiles: modified,
fileDetailsCount: details.size,
});
return {
added,
removed,
modified,
locDelta,
details,
};
}
/**
* Check if current state exceeds threshold from baseline
* In snapshot mode, the baseline LOC IS the threshold
*/
async checkThreshold(sessionId, currentSnapshot, allowedPositiveLines) {
const baseline = await this.getBaseline(sessionId);
const delta = currentSnapshot.totalLoc - baseline.totalLoc;
return {
exceeded: delta > allowedPositiveLines,
current: currentSnapshot.totalLoc,
baseline: baseline.totalLoc,
delta,
};
}
/**
* Check if current LOC exceeds snapshot baseline threshold
* Used in snapshot mode where baseline LOC is the threshold
*/
async checkSnapshotThreshold(sessionId, currentLoc) {
// Get the baseline threshold for snapshot mode
const thresholdData = await this.storage.get(SnapshotManager.STORAGE_KEYS.baselineThreshold(sessionId));
if (!thresholdData || typeof thresholdData !== 'object' || !('totalLoc' in thresholdData)) {
// No baseline threshold set, initialize it
const baseline = await this.initializeBaseline(sessionId);
return {
exceeded: false,
current: currentLoc,
baseline: baseline.totalLoc,
delta: 0,
};
}
const baselineLoc = thresholdData.totalLoc;
const delta = currentLoc - baselineLoc;
debugLog({
event: 'snapshot_threshold_check',
sessionId: sessionId,
currentLoc: currentLoc,
baselineLoc: baselineLoc,
delta: delta,
exceeded: currentLoc > baselineLoc,
});
return {
exceeded: currentLoc > baselineLoc,
current: currentLoc,
baseline: baselineLoc,
delta,
};
}
/**
* Get the current snapshot baseline threshold
*/
async getSnapshotBaseline(sessionId) {
const thresholdData = await this.storage.get(SnapshotManager.STORAGE_KEYS.baselineThreshold(sessionId));
if (thresholdData && typeof thresholdData === 'object' && 'totalLoc' in thresholdData) {
return thresholdData.totalLoc;
}
return null;
}
/**
* Get last valid snapshot
*/
getLastValidSnapshot() {
return this.lastValidSnapshot;
}
/**
* Get or restore the current valid state for a session
*/
async getCurrentValidLoc(sessionId) {
const currentState = await this.storage.get(SnapshotManager.STORAGE_KEYS.current(sessionId));
if (currentState && typeof currentState === 'object' && 'totalLoc' in currentState) {
debugLog({
event: 'current_state_restored',
sessionId: sessionId,
totalLoc: currentState.totalLoc,
timestamp: currentState.timestamp,
});
return currentState.totalLoc;
}
return null;
}
/**
* Convert a ProjectSnapshot to a JSON-serializable format
*/
toSerializable(snapshot) {
return {
...snapshot,
files: Object.fromEntries(snapshot.files),
};
}
/**
* Convert a serialized snapshot back to ProjectSnapshot format
*/
fromSerializable(data) {
return {
...data,
files: new Map(Object.entries(data.files)),
};
}
/**
* Calculate total LOC from file snapshots
*/
calculateTotalLoc(files) {
let total = 0;
const fileLocs = {};
for (const [path, file] of files.entries()) {
total += file.locCount;
fileLocs[path] = file.locCount;
}
debugLog({
event: 'calculate_total_loc',
totalLoc: total,
fileCount: files.size,
topFiles: Object.entries(fileLocs)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([path, loc]) => ({ path, loc })),
});
return total;
}
/**
* Detect new files created since last snapshot
*/
async detectNewFiles(sessionId) {
const base = this.lastValidSnapshot || await this.getBaseline(sessionId);
const currentFiles = await this.fileScanner.scanProject();
const newFiles = [];
for (const path of currentFiles.keys()) {
if (!base.files.has(path)) {
newFiles.push(path);
}
}
return newFiles;
}
/**
* Restore last valid snapshot from storage if not already loaded
*/
async restoreLastValidSnapshot(sessionId) {
if (this.lastValidSnapshot) {
return;
}
const stored = await this.storage.get(`snapshot:lastvalid:${sessionId}`);
if (stored && typeof stored === 'object' && 'snapshot' in stored) {
this.lastValidSnapshot = this.fromSerializable(stored.snapshot);
debugLog({
event: 'last_valid_snapshot_restored',
sessionId: sessionId,
snapshotId: this.lastValidSnapshot.id,
totalLoc: this.lastValidSnapshot.totalLoc,
restoredFrom: 'storage',
});
}
}
}
exports.SnapshotManager = SnapshotManager;
//# sourceMappingURL=SnapshotManager.js.map