/**
* ExecutionLogService - Tracks hook executions to prevent duplicate actions
*
* DESIGN PATTERNS:
* - Repository pattern: Abstracts data access to execution log
* - Query pattern: Provides efficient lookups for hook execution history
* - Singleton cache: In-memory cache for performance
*
* CODING STANDARDS:
* - Use instance methods for session-scoped operations
* - Handle file system errors gracefully
* - Optimize for performance with efficient data structures
*
* AVOID:
* - Loading entire log file into memory
* - Blocking I/O operations
* - Complex parsing logic (keep it simple)
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import * as crypto from 'node:crypto';
import type { LogEntry } from '../types';
/**
* Type guard for Node.js filesystem errors
*/
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
/**
* Log statistics returned by getStats method
*/
export interface LogStats {
totalEntries: number;
uniqueFiles: number;
}
/**
* Input parameters for logging a hook execution
*/
export interface LogExecutionParams {
sessionId: string;
filePath: string;
operation: string;
decision: string;
filePattern?: string;
/** File modification timestamp (mtime) at time of execution */
fileMtime?: number;
/** MD5 checksum of file content at time of execution */
fileChecksum?: string;
/** List of files generated by scaffold method execution */
generatedFiles?: readonly string[] | string[];
/** Unique scaffold execution ID (for tracking specific scaffold operations) */
scaffoldId?: string;
/** Project path where scaffold was executed */
projectPath?: string;
/** Name of the scaffold feature/method that was used */
featureName?: string;
}
/**
* Input parameters for checking if a hook execution has occurred
*/
export interface HasExecutedParams {
/** File path to check */
filePath: string;
/** Decision to check for (e.g., 'deny' means we already showed patterns) */
decision: string;
/** Optional file pattern to match */
filePattern?: string;
/** Optional project path to distinguish same patterns in different projects */
projectPath?: string;
}
/**
* Service for tracking hook executions using an append-only log
* Prevents duplicate hook actions (e.g., showing design patterns twice for same file)
* Each session has its own log file for isolation
*/
export class ExecutionLogService {
/** Log file path for this session - stored in system temp directory */
private readonly logFile: string;
/** In-memory cache of recent executions (last 1000 entries) */
private cache: LogEntry[] | null = null;
/** Max cache size to prevent memory bloat */
private static readonly MAX_CACHE_SIZE = 1000;
/** Session ID for this service instance */
private readonly sessionId: string;
/**
* Create a new ExecutionLogService instance for a specific session
* @param sessionId - Unique session identifier
*/
constructor(sessionId: string) {
this.sessionId = sessionId;
this.logFile = path.join(os.tmpdir(), `hook-adapter-executions-${sessionId}.jsonl`);
}
/**
* Check if a specific action was already taken for this file in this session
*
* NOTE: Uses fail-open strategy - on error, returns false to allow action.
* This ensures hooks can still provide guidance even if log access fails.
*
* @param params - Parameters for checking execution
* @returns true if the action was already taken, false on error (fail-open)
*/
async hasExecuted(params: HasExecutedParams): Promise<boolean> {
const { filePath, decision, filePattern, projectPath } = params;
try {
const entries = await this.loadLog();
// Search from end (most recent) for efficiency
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const matchedFile =
(entry.filePattern === filePattern && entry.filePattern && filePattern) ||
entry.filePath === filePath;
// Match project path if provided (allows same patterns in different projects)
const matchedProject = !projectPath || entry.projectPath === projectPath;
// Match file, decision, and project (session is already filtered by log file)
if (entry.decision === decision && matchedFile && matchedProject) {
return true;
}
}
return false;
} catch (error: unknown) {
// Fail-open: on error, return false to allow action
// This ensures hooks can still provide guidance even if log access fails
console.error(`Error checking execution for ${filePath}:`, error);
return false;
}
}
/**
* Log a hook execution
*
* NOTE: This method uses fail-silent strategy. Logging failures should never
* break hook execution since the hook's primary purpose is to provide guidance,
* not to persist data. The log is used for optimization (preventing duplicate
* guidance) rather than critical functionality.
*
* @param params - Log execution parameters (sessionId will be set automatically)
*/
async logExecution(params: Omit<LogExecutionParams, 'sessionId'>): Promise<void> {
const entry: LogEntry = {
timestamp: Date.now(),
sessionId: this.sessionId,
filePath: params.filePath,
operation: params.operation,
decision: params.decision,
filePattern: params.filePattern,
fileMtime: params.fileMtime,
fileChecksum: params.fileChecksum,
generatedFiles: params.generatedFiles,
scaffoldId: params.scaffoldId,
projectPath: params.projectPath,
featureName: params.featureName,
};
// Append to log file (JSONL format - one JSON object per line)
try {
await fs.appendFile(this.logFile, `${JSON.stringify(entry)}\n`, 'utf-8');
// Update cache
if (this.cache) {
this.cache.push(entry);
// Trim cache if too large
if (this.cache.length > ExecutionLogService.MAX_CACHE_SIZE) {
this.cache = this.cache.slice(-ExecutionLogService.MAX_CACHE_SIZE);
}
}
} catch (error) {
// Fail-silent: logging is non-critical - hooks should continue even if log fails
// This prevents disk full, permission issues, etc. from breaking hook functionality
console.error('Failed to log hook execution:', error);
}
}
/**
* Load execution log from file
* Uses in-memory cache for performance
*
* NOTE: Uses fail-silent strategy for non-ENOENT errors. The log is used for
* optimization (deduplication) rather than critical functionality. If the log
* cannot be read, returning empty allows hooks to continue with potentially
* duplicate guidance rather than failing entirely.
*/
async loadLog(): Promise<LogEntry[]> {
// Return cached data if available
if (this.cache !== null) {
return this.cache;
}
try {
// Read log file
const content = await fs.readFile(this.logFile, 'utf-8');
// Parse JSONL format
const lines = content.trim().split('\n').filter(Boolean);
const entries: LogEntry[] = [];
for (const line of lines) {
try {
entries.push(JSON.parse(line) as LogEntry);
} catch (parseError) {
// Skip malformed entries, log for debugging
console.warn('Skipping malformed log entry:', line.substring(0, 100));
}
}
// Keep only recent entries to prevent memory bloat
this.cache = entries.slice(-ExecutionLogService.MAX_CACHE_SIZE);
return this.cache;
} catch (error: unknown) {
// File doesn't exist yet - expected for new sessions
if (isNodeError(error) && error.code === 'ENOENT') {
this.cache = [];
return this.cache;
}
// Other errors - fail-silent to allow hooks to continue
// This prevents permission issues, disk errors from breaking functionality
console.error(`Failed to load execution log from ${this.logFile}:`, error);
this.cache = [];
return this.cache;
}
}
/**
* Clear the execution log (for testing)
* @throws Error if deletion fails for reasons other than file not existing
*/
async clearLog(): Promise<void> {
try {
await fs.unlink(this.logFile);
this.cache = [];
} catch (error: unknown) {
// File doesn't exist - already cleared, nothing to do
if (isNodeError(error) && error.code === 'ENOENT') {
this.cache = [];
return;
}
// Re-throw with context for unexpected errors
throw new Error(
`Failed to clear execution log at ${this.logFile}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
/**
* Get log statistics (for debugging)
*
* NOTE: Uses fail-open strategy - on error, returns zero counts.
* This is acceptable for debugging statistics which are non-critical.
*
* @returns Log statistics, or zeros on error (fail-open)
*/
async getStats(): Promise<LogStats> {
try {
const entries = await this.loadLog();
const files = new Set(entries.map((e) => e.filePath));
return {
totalEntries: entries.length,
uniqueFiles: files.size,
};
} catch (error: unknown) {
// Fail-open: return zeros for debugging stats on error
console.error('Error getting log stats:', error);
return {
totalEntries: 0,
uniqueFiles: 0,
};
}
}
/**
* Get file metadata (mtime and checksum) for a file
*
* @param filePath - Path to the file
* @returns File metadata or null if file doesn't exist
*/
async getFileMetadata(filePath: string): Promise<{ mtime: number; checksum: string } | null> {
try {
// Read file content first to compute checksum - this is the authoritative value
// for detecting changes. We get mtime after for optimization purposes.
// Note: There's a theoretical race between read and stat, but the checksum
// (computed from actual content read) is what we use for change detection.
const content = await fs.readFile(filePath, 'utf-8');
const checksum = crypto.createHash('md5').update(content).digest('hex');
const stats = await fs.stat(filePath);
return {
mtime: stats.mtimeMs,
checksum,
};
} catch (error: unknown) {
// Expected: file doesn't exist (ENOENT)
if (isNodeError(error) && error.code === 'ENOENT') {
return null;
}
// Unexpected errors - log for debugging
console.warn(`Failed to get file metadata for ${filePath}:`, error);
return null;
}
}
/**
* Check if a file has changed since the last execution for this session
* Returns true if the file should be reviewed (new file or content changed)
*
* NOTE: Uses fail-open strategy - on error, returns true to allow review.
* This ensures hooks can still provide value even if log access fails.
*
* @param filePath - File path to check
* @param decision - Decision type to check for
* @returns true if file has changed or no previous execution found, true on error (fail-open)
*/
async hasFileChanged(filePath: string, decision: string): Promise<boolean> {
try {
const entries = await this.loadLog();
// Find the most recent execution for this file/decision
let lastExecution: LogEntry | null = null;
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.filePath === filePath && entry.decision === decision) {
lastExecution = entry;
break;
}
}
// No previous execution - file should be reviewed
if (!lastExecution || !lastExecution.fileChecksum) {
return true;
}
// Get current file metadata
const currentMetadata = await this.getFileMetadata(filePath);
if (!currentMetadata) {
return true; // File doesn't exist, let hook handle it
}
// Compare checksum - if different, file has changed
return currentMetadata.checksum !== lastExecution.fileChecksum;
} catch (error: unknown) {
// Fail-open: on error, return true to allow review
// This ensures hooks can still provide value even if log access fails
console.error(`Error checking if file changed for ${filePath}:`, error);
return true;
}
}
/**
* Check if file was recently reviewed (within debounce window)
* Prevents noisy feedback during rapid successive edits
*
* NOTE: Uses fail-open strategy - on error, returns false to allow review.
* This ensures hooks can still provide value even if log access fails.
*
* @param filePath - File path to check
* @param debounceMs - Debounce window in milliseconds (default: 3000ms = 3 seconds)
* @returns true if file was reviewed within debounce window, false on error (fail-open)
*/
async wasRecentlyReviewed(filePath: string, debounceMs = 3000): Promise<boolean> {
try {
const entries = await this.loadLog();
const now = Date.now();
// Search from end (most recent) for efficiency
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
// Match file and check if it's a review operation (has fileMtime or fileChecksum)
// and has decision 'allow' or 'deny' (actual reviews, not skips)
const isReviewOperation = entry.fileMtime !== undefined || entry.fileChecksum !== undefined;
const isReviewDecision = entry.decision === 'allow' || entry.decision === 'deny';
if (entry.filePath === filePath && isReviewOperation && isReviewDecision) {
// Check if this review was recent (within debounce window)
const timeSinceLastReview = now - (entry.timestamp ?? 0);
if (timeSinceLastReview < debounceMs) {
return true;
}
// Found an entry but it's old enough - allow review
return false;
}
}
// No previous review found - allow review
return false;
} catch (error: unknown) {
// Fail-open: on error, allow review to proceed
// This ensures hooks can still provide value even if log access fails
console.error(`Error checking recent review for ${filePath}:`, error);
return false;
}
}
/**
* Check if a file was generated by a scaffold method
* Useful for hooks to avoid suggesting scaffold for files already created by scaffold
*
* NOTE: Uses fail-open strategy - on error, returns false to allow scaffold suggestion.
* Worst case: user sees scaffold suggestion for an already-scaffolded file.
*
* @param filePath - File path to check
* @returns true if file was generated by scaffold in this session, false on error (fail-open)
*/
async wasGeneratedByScaffold(filePath: string): Promise<boolean> {
try {
const entries = await this.loadLog();
// Search from end (most recent) for efficiency
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
// Only check scaffold operations
if (entry.operation === 'scaffold') {
// Check if this file is in the generatedFiles list
if (entry.generatedFiles && entry.generatedFiles.includes(filePath)) {
return true;
}
}
}
return false;
} catch (error: unknown) {
// Fail-open: on error, return false to allow scaffold suggestion
// Worst case is duplicate scaffold suggestion, which is acceptable
console.error(`Error checking if file was generated by scaffold for ${filePath}:`, error);
return false;
}
}
}