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