claude-git-hooks 1.5.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * File: file-operations.js
3
+ * Purpose: File system operations and content filtering
4
+ *
5
+ * Key responsibilities:
6
+ * - Read files with size validation
7
+ * - Filter SKIP-ANALYSIS patterns from code
8
+ * - Validate file extensions
9
+ * - Check file sizes
10
+ *
11
+ * Dependencies:
12
+ * - fs/promises: Async file operations
13
+ * - path: Cross-platform path handling
14
+ * - logger: Debug and error logging
15
+ */
16
+
17
+ import fs from 'fs/promises';
18
+ import fsSync from 'fs';
19
+ import path from 'path';
20
+ import logger from './logger.js';
21
+
22
+ /**
23
+ * Custom error for file operation failures
24
+ */
25
+ class FileOperationError extends Error {
26
+ constructor(message, { filePath, cause, context } = {}) {
27
+ super(message);
28
+ this.name = 'FileOperationError';
29
+ this.filePath = filePath;
30
+ this.cause = cause;
31
+ this.context = context;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Gets file size in bytes
37
+ * Why: Need to filter large files before reading to avoid memory issues
38
+ *
39
+ * @param {string} filePath - Path to file
40
+ * @returns {Promise<number>} File size in bytes
41
+ */
42
+ const getFileSize = async (filePath) => {
43
+ logger.debug('file-operations - getFileSize', 'Getting file size', { filePath });
44
+
45
+ try {
46
+ const stats = await fs.stat(filePath);
47
+ return stats.size;
48
+ } catch (error) {
49
+ throw new FileOperationError('Failed to get file size', {
50
+ filePath,
51
+ cause: error
52
+ });
53
+ }
54
+ };
55
+
56
+ /**
57
+ * Checks if file exists
58
+ *
59
+ * @param {string} filePath - Path to file
60
+ * @returns {Promise<boolean>} True if file exists
61
+ */
62
+ const fileExists = async (filePath) => {
63
+ try {
64
+ await fs.access(filePath);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ };
70
+
71
+ /**
72
+ * Reads file content with size check
73
+ * Why: Prevents loading huge files into memory that could cause OOM errors
74
+ *
75
+ * @param {string} filePath - Path to file
76
+ * @param {Object} options - Read options
77
+ * @param {number} options.maxSize - Maximum file size in bytes (default: 100000 = 100KB)
78
+ * @param {string} options.encoding - File encoding (default: 'utf8')
79
+ * @returns {Promise<string>} File content
80
+ * @throws {FileOperationError} If file too large or read fails
81
+ */
82
+ const readFile = async (filePath, { maxSize = 100000, encoding = 'utf8' } = {}) => {
83
+ logger.debug(
84
+ 'file-operations - readFile',
85
+ 'Reading file',
86
+ { filePath, maxSize, encoding }
87
+ );
88
+
89
+ try {
90
+ const size = await getFileSize(filePath);
91
+
92
+ // Why: Check size before reading to avoid loading huge files
93
+ if (size > maxSize) {
94
+ throw new FileOperationError('File exceeds maximum size', {
95
+ filePath,
96
+ context: { size, maxSize }
97
+ });
98
+ }
99
+
100
+ const content = await fs.readFile(filePath, encoding);
101
+
102
+ logger.debug(
103
+ 'file-operations - readFile',
104
+ 'File read successfully',
105
+ { filePath, contentLength: content.length }
106
+ );
107
+
108
+ return content;
109
+
110
+ } catch (error) {
111
+ if (error instanceof FileOperationError) {
112
+ throw error;
113
+ }
114
+
115
+ logger.error('file-operations - readFile', 'Failed to read file', error);
116
+ throw new FileOperationError('Failed to read file', {
117
+ filePath,
118
+ cause: error
119
+ });
120
+ }
121
+ };
122
+
123
+ /**
124
+ * Filters SKIP-ANALYSIS patterns from code content
125
+ * Why: Allows developers to exclude specific code from analysis
126
+ *
127
+ * Supports two patterns:
128
+ * 1. Single line: // SKIP-ANALYSIS (excludes next line)
129
+ * 2. Block: // SKIP_ANALYSIS_BLOCK ... // SKIP_ANALYSIS_BLOCK (excludes block)
130
+ *
131
+ * @param {string} content - File content to filter
132
+ * @returns {string} Filtered content
133
+ */
134
+ const filterSkipAnalysis = (content) => {
135
+ logger.debug(
136
+ 'file-operations - filterSkipAnalysis',
137
+ 'Filtering SKIP-ANALYSIS patterns',
138
+ { originalLength: content.length }
139
+ );
140
+
141
+ const lines = content.split('\n');
142
+ let skipNext = false;
143
+ let inSkipBlock = false;
144
+
145
+ // Why: Use map instead of filter to preserve line numbers for error reporting
146
+ // Empty lines maintain original line number mapping
147
+ const filteredLines = lines.map((line) => {
148
+ // Detect single-line SKIP-ANALYSIS
149
+ if (line.includes('// SKIP-ANALYSIS')) {
150
+ skipNext = true;
151
+ return ''; // Preserve line number
152
+ }
153
+
154
+ // Detect SKIP_ANALYSIS_BLOCK (toggle block state)
155
+ if (line.includes('// SKIP_ANALYSIS_BLOCK')) {
156
+ inSkipBlock = !inSkipBlock;
157
+ return ''; // Preserve line number
158
+ }
159
+
160
+ // Skip lines inside block
161
+ if (inSkipBlock) {
162
+ return '';
163
+ }
164
+
165
+ // Skip next line after single SKIP-ANALYSIS
166
+ if (skipNext) {
167
+ skipNext = false;
168
+ return '';
169
+ }
170
+
171
+ return line;
172
+ });
173
+
174
+ const filtered = filteredLines.join('\n');
175
+
176
+ logger.debug(
177
+ 'file-operations - filterSkipAnalysis',
178
+ 'Filtering complete',
179
+ {
180
+ originalLength: content.length,
181
+ filteredLength: filtered.length,
182
+ linesRemoved: lines.length - filteredLines.filter(l => l !== '').length
183
+ }
184
+ );
185
+
186
+ return filtered;
187
+ };
188
+
189
+ /**
190
+ * Validates file extension against allowed list
191
+ * Why: Pre-commit hook should only analyze specific file types
192
+ *
193
+ * @param {string} filePath - Path to file
194
+ * @param {Array<string>} allowedExtensions - Array of extensions (e.g., ['.java', '.xml'])
195
+ * @returns {boolean} True if extension is allowed
196
+ */
197
+ const hasAllowedExtension = (filePath, allowedExtensions = []) => {
198
+ if (allowedExtensions.length === 0) {
199
+ return true; // No filter, all allowed
200
+ }
201
+
202
+ const ext = path.extname(filePath).toLowerCase();
203
+ const isAllowed = allowedExtensions.some(allowed =>
204
+ ext === allowed.toLowerCase()
205
+ );
206
+
207
+ logger.debug(
208
+ 'file-operations - hasAllowedExtension',
209
+ 'Extension check',
210
+ { filePath, ext, allowedExtensions, isAllowed }
211
+ );
212
+
213
+ return isAllowed;
214
+ };
215
+
216
+ /**
217
+ * Filters files by size and extension
218
+ * Why: Batch validation of files before processing
219
+ *
220
+ * @param {Array<string>} files - Array of file paths
221
+ * @param {Object} options - Filter options
222
+ * @param {number} options.maxSize - Max file size in bytes
223
+ * @param {Array<string>} options.extensions - Allowed extensions
224
+ * @returns {Promise<Array<Object>>} Filtered files with metadata
225
+ *
226
+ * Returned object structure:
227
+ * [
228
+ * {
229
+ * path: string, // File path
230
+ * size: number, // Size in bytes
231
+ * valid: boolean, // Whether file passes filters
232
+ * reason: string // Why file was filtered (if valid=false)
233
+ * }
234
+ * ]
235
+ */
236
+ const filterFiles = async (files, { maxSize = 100000, extensions = [] } = {}) => {
237
+ logger.debug(
238
+ 'file-operations - filterFiles',
239
+ 'Filtering files',
240
+ { fileCount: files.length, maxSize, extensions }
241
+ );
242
+
243
+ const results = await Promise.allSettled(
244
+ files.map(async (filePath) => {
245
+ // Check extension first (fast)
246
+ if (!hasAllowedExtension(filePath, extensions)) {
247
+ return {
248
+ path: filePath,
249
+ size: 0,
250
+ valid: false,
251
+ reason: 'Extension not allowed'
252
+ };
253
+ }
254
+
255
+ // Check if file exists
256
+ const exists = await fileExists(filePath);
257
+ if (!exists) {
258
+ return {
259
+ path: filePath,
260
+ size: 0,
261
+ valid: false,
262
+ reason: 'File not found'
263
+ };
264
+ }
265
+
266
+ // Check size
267
+ try {
268
+ const size = await getFileSize(filePath);
269
+
270
+ if (size > maxSize) {
271
+ return {
272
+ path: filePath,
273
+ size,
274
+ valid: false,
275
+ reason: `File too large (${size} > ${maxSize})`
276
+ };
277
+ }
278
+
279
+ return {
280
+ path: filePath,
281
+ size,
282
+ valid: true,
283
+ reason: null
284
+ };
285
+
286
+ } catch (error) {
287
+ return {
288
+ path: filePath,
289
+ size: 0,
290
+ valid: false,
291
+ reason: `Error reading file: ${error.message}`
292
+ };
293
+ }
294
+ })
295
+ );
296
+
297
+ // Extract successful results
298
+ const fileMetadata = results
299
+ .filter(r => r.status === 'fulfilled')
300
+ .map(r => r.value);
301
+
302
+ const validCount = fileMetadata.filter(f => f.valid).length;
303
+
304
+ logger.debug(
305
+ 'file-operations - filterFiles',
306
+ 'Filtering complete',
307
+ {
308
+ totalFiles: files.length,
309
+ validFiles: validCount,
310
+ invalidFiles: fileMetadata.length - validCount
311
+ }
312
+ );
313
+
314
+ return fileMetadata;
315
+ };
316
+
317
+ /**
318
+ * Reads multiple files in parallel with filtering
319
+ * Why: Efficiently loads file contents for analysis
320
+ *
321
+ * @param {Array<string>} files - Array of file paths
322
+ * @param {Object} options - Read options
323
+ * @param {number} options.maxSize - Max file size
324
+ * @param {boolean} options.applySkipFilter - Apply SKIP-ANALYSIS filtering
325
+ * @returns {Promise<Array<Object>>} Files with content
326
+ *
327
+ * Returned object structure:
328
+ * [
329
+ * {
330
+ * path: string, // File path
331
+ * content: string, // File content (filtered if requested)
332
+ * size: number, // Original size in bytes
333
+ * error: Error // Error if read failed
334
+ * }
335
+ * ]
336
+ */
337
+ const readMultipleFiles = async (files, { maxSize = 100000, applySkipFilter = true } = {}) => {
338
+ logger.debug(
339
+ 'file-operations - readMultipleFiles',
340
+ 'Reading multiple files',
341
+ { fileCount: files.length, maxSize, applySkipFilter }
342
+ );
343
+
344
+ const results = await Promise.allSettled(
345
+ files.map(async (filePath) => {
346
+ try {
347
+ let content = await readFile(filePath, { maxSize });
348
+
349
+ // Apply SKIP-ANALYSIS filtering if requested
350
+ if (applySkipFilter) {
351
+ content = filterSkipAnalysis(content);
352
+ }
353
+
354
+ const size = await getFileSize(filePath);
355
+
356
+ return {
357
+ path: filePath,
358
+ content,
359
+ size,
360
+ error: null
361
+ };
362
+
363
+ } catch (error) {
364
+ logger.error(
365
+ 'file-operations - readMultipleFiles',
366
+ `Failed to read file: ${filePath}`,
367
+ error
368
+ );
369
+
370
+ return {
371
+ path: filePath,
372
+ content: null,
373
+ size: 0,
374
+ error
375
+ };
376
+ }
377
+ })
378
+ );
379
+
380
+ // Extract all results (both successful and failed)
381
+ const fileContents = results.map(r =>
382
+ r.status === 'fulfilled' ? r.value : r.reason
383
+ );
384
+
385
+ const successCount = fileContents.filter(f => f.error === null).length;
386
+
387
+ logger.debug(
388
+ 'file-operations - readMultipleFiles',
389
+ 'Reading complete',
390
+ {
391
+ totalFiles: files.length,
392
+ successfulReads: successCount,
393
+ failedReads: files.length - successCount
394
+ }
395
+ );
396
+
397
+ return fileContents;
398
+ };
399
+
400
+ export {
401
+ FileOperationError,
402
+ getFileSize,
403
+ fileExists,
404
+ readFile,
405
+ filterSkipAnalysis,
406
+ hasAllowedExtension,
407
+ filterFiles,
408
+ readMultipleFiles
409
+ };