claude-git-hooks 2.33.0 → 2.34.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,443 @@
1
+ /**
2
+ * File: linter-runner.js
3
+ * Purpose: Linter orchestration and preset-to-linter mapping
4
+ *
5
+ * Defines available linter tools and maps them to presets.
6
+ * Runs applicable linters on a set of files, aggregates results,
7
+ * and reports with install instructions for missing tools.
8
+ *
9
+ * Used by:
10
+ * - lib/hooks/pre-commit.js (staged files)
11
+ * - lib/commands/lint.js (user-specified paths)
12
+ *
13
+ * Dependencies:
14
+ * - tool-runner: Generic tool execution infrastructure
15
+ * - logger: Debug and error logging
16
+ */
17
+
18
+ import path from 'path';
19
+ import {
20
+ isToolAvailable,
21
+ filterFilesByTool,
22
+ runToolWithAutoFix,
23
+ displayToolResult
24
+ } from './tool-runner.js';
25
+ import { getRepoRoot } from './git-operations.js';
26
+ import logger from './logger.js';
27
+
28
+ /**
29
+ * Parse ESLint JSON output into structured issues
30
+ *
31
+ * @param {string} stdout - ESLint JSON output
32
+ * @returns {{ errors: Array, warnings: Array }}
33
+ */
34
+ export function parseEslintOutput(stdout) {
35
+ const errors = [];
36
+ const warnings = [];
37
+
38
+ try {
39
+ const results = JSON.parse(stdout);
40
+
41
+ for (const fileResult of results) {
42
+ for (const msg of fileResult.messages || []) {
43
+ const issue = {
44
+ file: fileResult.filePath || '',
45
+ line: msg.line,
46
+ column: msg.column,
47
+ severity: msg.severity === 2 ? 'error' : 'warning',
48
+ message: msg.message,
49
+ ruleId: msg.ruleId || null
50
+ };
51
+
52
+ if (issue.severity === 'error') {
53
+ errors.push(issue);
54
+ } else {
55
+ warnings.push(issue);
56
+ }
57
+ }
58
+ }
59
+ } catch {
60
+ // ESLint output wasn't valid JSON — treat as raw error
61
+ if (stdout.trim()) {
62
+ errors.push({ file: '', severity: 'error', message: stdout.trim() });
63
+ }
64
+ }
65
+
66
+ return { errors, warnings };
67
+ }
68
+
69
+ /**
70
+ * Parse Spotless Maven output into structured issues
71
+ * Spotless check outputs file paths that need formatting
72
+ *
73
+ * @param {string} stdout - Maven stdout
74
+ * @returns {{ errors: Array, warnings: Array }}
75
+ */
76
+ export function parseSpotlessOutput(stdout) {
77
+ const errors = [];
78
+ const warnings = [];
79
+
80
+ // Spotless outputs lines like "The following files were not formatted:" followed by file paths
81
+ const lines = stdout.split('\n');
82
+ let inFileList = false;
83
+
84
+ for (const line of lines) {
85
+ const trimmed = line.trim();
86
+
87
+ if (trimmed.includes('The following files were not formatted')) {
88
+ inFileList = true;
89
+ continue;
90
+ }
91
+
92
+ if (inFileList && trimmed.length > 0 && !trimmed.startsWith('[')) {
93
+ errors.push({
94
+ file: trimmed,
95
+ severity: 'error',
96
+ message: 'File is not properly formatted'
97
+ });
98
+ }
99
+
100
+ // End of file list
101
+ if (inFileList && (trimmed.startsWith('[') || trimmed === '')) {
102
+ inFileList = false;
103
+ }
104
+ }
105
+
106
+ // If no specific files found but exit code was non-zero, generic error
107
+ if (errors.length === 0 && (stdout.includes('FAILED') || stdout.includes('BUILD FAILURE'))) {
108
+ errors.push({
109
+ file: '',
110
+ severity: 'error',
111
+ message: 'Spotless check failed — run mvn spotless:apply to fix'
112
+ });
113
+ }
114
+
115
+ return { errors, warnings };
116
+ }
117
+
118
+ /**
119
+ * Parse sqlfluff JSON output into structured issues
120
+ *
121
+ * @param {string} stdout - sqlfluff JSON output
122
+ * @returns {{ errors: Array, warnings: Array }}
123
+ */
124
+ export function parseSqlfluffOutput(stdout) {
125
+ const errors = [];
126
+ const warnings = [];
127
+
128
+ try {
129
+ const results = JSON.parse(stdout);
130
+
131
+ for (const fileResult of results) {
132
+ for (const violation of fileResult.violations || []) {
133
+ const issue = {
134
+ file: fileResult.filepath || '',
135
+ line: violation.start_line_no,
136
+ column: violation.start_line_pos,
137
+ severity: 'warning',
138
+ message: violation.description,
139
+ ruleId: violation.code || null
140
+ };
141
+
142
+ // sqlfluff L-rules are warnings, PRS/TMP are errors
143
+ if (violation.code && /^(PRS|TMP)/.test(violation.code)) {
144
+ issue.severity = 'error';
145
+ errors.push(issue);
146
+ } else {
147
+ warnings.push(issue);
148
+ }
149
+ }
150
+ }
151
+ } catch {
152
+ if (stdout.trim()) {
153
+ errors.push({ file: '', severity: 'error', message: stdout.trim() });
154
+ }
155
+ }
156
+
157
+ return { errors, warnings };
158
+ }
159
+
160
+ /**
161
+ * Convert file paths to Spotless-compatible regex pattern
162
+ * Spotless -DspotlessFiles accepts a regex matching absolute file paths
163
+ *
164
+ * @param {string[]} files - Relative file paths
165
+ * @returns {string} Regex pattern
166
+ */
167
+ export function filesToSpotlessRegex(files) {
168
+ const escaped = files.map((f) =>
169
+ f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\//g, '[\\\\/]')
170
+ );
171
+ return escaped.join('|');
172
+ }
173
+
174
+ /**
175
+ * Available linter tool definitions
176
+ * @type {Object<string, import('./tool-runner.js').ToolDefinition>}
177
+ */
178
+ export const LINTER_TOOLS = {
179
+ eslint: {
180
+ name: 'eslint',
181
+ command: 'npx',
182
+ args: (files) => ['eslint', '--format', 'json', '--no-error-on-unmatched-pattern', ...files],
183
+ fixArgs: (files) => ['eslint', '--fix', '--no-error-on-unmatched-pattern', ...files],
184
+ detectCommand: 'eslint',
185
+ detectInProjectFile: {
186
+ filename: 'package.json',
187
+ check: (content) => {
188
+ const pkg = JSON.parse(content);
189
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
190
+ return 'eslint' in allDeps;
191
+ }
192
+ },
193
+ installHint: 'npm install --save-dev eslint',
194
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
195
+ parseOutput: parseEslintOutput,
196
+ timeout: 30000
197
+ },
198
+ spotless: {
199
+ name: 'spotless',
200
+ command: 'mvn',
201
+ args: (files) => ['spotless:check', '-q', `-DspotlessFiles=${filesToSpotlessRegex(files)}`],
202
+ fixArgs: (files) => [
203
+ 'spotless:apply',
204
+ '-q',
205
+ `-DspotlessFiles=${filesToSpotlessRegex(files)}`
206
+ ],
207
+ detectCommand: 'mvn',
208
+ detectInProjectFile: {
209
+ filename: 'pom.xml',
210
+ check: (content) => content.includes('spotless-maven-plugin')
211
+ },
212
+ installHint: 'Add spotless-maven-plugin to pom.xml (see https://github.com/diffplug/spotless)',
213
+ extensions: ['.java'],
214
+ parseOutput: parseSpotlessOutput,
215
+ timeout: 60000
216
+ },
217
+ sqlfluff: {
218
+ name: 'sqlfluff',
219
+ command: 'sqlfluff',
220
+ args: (files) => ['lint', '--format', 'json', ...files],
221
+ fixArgs: (files) => ['fix', '--force', ...files],
222
+ detectCommand: 'sqlfluff',
223
+ installHint: 'pip install sqlfluff',
224
+ extensions: ['.sql'],
225
+ parseOutput: parseSqlfluffOutput,
226
+ timeout: 30000
227
+ }
228
+ };
229
+
230
+ /**
231
+ * Preset-to-linter mapping
232
+ * Each preset lists the linter tool names it uses
233
+ * @type {Object<string, string[]>}
234
+ */
235
+ export const PRESET_LINTERS = {
236
+ frontend: ['eslint'],
237
+ backend: ['spotless'],
238
+ fullstack: ['eslint', 'spotless'],
239
+ database: ['sqlfluff'],
240
+ ai: ['eslint'],
241
+ default: ['eslint']
242
+ };
243
+
244
+ /**
245
+ * Get linter tool definitions for a preset
246
+ *
247
+ * @param {string} presetName - Preset name
248
+ * @returns {import('./tool-runner.js').ToolDefinition[]} Tool definitions
249
+ */
250
+ export function getLinterToolsForPreset(presetName) {
251
+ const toolNames = PRESET_LINTERS[presetName] || PRESET_LINTERS.default;
252
+ return toolNames.map((name) => LINTER_TOOLS[name]).filter(Boolean);
253
+ }
254
+
255
+ /**
256
+ * Run all applicable linters on a set of files
257
+ *
258
+ * @param {string[]} files - File paths to lint
259
+ * @param {Object} config - Configuration object (from config.js)
260
+ * @param {string} [presetName] - Preset name (default: 'default')
261
+ * @returns {{ results: ToolRunResult[], totalErrors: number, totalWarnings: number, totalFixed: number }}
262
+ */
263
+ export function runLinters(files, config, presetName = 'default') {
264
+ const lintConfig = config.linting || {};
265
+ const autoFix = lintConfig.autoFix !== false;
266
+ const timeout = lintConfig.timeout || 30000;
267
+
268
+ const tools = getLinterToolsForPreset(presetName);
269
+ const results = [];
270
+
271
+ logger.debug('linter-runner - runLinters', 'Starting linters', {
272
+ preset: presetName,
273
+ tools: tools.map((t) => t.name),
274
+ fileCount: files.length,
275
+ autoFix
276
+ });
277
+
278
+ for (const toolDef of tools) {
279
+ // Filter files by tool's extensions
280
+ const matchingFiles = filterFilesByTool(files, toolDef);
281
+
282
+ if (matchingFiles.length === 0) {
283
+ logger.debug(
284
+ 'linter-runner - runLinters',
285
+ `No files for ${toolDef.name}, skipping`
286
+ );
287
+ continue;
288
+ }
289
+
290
+ // Check tool availability
291
+ const availability = isToolAvailable(toolDef);
292
+
293
+ if (!availability.available) {
294
+ // Use specific hint from availability check (e.g., "configured but mvn not in PATH")
295
+ // or fall back to the tool's generic installHint
296
+ const hint = availability.installHint || `Install with: ${toolDef.installHint}`;
297
+ results.push({
298
+ tool: toolDef.name,
299
+ skipped: true,
300
+ skipReason: hint,
301
+ errors: [],
302
+ warnings: [],
303
+ fixedCount: 0,
304
+ fixedFiles: []
305
+ });
306
+ continue;
307
+ }
308
+
309
+ // Run tool with optional auto-fix
310
+ const result = runToolWithAutoFix(toolDef, matchingFiles, {
311
+ autoFix,
312
+ timeout,
313
+ restage: true
314
+ });
315
+
316
+ results.push(result);
317
+ }
318
+
319
+ // Aggregate totals
320
+ const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0);
321
+ const totalWarnings = results.reduce((sum, r) => sum + r.warnings.length, 0);
322
+ const totalFixed = results.reduce((sum, r) => sum + r.fixedCount, 0);
323
+
324
+ logger.debug('linter-runner - runLinters', 'Linting complete', {
325
+ totalErrors,
326
+ totalWarnings,
327
+ totalFixed,
328
+ toolsRun: results.filter((r) => !r.skipped).length,
329
+ toolsSkipped: results.filter((r) => r.skipped).length
330
+ });
331
+
332
+ return { results, totalErrors, totalWarnings, totalFixed };
333
+ }
334
+
335
+ /**
336
+ * Display aggregated lint results
337
+ *
338
+ * @param {{ results: ToolRunResult[], totalErrors: number, totalWarnings: number, totalFixed: number }} lintResult
339
+ */
340
+ export function displayLintResults(lintResult) {
341
+ const { results, totalErrors, totalWarnings, totalFixed } = lintResult;
342
+
343
+ console.log('\n🔍 Linting results:');
344
+
345
+ for (const result of results) {
346
+ displayToolResult(result);
347
+ }
348
+
349
+ if (totalFixed > 0) {
350
+ console.log(`\n 🔧 Auto-fixed ${totalFixed} file(s) and re-staged`);
351
+ }
352
+
353
+ if (totalErrors > 0) {
354
+ console.log(`\n ❌ ${totalErrors} error(s) found`);
355
+ }
356
+
357
+ if (totalWarnings > 0 && totalErrors === 0) {
358
+ console.log(`\n ⚠️ ${totalWarnings} warning(s) — commit not blocked`);
359
+ }
360
+
361
+ if (totalErrors === 0 && totalWarnings === 0 && results.every((r) => !r.skipped)) {
362
+ console.log(' ✅ All linters passed');
363
+ }
364
+
365
+ console.log();
366
+ }
367
+
368
+ /**
369
+ * Convert remaining (unfixable) lint issues to Claude analysis detail format
370
+ * so they can be passed to the judge for semantic fixes.
371
+ *
372
+ * @param {{ results: ToolRunResult[], totalErrors: number, totalWarnings: number }} lintResult
373
+ * @returns {Array<Object>} Issues in Claude analysis detail format
374
+ */
375
+ export function lintIssuesToAnalysisDetails(lintResult) {
376
+ const details = [];
377
+ // Judge expects relative paths — ESLint outputs absolute paths
378
+ let repoRoot;
379
+ try {
380
+ repoRoot = getRepoRoot();
381
+ } catch {
382
+ repoRoot = process.cwd();
383
+ }
384
+
385
+ for (const result of lintResult.results) {
386
+ if (result.skipped) continue;
387
+
388
+ for (const issue of [...result.errors, ...result.warnings]) {
389
+ // Convert absolute paths to relative (judge prepends repoRoot)
390
+ let filePath = issue.file || '';
391
+ if (path.isAbsolute(filePath)) {
392
+ filePath = path.relative(repoRoot, filePath);
393
+ }
394
+
395
+ details.push({
396
+ severity: issue.severity === 'error' ? 'major' : 'minor',
397
+ type: 'lint',
398
+ category: 'lint',
399
+ file: filePath,
400
+ line: issue.line || null,
401
+ message: issue.message,
402
+ description: `[${result.tool}] ${issue.message}`,
403
+ rule: issue.ruleId || null,
404
+ source: 'linter'
405
+ });
406
+ }
407
+ }
408
+
409
+ return details;
410
+ }
411
+
412
+ /**
413
+ * Check linter availability for a preset and log install instructions
414
+ * Used during hook installation (informational only, does not block)
415
+ *
416
+ * @param {string} presetName - Preset name
417
+ */
418
+ export function checkLinterAvailability(presetName) {
419
+ const tools = getLinterToolsForPreset(presetName);
420
+
421
+ if (tools.length === 0) {
422
+ return;
423
+ }
424
+
425
+ console.log(`\n🔍 Checking linter availability for '${presetName}' preset...`);
426
+
427
+ for (const toolDef of tools) {
428
+ const availability = isToolAvailable(toolDef);
429
+
430
+ if (availability.available) {
431
+ console.log(` ✅ ${toolDef.name} — found`);
432
+ } else if (availability.installHint) {
433
+ // Configured in project but command binary missing
434
+ console.log(` ⚠️ ${toolDef.name} — skipped`);
435
+ console.log(` 💡 ${availability.installHint}`);
436
+ } else {
437
+ console.log(` ⚠️ ${toolDef.name} — not found`);
438
+ console.log(` 💡 Install with: ${toolDef.installHint}`);
439
+ }
440
+ }
441
+
442
+ console.log(' Linting will skip unavailable tools at commit time.\n');
443
+ }