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.
- package/CHANGELOG.md +32 -0
- package/CLAUDE.md +53 -12
- package/bin/claude-hooks +1 -0
- package/lib/cli-metadata.js +9 -0
- package/lib/commands/helpers.js +69 -2
- package/lib/commands/install.js +24 -0
- package/lib/commands/lint.js +187 -0
- package/lib/config.js +7 -0
- package/lib/hooks/pre-commit.js +97 -31
- package/lib/utils/claude-client.js +177 -37
- package/lib/utils/judge.js +11 -9
- package/lib/utils/linter-runner.js +443 -0
- package/lib/utils/tool-runner.js +418 -0
- package/package.json +69 -69
- package/templates/config.advanced.example.json +38 -0
|
@@ -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
|
+
}
|