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.
@@ -33,6 +33,7 @@ import { getVersion } from '../utils/package-info.js';
33
33
  import logger from '../utils/logger.js';
34
34
  import { getConfig } from '../config.js';
35
35
  import { recordMetric } from '../utils/metrics.js';
36
+ import { runLinters, displayLintResults, lintIssuesToAnalysisDetails } from '../utils/linter-runner.js';
36
37
 
37
38
  /**
38
39
  * Configuration loaded from lib/config.js
@@ -144,11 +145,45 @@ const main = async () => {
144
145
  process.exit(0);
145
146
  }
146
147
 
147
- // Step 3: Build file data using shared engine
148
+ // Step 3: Run linters (fast, deterministic before Claude analysis)
149
+ // Unfixable lint issues are forwarded to the judge for semantic resolution
150
+ let unfixableLintDetails = [];
151
+ if (config.linting?.enabled !== false) {
152
+ logger.info('🔍 Running linters...');
153
+ const lintStartTime = Date.now();
154
+ const filePaths = validFiles.map((f) => (typeof f === 'string' ? f : f.path));
155
+ const lintResult = runLinters(filePaths, config, presetName);
156
+ displayLintResults(lintResult);
157
+
158
+ // Record lint metric
159
+ recordMetric('linting.completed', {
160
+ preset: presetName,
161
+ fileCount: filePaths.length,
162
+ totalErrors: lintResult.totalErrors,
163
+ totalWarnings: lintResult.totalWarnings,
164
+ totalFixed: lintResult.totalFixed,
165
+ toolsRun: lintResult.results.filter((r) => !r.skipped).length,
166
+ toolsSkipped: lintResult.results.filter((r) => r.skipped).length,
167
+ duration: Date.now() - lintStartTime
168
+ }).catch((err) => {
169
+ logger.debug('pre-commit - main', 'Lint metrics recording failed', err);
170
+ });
171
+
172
+ // Forward all remaining lint issues (errors + warnings) to the judge
173
+ // The judge decides whether to fix, dismiss, or block — no hard exit here
174
+ if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) {
175
+ unfixableLintDetails = lintIssuesToAnalysisDetails(lintResult);
176
+ logger.debug('pre-commit - main', 'Forwarding unfixable lint issues to judge', {
177
+ count: unfixableLintDetails.length
178
+ });
179
+ }
180
+ }
181
+
182
+ // Step 4: Build file data using shared engine
148
183
  logger.debug('pre-commit - main', 'Building file data for analysis');
149
184
  const filesData = buildFilesData(validFiles, { staged: true });
150
185
 
151
- // Step 4: Log analysis configuration
186
+ // Step 5: Log analysis configuration
152
187
  logger.info(`Sending ${filesData.length} files for review...`);
153
188
 
154
189
  // Display analysis routing hint
@@ -156,51 +191,82 @@ const main = async () => {
156
191
  logger.info('⚡ Intelligent orchestration: grouping files and assigning models');
157
192
  }
158
193
 
159
- // Step 5: Run analysis using shared engine
194
+ // Step 6: Run analysis using shared engine
160
195
  const result = await runAnalysis(filesData, config, {
161
196
  hook: 'pre-commit',
162
197
  saveDebug: config.system.debug
163
198
  });
164
199
 
165
- // Step 6: Display results using shared function
200
+ // Step 7: Display results using shared function
166
201
  displayResults(result);
167
202
 
168
- // Step 6.5: Judge auto-fix all issues
203
+ // Step 8: Merge unfixable lint issues into analysis result for the judge
204
+ if (unfixableLintDetails.length > 0) {
205
+ if (!Array.isArray(result.details)) {
206
+ result.details = [];
207
+ }
208
+ result.details.push(...unfixableLintDetails);
209
+
210
+ // Update issue counts
211
+ for (const detail of unfixableLintDetails) {
212
+ const sev = (detail.severity || 'minor').toLowerCase();
213
+ if (result.issues[sev] !== undefined) {
214
+ result.issues[sev]++;
215
+ }
216
+ }
217
+
218
+ logger.info(`📋 ${unfixableLintDetails.length} unfixable lint issue(s) forwarded to judge`);
219
+ }
220
+
221
+ // Step 9: Judge — auto-fix all issues (Claude + lint)
169
222
  if (config.judge?.enabled !== false && hasAnyIssues(result)) {
223
+ let judgeModule;
170
224
  try {
171
- const { judgeAndFix } = await import('../utils/judge.js');
172
- const judgeResult = await judgeAndFix(result, filesData, config);
225
+ judgeModule = await import('../utils/judge.js');
226
+ } catch (importErr) {
227
+ logger.warning(`Judge module import failed: ${importErr.message}`);
228
+ logger.warning('Commit blocked — judge module unavailable');
229
+ result.QUALITY_GATE = 'FAILED';
230
+ result.approved = false;
231
+ judgeModule = null;
232
+ }
173
233
 
174
- // Update result with remaining issues (fixed + false positives removed)
175
- result.blockingIssues = judgeResult.remainingIssues.filter((i) =>
176
- ['blocker', 'critical'].includes((i.severity || '').toLowerCase())
177
- );
178
- result.details = judgeResult.remainingIssues;
179
-
180
- // Recalculate quality gate — pass only if ALL issues resolved
181
- if (judgeResult.remainingIssues.length === 0) {
182
- result.issues = {
183
- blocker: 0,
184
- critical: 0,
185
- major: 0,
186
- minor: 0,
187
- info: 0
188
- };
189
- result.QUALITY_GATE = 'PASSED';
190
- result.approved = true;
191
- } else {
234
+ if (judgeModule) {
235
+ try {
236
+ const { judgeAndFix } = judgeModule;
237
+ const judgeResult = await judgeAndFix(result, filesData, config);
238
+
239
+ // Update result with remaining issues (fixed + false positives removed)
240
+ result.blockingIssues = judgeResult.remainingIssues.filter((i) =>
241
+ ['blocker', 'critical'].includes((i.severity || '').toLowerCase())
242
+ );
243
+ result.details = judgeResult.remainingIssues;
244
+
245
+ // Recalculate quality gate — pass only if ALL issues resolved
246
+ if (judgeResult.remainingIssues.length === 0) {
247
+ result.issues = {
248
+ blocker: 0,
249
+ critical: 0,
250
+ major: 0,
251
+ minor: 0,
252
+ info: 0
253
+ };
254
+ result.QUALITY_GATE = 'PASSED';
255
+ result.approved = true;
256
+ } else {
257
+ result.QUALITY_GATE = 'FAILED';
258
+ result.approved = false;
259
+ }
260
+ } catch (err) {
261
+ logger.warning(`Judge execution failed: ${err.message}`);
262
+ logger.warning('Commit blocked — judge could not verify issues');
192
263
  result.QUALITY_GATE = 'FAILED';
193
264
  result.approved = false;
194
265
  }
195
- } catch (err) {
196
- logger.warning(`Judge failed: ${err.message}`);
197
- logger.warning('Commit blocked — judge could not verify issues');
198
- result.QUALITY_GATE = 'FAILED';
199
- result.approved = false;
200
266
  }
201
267
  }
202
268
 
203
- // Step 7: Check quality gate
269
+ // Step 10: Check quality gate
204
270
  const qualityGatePassed = result.QUALITY_GATE === 'PASSED';
205
271
  const approved = result.approved !== false;
206
272
 
@@ -15,7 +15,7 @@
15
15
  * - claude-diagnostics: Error detection and formatting
16
16
  */
17
17
 
18
- import { spawn, execSync } from 'child_process';
18
+ import { spawn, execSync, execFileSync } from 'child_process';
19
19
  import fs from 'fs/promises';
20
20
  import path from 'path';
21
21
  import os from 'os';
@@ -70,6 +70,37 @@ const isWSLAvailable = () => {
70
70
  }
71
71
  };
72
72
 
73
+ /**
74
+ * Get running WSL distros, excluding Docker-internal ones
75
+ * Why: The default WSL distro may be docker-desktop (which doesn't have Claude).
76
+ * We need to find the actual Linux distro (e.g., Ubuntu) where Claude is installed.
77
+ *
78
+ * @param {string} wslPath - Absolute path to wsl.exe
79
+ * @returns {string[]} List of running non-Docker distro names
80
+ */
81
+ export const getRunningWSLDistros = (wslPath) => {
82
+ try {
83
+ // Use execFileSync to bypass cmd.exe shell quoting — args passed directly to wsl.exe
84
+ const raw = execFileSync(wslPath, ['-l', '--running', '-q'], {
85
+ timeout: 5000,
86
+ stdio: ['pipe', 'pipe', 'ignore']
87
+ });
88
+ // Why UTF-16LE: wsl.exe is a native Windows executable that outputs Unicode in Windows' default
89
+ // encoding (UTF-16LE), not UTF-8. The null bytes (\0) are BOM artifacts and padding characters
90
+ // from the UTF-16LE encoding that must be stripped to get clean distro names.
91
+ const output = raw.toString('utf16le');
92
+ return output
93
+ .split(/\r?\n/)
94
+ .map((s) => s.replace(/\0/g, '').trim())
95
+ .filter((s) => s && !s.toLowerCase().includes('docker'));
96
+ } catch (e) {
97
+ logger.debug('claude-client - getRunningWSLDistros', 'Failed to list WSL distros', {
98
+ error: e.message
99
+ });
100
+ return [];
101
+ }
102
+ };
103
+
73
104
  /**
74
105
  * Get Claude command configuration for current platform
75
106
  * Why: On Windows, try native Claude first, then WSL as fallback
@@ -112,11 +143,12 @@ const getClaudeCommand = () => {
112
143
  // Node 24: Resolve wsl.exe absolute path to avoid shell: true
113
144
  const wslPath = which('wsl');
114
145
  if (wslPath) {
146
+ const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
147
+
148
+ // Strategy 1: Direct `wsl claude` (Claude in default non-interactive PATH)
149
+ // Use execFileSync to bypass cmd.exe — args passed directly to wsl.exe without quote mangling
115
150
  try {
116
- // Verify Claude exists in WSL
117
- // Increased timeout from 5s to 15s to handle system load better
118
- const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
119
- execSync(`"${wslPath}" claude --version`, {
151
+ execFileSync(wslPath, ['claude', '--version'], {
120
152
  stdio: 'ignore',
121
153
  timeout: wslCheckTimeout
122
154
  });
@@ -125,11 +157,9 @@ const getClaudeCommand = () => {
125
157
  });
126
158
  return { command: wslPath, args: ['claude'] };
127
159
  } catch (wslError) {
128
- // Differentiate error types for accurate user feedback
129
160
  const errorMsg = wslError.message || '';
130
- const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
131
161
 
132
- // Timeout: Transient system load issue
162
+ // Timeout: Transient system load issue — don't try fallback
133
163
  if (errorMsg.includes('ETIMEDOUT')) {
134
164
  throw new ClaudeClientError(
135
165
  'Timeout connecting to WSL - system under heavy load',
@@ -146,30 +176,132 @@ const getClaudeCommand = () => {
146
176
  );
147
177
  }
148
178
 
149
- // Not found: Claude CLI missing in WSL
150
- if (errorMsg.includes('ENOENT') || errorMsg.includes('command not found')) {
151
- throw new ClaudeClientError('Claude CLI not found in WSL', {
152
- context: {
153
- platform: 'Windows',
154
- wslPath,
155
- error: 'ENOENT',
156
- suggestion:
157
- 'Install Claude in WSL: wsl -e bash -c "npm install -g @anthropic-ai/claude-cli"'
158
- }
179
+ // Not a timeout try login-shell fallback below
180
+ logger.debug(
181
+ 'claude-client - getClaudeCommand',
182
+ 'Direct wsl claude failed, trying login-shell resolution',
183
+ { error: errorMsg }
184
+ );
185
+ }
186
+
187
+ // Strategy 2: WSL login-shell resolution (Claude installed via nvm or custom PATH)
188
+ // Why: `wsl claude` runs a non-interactive shell that doesn't source .bashrc/.profile,
189
+ // so Claude installed via nvm or user-scoped npm prefix won't be in PATH.
190
+ // `bash -lc "which claude"` sources the login profile to resolve the full path.
191
+ // Why execFileSync: cmd.exe strips inner quotes from `bash -lc "which claude"`,
192
+ // causing bash to receive `which` and `claude` as separate args. execFileSync bypasses
193
+ // cmd.exe and passes arguments directly, preserving multi-word argument boundaries.
194
+ try {
195
+ const resolvedPath = execFileSync(
196
+ wslPath,
197
+ ['bash', '-lc', 'which claude'],
198
+ {
199
+ encoding: 'utf8',
200
+ stdio: ['pipe', 'pipe', 'ignore'],
201
+ timeout: wslCheckTimeout
202
+ }
203
+ ).trim();
204
+
205
+ if (resolvedPath && resolvedPath.startsWith('/')) {
206
+ // Verify: run the resolved path via login shell (node may also require login shell)
207
+ execFileSync(wslPath, ['bash', '-lc', `${resolvedPath} --version`], {
208
+ stdio: 'ignore',
209
+ timeout: wslCheckTimeout
159
210
  });
211
+ logger.debug(
212
+ 'claude-client - getClaudeCommand',
213
+ 'Using WSL Claude CLI via login-shell resolution',
214
+ { wslPath, claudePath: resolvedPath }
215
+ );
216
+ // Return loginShell so executeClaude wraps the call in bash -lc
217
+ return { command: wslPath, args: [], loginShell: { path: resolvedPath } };
160
218
  }
219
+ } catch (loginShellError) {
220
+ logger.debug(
221
+ 'claude-client - getClaudeCommand',
222
+ 'WSL login-shell resolution failed',
223
+ { error: loginShellError.message }
224
+ );
225
+ }
161
226
 
162
- // Generic error: Other WSL issues
163
- throw new ClaudeClientError('Failed to verify Claude CLI in WSL', {
164
- context: {
165
- platform: 'Windows',
166
- wslPath,
167
- error: errorMsg,
168
- suggestion:
169
- 'Check WSL is functioning: wsl --version, or skip analysis: git commit --no-verify'
227
+ // Strategy 3: Non-default WSL distro (e.g., default is docker-desktop)
228
+ // Why: If the default WSL distro is docker-desktop or another minimal distro,
229
+ // Claude may be installed in a different distro like Ubuntu.
230
+ const distros = getRunningWSLDistros(wslPath);
231
+ logger.debug(
232
+ 'claude-client - getClaudeCommand',
233
+ 'Trying non-default WSL distros',
234
+ { distros }
235
+ );
236
+
237
+ for (const distro of distros) {
238
+ // Try direct call in this distro
239
+ try {
240
+ execFileSync(wslPath, ['-d', distro, 'claude', '--version'], {
241
+ stdio: 'ignore',
242
+ timeout: wslCheckTimeout
243
+ });
244
+ logger.debug(
245
+ 'claude-client - getClaudeCommand',
246
+ 'Using WSL Claude CLI in distro',
247
+ { wslPath, distro }
248
+ );
249
+ return { command: wslPath, args: ['-d', distro, 'claude'] };
250
+ } catch (distroError) {
251
+ // Try login-shell resolution in this distro
252
+ try {
253
+ const resolved = execFileSync(
254
+ wslPath,
255
+ ['-d', distro, 'bash', '-lc', 'which claude'],
256
+ {
257
+ encoding: 'utf8',
258
+ stdio: ['pipe', 'pipe', 'ignore'],
259
+ timeout: wslCheckTimeout
260
+ }
261
+ ).trim();
262
+
263
+ if (resolved && resolved.startsWith('/')) {
264
+ execFileSync(
265
+ wslPath,
266
+ ['-d', distro, 'bash', '-lc', `${resolved} --version`],
267
+ {
268
+ stdio: 'ignore',
269
+ timeout: wslCheckTimeout
270
+ }
271
+ );
272
+ logger.debug(
273
+ 'claude-client - getClaudeCommand',
274
+ 'Using WSL Claude CLI via login-shell in distro',
275
+ { wslPath, distro, claudePath: resolved }
276
+ );
277
+ // Return loginShell so executeClaude wraps the call in bash -lc
278
+ return {
279
+ command: wslPath,
280
+ args: ['-d', distro],
281
+ loginShell: { path: resolved }
282
+ };
283
+ }
284
+ } catch (distroLoginError) {
285
+ logger.debug(
286
+ 'claude-client - getClaudeCommand',
287
+ `Distro ${distro} does not have Claude`,
288
+ { error: distroLoginError.message }
289
+ );
170
290
  }
171
- });
291
+ }
172
292
  }
293
+
294
+ // All strategies failed
295
+ throw new ClaudeClientError('Claude CLI not found in WSL', {
296
+ context: {
297
+ platform: 'Windows',
298
+ wslPath,
299
+ error: 'Claude not found via direct call or login-shell resolution',
300
+ triedDistros: distros,
301
+ suggestion:
302
+ 'Install Claude in WSL: wsl -e bash -c "npm install -g @anthropic-ai/claude-cli"'
303
+ }
304
+ });
173
305
  }
174
306
 
175
307
  throw new ClaudeClientError('Claude CLI not found in Windows or WSL', {
@@ -212,18 +344,26 @@ const getClaudeCommand = () => {
212
344
  const executeClaude = (prompt, { timeout = 120000, allowedTools = [], model = null } = {}) =>
213
345
  new Promise((resolve, reject) => {
214
346
  // Get platform-specific command
215
- const { command, args } = getClaudeCommand();
347
+ const { command, args, loginShell } = getClaudeCommand();
216
348
 
217
- // Add allowed tools if specified (for MCP tools)
349
+ // Build final args handling differs for login-shell (WSL via bash -lc) vs direct invocation
218
350
  const finalArgs = [...args];
219
- if (allowedTools.length > 0) {
220
- // Format: --allowedTools "mcp__github__create_pull_request,mcp__github__get_file_contents"
221
- finalArgs.push('--allowedTools', allowedTools.join(','));
222
- }
223
-
224
- // Add model override if specified
225
- if (model) {
226
- finalArgs.push('--model', model);
351
+ if (loginShell) {
352
+ // Why: When claude is found via WSL login-shell (e.g. nvm-installed node), it must be
353
+ // executed inside bash --login so that .profile/.bashrc set up the correct PATH.
354
+ // All CLI flags are embedded in the bash -c command string (no spaces in flag values).
355
+ const claudeParts = [loginShell.path];
356
+ if (allowedTools.length > 0) claudeParts.push(`--allowedTools ${allowedTools.join(',')}`);
357
+ if (model) claudeParts.push(`--model ${model}`);
358
+ finalArgs.push('bash', '-lc', claudeParts.join(' '));
359
+ } else {
360
+ if (allowedTools.length > 0) {
361
+ // Format: --allowedTools "mcp__github__create_pull_request,mcp__github__get_file_contents"
362
+ finalArgs.push('--allowedTools', allowedTools.join(','));
363
+ }
364
+ if (model) {
365
+ finalArgs.push('--model', model);
366
+ }
227
367
  }
228
368
 
229
369
  // CRITICAL FIX: Windows .cmd/.bat file handling
@@ -182,17 +182,19 @@ const judgeAndFix = async (analysisResult, filesData, config) => {
182
182
  const resolvedIndices = new Set();
183
183
  const verdicts = [];
184
184
 
185
- for (let i = 0; i < fixes.length; i++) {
185
+ // Cap at allIssues.length LLM may hallucinate extra fixes beyond the input count
186
+ const fixCount = Math.min(fixes.length, allIssues.length);
187
+
188
+ if (fixes.length > allIssues.length) {
189
+ logger.debug('judge', `LLM returned ${fixes.length} fixes for ${allIssues.length} issues — ignoring ${fixes.length - allIssues.length} extra entries`);
190
+ }
191
+
192
+ for (let i = 0; i < fixCount; i++) {
186
193
  const verdict = fixes[i];
187
194
  const num = i + 1;
188
- // Find matching issue by index or use verdict data if index exceeds allIssues length
189
- const matchedIssue = i < allIssues.length ? allIssues[i] : null;
190
- const severity = matchedIssue
191
- ? (matchedIssue.severity || 'unknown').toUpperCase()
192
- : 'UNKNOWN';
193
- const desc = matchedIssue
194
- ? matchedIssue.description || matchedIssue.message || 'No description'
195
- : verdict.explanation || 'Unknown issue';
195
+ const matchedIssue = allIssues[i];
196
+ const severity = (matchedIssue.severity || 'unknown').toUpperCase();
197
+ const desc = matchedIssue.description || matchedIssue.message || 'No description';
196
198
 
197
199
  if (verdict.assessment === 'FALSE_POSITIVE') {
198
200
  falsePositiveCount++;