claude-git-hooks 2.35.0 → 2.35.2

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 CHANGED
@@ -5,6 +5,43 @@ Todos los cambios notables en este proyecto se documentarán en este archivo.
5
5
  El formato está basado en [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.35.2] - 2026-04-13
9
+
10
+ ### ✨ Added
11
+ - Added Maven Wrapper (mvnw/mvnw.cmd) auto-detection for Spotless — bypasses cmd.exe metacharacter issues on Windows/WSL2
12
+ - Added per-file execution mode for Spotless to avoid pipe (`|`) metacharacter in regex arguments on Windows
13
+ - Added `env` option to `runTool`, `runToolFix`, and `runToolWithAutoFix` for passing extra environment variables to tool invocations
14
+ - Added `configDir` to `_detectInProjectFiles` return value for downstream Maven Wrapper resolution
15
+
16
+ ### 🐛 Fixed
17
+ - Fixed Spotless linter failing on Windows/WSL2 when multiple Java files are staged — `|` (pipe) in the `DspotlessFiles` regex was interpreted as a shell pipe by cmd.exe during batch script expansion
18
+
19
+
20
+ ## [2.35.1] - 2026-04-08
21
+
22
+ ### ✨ Added
23
+ - Local binary resolution for linter tools — walks up from config directory to repo root, preferring project-installed binaries over npx to prevent version mismatches (#123)
24
+ - Array-form `detectInProjectFile` for tool detection — Prettier and ESLint now recognized via config files (.prettierrc, .eslintrc.json, eslint.config.js, etc.) in addition to package.json dependencies
25
+ - WSL2/Windows interop support for .cmd/.bat tool binaries, with proper cmd.exe metacharacter escaping
26
+ - Source logging for linter tool resolution (remote/remote default/local) to make non-determinism visible
27
+ - New `localBin` and `toolCwd` options for `runTool`/`runToolFix`/`runToolWithAutoFix` enabling direct binary execution and correct plugin resolution in monorepos
28
+
29
+ ### 🔧 Changed
30
+ - Replaced `execSync` with `execFileSync` in tool-runner — args passed directly to process, preventing shell interpretation of regex metacharacters (e.g., `|` in Spotless patterns) and file path injection
31
+ - Tool detection now requires project-level configuration when `detectInProjectFile` is defined — PATH-only presence no longer sufficient, preventing false positives (e.g., mvn in PATH treated as Spotless available)
32
+ - Project file scanning starts from repo root instead of cwd, fixing detection when git hooks run from subdirectories
33
+ - Increased judge timeout from 120s to 180s
34
+
35
+ ### 🐛 Fixed
36
+ - Fixed tool auto-fix being attempted after execution failures (ENOENT, EACCES) by tracking `executionFailed` flag and skipping fix step
37
+ - Fixed ENOENT handling in `runToolFix` — missing command binary now returns gracefully instead of being misinterpreted as a partial fix
38
+ - Fixed `git add` in restage step to use `execFileSync` with proper argument array instead of shell string interpolation
39
+
40
+ ### 🔒 Security
41
+ - Eliminated shell injection risk in tool execution by switching from `execSync` (shell-based) to `execFileSync` (direct process spawn)
42
+ - Added `_escapeCmdMeta()` and `_prepareExec()` for safe cmd.exe argument passing when executing .cmd/.bat files on Windows/WSL
43
+
44
+
8
45
  ## [2.35.0] - 2026-03-27
9
46
 
10
47
  ### ✨ Added
@@ -28,7 +28,7 @@ import logger from './logger.js';
28
28
  import { recordMetric } from './metrics.js';
29
29
 
30
30
  const JUDGE_DEFAULT_MODEL = 'sonnet';
31
- const JUDGE_TIMEOUT = 120000;
31
+ const JUDGE_TIMEOUT = 180000;
32
32
 
33
33
  /**
34
34
  * Applies a single search/replace fix to a file and stages it
@@ -15,6 +15,8 @@
15
15
  * - logger: Debug and error logging
16
16
  */
17
17
 
18
+ import fs from 'fs';
19
+ import os from 'os';
18
20
  import path from 'path';
19
21
  import {
20
22
  isToolAvailable,
@@ -212,6 +214,36 @@ export function filesToSpotlessRegex(files) {
212
214
  return escaped.join('|');
213
215
  }
214
216
 
217
+ /**
218
+ * Find Maven Wrapper (mvnw/mvnw.cmd) near the pom.xml directory.
219
+ * Maven Wrapper invokes Java directly, bypassing cmd.exe — avoids
220
+ * metacharacter issues with | in command-line args on Windows/WSL2.
221
+ *
222
+ * @param {string} configDir - Directory containing pom.xml
223
+ * @returns {string|null} Path to mvnw/mvnw.cmd, or null if not found
224
+ */
225
+ function _findMavenWrapper(configDir) {
226
+ if (!configDir) return null;
227
+
228
+ // On Windows, prefer .cmd (executable) over extensionless (bash script).
229
+ // On Linux/macOS/WSL, prefer extensionless (native) over .cmd.
230
+ const candidates = os.platform() === 'win32'
231
+ ? [path.join(configDir, 'mvnw.cmd'), path.join(configDir, 'mvnw')]
232
+ : [path.join(configDir, 'mvnw'), path.join(configDir, 'mvnw.cmd')];
233
+
234
+ for (const candidate of candidates) {
235
+ if (fs.existsSync(candidate)) {
236
+ logger.debug('linter-runner - _findMavenWrapper',
237
+ 'Found Maven Wrapper', { path: candidate });
238
+ return candidate;
239
+ }
240
+ }
241
+
242
+ logger.debug('linter-runner - _findMavenWrapper',
243
+ 'No Maven Wrapper found', { configDir });
244
+ return null;
245
+ }
246
+
215
247
  /**
216
248
  * Available linter tool definitions
217
249
  * @type {Object<string, import('./tool-runner.js').ToolDefinition>}
@@ -223,14 +255,24 @@ export const LINTER_TOOLS = {
223
255
  args: (files) => ['prettier', '--check', ...files],
224
256
  fixArgs: (files) => ['prettier', '--write', ...files],
225
257
  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
- },
258
+ // Array form: multiple detection strategies. First match wins.
259
+ // Prettier may be a direct dep, or bundled via meta-frameworks,
260
+ // or only indicated by a config file.
261
+ detectInProjectFile: [
262
+ {
263
+ filename: 'package.json',
264
+ check: (content) => {
265
+ const pkg = JSON.parse(content);
266
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
267
+ return 'prettier' in allDeps;
268
+ }
269
+ },
270
+ { filename: '.prettierrc', check: () => true },
271
+ { filename: '.prettierrc.json', check: () => true },
272
+ { filename: '.prettierrc.js', check: () => true },
273
+ { filename: 'prettier.config.js', check: () => true },
274
+ { filename: 'prettier.config.mjs', check: () => true }
275
+ ],
234
276
  installHint: 'npm install --save-dev prettier',
235
277
  extensions: ['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.html', '.json', '.md', '.yaml', '.yml'],
236
278
  parseOutput: parsePrettierOutput,
@@ -242,14 +284,24 @@ export const LINTER_TOOLS = {
242
284
  args: (files) => ['eslint', '--format', 'json', '--no-error-on-unmatched-pattern', ...files],
243
285
  fixArgs: (files) => ['eslint', '--fix', '--no-error-on-unmatched-pattern', ...files],
244
286
  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
- },
287
+ // Array form: ESLint may be a direct dep, bundled via react-scripts/next,
288
+ // indicated by eslintConfig in package.json, or by a config file.
289
+ detectInProjectFile: [
290
+ {
291
+ filename: 'package.json',
292
+ check: (content) => {
293
+ const pkg = JSON.parse(content);
294
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
295
+ return 'eslint' in allDeps || 'eslintConfig' in pkg;
296
+ }
297
+ },
298
+ { filename: '.eslintrc.json', check: () => true },
299
+ { filename: '.eslintrc.js', check: () => true },
300
+ { filename: '.eslintrc.yml', check: () => true },
301
+ { filename: '.eslintrc', check: () => true },
302
+ { filename: 'eslint.config.js', check: () => true },
303
+ { filename: 'eslint.config.mjs', check: () => true }
304
+ ],
253
305
  installHint: 'npm install --save-dev eslint',
254
306
  extensions: ['.js', '.jsx', '.ts', '.tsx'],
255
307
  parseOutput: parseEslintOutput,
@@ -272,7 +324,14 @@ export const LINTER_TOOLS = {
272
324
  installHint: 'Add spotless-maven-plugin to pom.xml (see https://github.com/diffplug/spotless)',
273
325
  extensions: ['.java'],
274
326
  parseOutput: parseSpotlessOutput,
275
- timeout: 60000
327
+ timeout: 60000,
328
+ // Run per-file to avoid | (pipe) in regex on Windows.
329
+ // filesToSpotlessRegex joins files with | for regex alternation.
330
+ // cmd.exe interprets | as a pipe at every expansion level —
331
+ // ^ escaping is consumed by the first cmd.exe parse, and %VAR% expansion
332
+ // in batch scripts (mvn.cmd) re-exposes unescaped | to a second parse.
333
+ // Per-file execution ensures each regex has at most one file pattern (no |).
334
+ perFile: true
276
335
  },
277
336
  sqlfluff: {
278
337
  name: 'sqlfluff',
@@ -338,6 +397,13 @@ export async function getLinterToolsForPreset(presetName) {
338
397
  });
339
398
  }
340
399
 
400
+ // User-visible source logging — makes non-determinism visible when remote config
401
+ // flips between available and unavailable across invocations
402
+ const hasPresetInRemote = remoteConfig?.presetTools?.[presetName];
403
+ const hasDefaultInRemote = remoteConfig?.presetTools?.default;
404
+ const source = hasPresetInRemote ? 'remote' : hasDefaultInRemote ? 'remote default' : 'local';
405
+ logger.info(`Linter tools [${source}]: ${toolNames.join(', ')}`);
406
+
341
407
  return toolNames.map((name) => LINTER_TOOLS[name]).filter(Boolean);
342
408
  }
343
409
 
@@ -395,14 +461,53 @@ export async function runLinters(files, config, presetName = 'default') {
395
461
  continue;
396
462
  }
397
463
 
398
- // Run tool with optional auto-fix
399
- const result = runToolWithAutoFix(toolDef, matchingFiles, {
464
+ // Maven Wrapper: prefer mvnw when available (invokes Java directly, bypasses cmd.exe).
465
+ // Cannot reuse localBin — _prepareToolCall strips args[0] for npx tools,
466
+ // but Spotless args are Maven goals (spotless:check), not a tool name to strip.
467
+ // Shallow-clone toolDef with wrapper as command instead.
468
+ let effectiveToolDef = toolDef;
469
+ if (!availability.localBin && availability.configDir && toolDef.command === 'mvn') {
470
+ const wrapper = _findMavenWrapper(availability.configDir);
471
+ if (wrapper) {
472
+ effectiveToolDef = { ...toolDef, command: wrapper };
473
+ logger.info(`Using Maven Wrapper: ${path.basename(wrapper)}`);
474
+ }
475
+ }
476
+
477
+ const toolOpts = {
400
478
  autoFix,
401
479
  timeout,
402
- restage: true
403
- });
480
+ restage: true,
481
+ localBin: availability.localBin,
482
+ toolCwd: availability.configDir
483
+ };
484
+
485
+ // Per-file execution: run once per file to avoid | (pipe) in command args.
486
+ // On Windows, cmd.exe interprets | as a pipe at every expansion level —
487
+ // ^ escaping is consumed at the first parse, and %VAR% expansion in batch
488
+ // scripts (mvn.cmd) re-exposes unescaped | to a second parse.
489
+ // Running per-file ensures each invocation has a single-file regex (no |).
490
+ if (effectiveToolDef.perFile && matchingFiles.length > 1) {
491
+ logger.debug('linter-runner - runLinters',
492
+ `Running ${effectiveToolDef.name} per-file`, { fileCount: matchingFiles.length });
493
+
494
+ const perFileResults = matchingFiles.map((file) =>
495
+ runToolWithAutoFix(effectiveToolDef, [file], toolOpts)
496
+ );
404
497
 
405
- results.push(result);
498
+ results.push({
499
+ tool: effectiveToolDef.name,
500
+ skipped: false,
501
+ errors: perFileResults.flatMap((r) => r.errors),
502
+ warnings: perFileResults.flatMap((r) => r.warnings),
503
+ fixedCount: perFileResults.reduce((sum, r) => sum + r.fixedCount, 0),
504
+ fixedFiles: perFileResults.flatMap((r) => r.fixedFiles)
505
+ });
506
+ } else {
507
+ // Normal batch execution
508
+ const result = runToolWithAutoFix(effectiveToolDef, matchingFiles, toolOpts);
509
+ results.push(result);
510
+ }
406
511
  }
407
512
 
408
513
  // Aggregate totals
@@ -17,11 +17,13 @@
17
17
  * - logger: Debug and error logging
18
18
  */
19
19
 
20
- import { execSync } from 'child_process';
20
+ import { execFileSync } from 'child_process';
21
21
  import fs from 'fs';
22
+ import os from 'os';
22
23
  import path from 'path';
23
24
  import { which } from './which-command.js';
24
25
  import { walkDirectoryTree } from './file-utils.js';
26
+ import { getRepoRoot } from './git-operations.js';
25
27
  import logger from './logger.js';
26
28
 
27
29
  /**
@@ -31,11 +33,13 @@ import logger from './logger.js';
31
33
  * @property {function(string[]): string[]} args - Build args for check mode
32
34
  * @property {function(string[]): string[]} [fixArgs] - Build args for auto-fix mode
33
35
  * @property {string} detectCommand - Binary to check via which() (e.g., 'eslint')
36
+ * @property {Object|Object[]} [detectInProjectFile] - Project file indicator(s). Single { filename, check } or array of them. When defined, project file validation is mandatory — PATH alone is not sufficient.
34
37
  * @property {string} [detectFallback] - Fallback path to check (e.g., 'node_modules/.bin/eslint')
35
38
  * @property {string} installHint - Install instruction shown when tool is missing
36
39
  * @property {string[]} extensions - File extensions this tool handles
37
40
  * @property {function(string): Object} parseOutput - Parse stdout into structured results
38
41
  * @property {number} [timeout] - Per-tool timeout in ms (default from config)
42
+ * @property {function(string[]): Object} [getEnv] - Build extra env vars for invocation (e.g., MAVEN_OPTS for Spotless regex)
39
43
  */
40
44
 
41
45
  /**
@@ -62,38 +66,51 @@ import logger from './logger.js';
62
66
  /**
63
67
  * Check if a tool is available on the system
64
68
  *
69
+ * When detectInProjectFile is defined, the tool requires project-level
70
+ * configuration (e.g., spotless-maven-plugin in pom.xml, eslint in package.json).
71
+ * In that case, finding the command binary in PATH is necessary but NOT sufficient —
72
+ * the project must declare the tool. This prevents false positives like mvn in PATH
73
+ * being treated as "Spotless available" when the project has no Spotless plugin.
74
+ *
65
75
  * @param {ToolDefinition} toolDef - Tool definition
66
76
  * @returns {{ available: boolean, path: string|null }} Availability info
67
77
  */
68
78
  export function isToolAvailable(toolDef) {
69
- // Strategy 1: Check detectCommand in PATH (global install)
70
- const resolved = which(toolDef.detectCommand);
71
- if (resolved) {
72
- logger.debug('tool-runner - isToolAvailable', `Found ${toolDef.name} in PATH`, { path: resolved });
73
- return { available: true, path: resolved };
74
- }
75
-
76
- // Strategy 2: Check project files for the tool as a dependency
77
- // Why: local installs (npm install --save-dev) live in node_modules/.bin,
78
- // not in PATH. npx resolves them at runtime, so we just need to confirm
79
- // the tool is declared in a project file (package.json deps or pom.xml plugin).
80
- // Uses walkDirectoryTree (same walker as version-manager) to scan up to 3 levels.
79
+ // Strategy 1: Tools that require project-level configuration
80
+ // The detectInProjectFile field signals that detectCommand (e.g., 'mvn') is a
81
+ // build tool, not the tool itself. Project file validation is mandatory.
81
82
  if (toolDef.detectInProjectFile) {
82
83
  const found = _detectInProjectFiles(toolDef);
83
84
  if (found) {
84
- // Tool is configured but the command binary is missing
85
+ // Tool is configured but not runnable (binary missing or not installed locally)
85
86
  if (found.configured && !found.available) {
86
87
  logger.debug('tool-runner - isToolAvailable',
87
- `${toolDef.name} configured but '${found.missingCommand}' not in PATH`
88
+ `${toolDef.name} configured but not runnable`, {
89
+ missingCommand: found.missingCommand || null
90
+ }
88
91
  );
89
92
  return {
90
93
  available: false,
91
94
  path: null,
92
- installHint: `${toolDef.name} is configured in your project but '${found.missingCommand}' is not in PATH. Install it and ensure it's accessible from your terminal.`
95
+ configured: true,
96
+ installHint: found.installHint || `${toolDef.name} is configured in your project but '${found.missingCommand}' is not in PATH. Install it and ensure it's accessible from your terminal.`
93
97
  };
94
98
  }
95
99
  return found;
96
100
  }
101
+ // Project file not found or tool not declared → not available for this project
102
+ logger.debug('tool-runner - isToolAvailable',
103
+ `${toolDef.name} not configured in project files`
104
+ );
105
+ return { available: false, path: null };
106
+ }
107
+
108
+ // Strategy 2: Simple tools without project file requirements (e.g., sqlfluff)
109
+ // PATH detection is sufficient — the tool is standalone.
110
+ const resolved = which(toolDef.detectCommand);
111
+ if (resolved) {
112
+ logger.debug('tool-runner - isToolAvailable', `Found ${toolDef.name} in PATH`, { path: resolved });
113
+ return { available: true, path: resolved };
97
114
  }
98
115
 
99
116
  logger.debug('tool-runner - isToolAvailable', `${toolDef.name} not found`);
@@ -101,17 +118,81 @@ export function isToolAvailable(toolDef) {
101
118
  }
102
119
 
103
120
  /**
104
- * Scan project files (package.json, pom.xml) up to 3 levels deep
105
- * to detect if a tool is declared as a dependency or plugin.
121
+ * Find a tool binary in local node_modules/.bin, walking up from configDir to rootDir.
122
+ * Prevents npx from downloading or using a globally cached version that may be
123
+ * incompatible with the project's configuration format.
124
+ *
125
+ * @param {string} command - Binary name to find (e.g., 'eslint', 'prettier')
126
+ * @param {string} configDir - Directory where the tool's config was found
127
+ * @param {string} rootDir - Repo root (stop searching here)
128
+ * @returns {string|null} Path to local binary, or null if not found
129
+ */
130
+ function _findLocalBinary(command, configDir, rootDir) {
131
+ let dir = configDir;
132
+
133
+ while (dir) {
134
+ const binDir = path.join(dir, 'node_modules', '.bin');
135
+ // Check both extensionless and .cmd — WSL repos on Windows filesystem
136
+ // may have .cmd files from a Windows npm install
137
+ // On Windows, prefer .cmd (executable) over extensionless (bash script).
138
+ // On Linux/macOS, prefer extensionless (native) over .cmd.
139
+ const candidates = os.platform() === 'win32'
140
+ ? [path.join(binDir, `${command}.cmd`), path.join(binDir, command)]
141
+ : [path.join(binDir, command), path.join(binDir, `${command}.cmd`)];
142
+
143
+ for (const candidate of candidates) {
144
+ if (fs.existsSync(candidate)) {
145
+ return candidate;
146
+ }
147
+ }
148
+
149
+ // Stop at repo root
150
+ if (dir === rootDir) break;
151
+ const parent = path.dirname(dir);
152
+ if (parent === dir) break; // filesystem root
153
+ dir = parent;
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Scan project files (package.json, pom.xml, config files) up to 3 levels deep
161
+ * to detect if a tool is declared as a dependency, plugin, or has a config file.
162
+ *
163
+ * detectInProjectFile can be a single { filename, check } or an array of them.
164
+ * Array form allows multiple detection strategies (e.g., ESLint: check package.json
165
+ * deps, eslintConfig key, OR .eslintrc.json existence). First match wins.
106
166
  *
107
167
  * @param {ToolDefinition} toolDef - Tool definition with detectInProjectFile
108
168
  * @returns {{ available: boolean, path: string|null } | null} Result or null if not found
109
169
  */
110
170
  export function _detectInProjectFiles(toolDef) {
111
- const { filename, check } = toolDef.detectInProjectFile;
171
+ // Normalize to array supports both single object and array of indicators
172
+ const checks = Array.isArray(toolDef.detectInProjectFile)
173
+ ? toolDef.detectInProjectFile
174
+ : [toolDef.detectInProjectFile];
175
+
112
176
  let found = false;
177
+ let configDir = null;
178
+
179
+ // Use repo root instead of cwd — git hooks may run from subdirectories
180
+ // and pom.xml / package.json at repo root could be beyond maxDepth from a deep cwd.
181
+ let startDir;
182
+ try {
183
+ startDir = getRepoRoot();
184
+ } catch {
185
+ startDir = process.cwd();
186
+ }
187
+
188
+ logger.debug('tool-runner - _detectInProjectFiles', 'Scanning from directory', {
189
+ startDir,
190
+ cwd: process.cwd(),
191
+ usingRepoRoot: startDir !== process.cwd(),
192
+ filenames: checks.map((c) => c.filename)
193
+ });
113
194
 
114
- walkDirectoryTree(process.cwd(), {
195
+ walkDirectoryTree(startDir, {
115
196
  maxDepth: 3,
116
197
  ignoreSet: new Set([
117
198
  '.git', 'node_modules', 'target', 'build', 'dist',
@@ -119,17 +200,21 @@ export function _detectInProjectFiles(toolDef) {
119
200
  ]),
120
201
  onFile: (entry, fullPath) => {
121
202
  if (found) return; // short-circuit after first match
122
- if (entry.name === filename) {
123
- try {
124
- const content = fs.readFileSync(fullPath, 'utf8');
125
- if (check(content)) {
126
- logger.debug('tool-runner - _detectInProjectFiles',
127
- `Found ${toolDef.name} in ${path.relative(process.cwd(), fullPath)}`
128
- );
129
- found = true;
203
+ for (const { filename, check } of checks) {
204
+ if (entry.name === filename) {
205
+ try {
206
+ const content = fs.readFileSync(fullPath, 'utf8');
207
+ if (check(content)) {
208
+ logger.debug('tool-runner - _detectInProjectFiles',
209
+ `Found ${toolDef.name} in ${path.relative(startDir, fullPath)}`
210
+ );
211
+ found = true;
212
+ configDir = path.dirname(fullPath);
213
+ return;
214
+ }
215
+ } catch {
216
+ // File read error — skip
130
217
  }
131
- } catch {
132
- // File read error — skip
133
218
  }
134
219
  }
135
220
  }
@@ -140,24 +225,109 @@ export function _detectInProjectFiles(toolDef) {
140
225
  }
141
226
 
142
227
  // Project file declares the tool, but can we actually run the command?
143
- // npx-based tools (eslint, etc.) are resolved by npx at runtime — always OK.
144
- // Non-npx tools (mvn, sqlfluff, etc.) need the command binary in PATH.
145
- if (toolDef.command !== 'npx') {
146
- const cmdResolved = which(toolDef.command);
147
- if (!cmdResolved) {
228
+ if (toolDef.command === 'npx') {
229
+ // npx-based tools: verify the package is installed locally.
230
+ // Without this, npx may download or use a globally cached version
231
+ // that's incompatible with the project's config (e.g., ESLint v10
232
+ // when the project uses .eslintrc.* from v8).
233
+ const localBin = _findLocalBinary(toolDef.detectCommand, configDir, startDir);
234
+ if (localBin) {
148
235
  logger.debug('tool-runner - _detectInProjectFiles',
149
- `${toolDef.name} configured in project but '${toolDef.command}' not in PATH`
150
- );
151
- return {
152
- available: false,
153
- path: null,
154
- configured: true,
155
- missingCommand: toolDef.command
156
- };
236
+ `Found local ${toolDef.name} binary`, { path: localBin });
237
+ return { available: true, path: toolDef.command, localBin, configDir };
157
238
  }
239
+ logger.debug('tool-runner - _detectInProjectFiles',
240
+ `${toolDef.name} configured but not installed locally`);
241
+ return {
242
+ available: false,
243
+ path: null,
244
+ configured: true,
245
+ installHint: `${toolDef.name} config found but the package is not installed locally. Run: ${toolDef.installHint}`
246
+ };
247
+ }
248
+
249
+ // Non-npx tools (mvn, sqlfluff, etc.) need the command binary in PATH.
250
+ const cmdResolved = which(toolDef.command);
251
+ if (!cmdResolved) {
252
+ logger.debug('tool-runner - _detectInProjectFiles',
253
+ `${toolDef.name} configured in project but '${toolDef.command}' not in PATH`
254
+ );
255
+ return {
256
+ available: false,
257
+ path: null,
258
+ configured: true,
259
+ missingCommand: toolDef.command
260
+ };
261
+ }
262
+
263
+ return { available: true, path: toolDef.command, configDir };
264
+ }
265
+
266
+ /**
267
+ * Resolve a tool's command to an absolute path.
268
+ * execFileSync bypasses the shell, so commands like 'npx' or 'mvn' that live
269
+ * in nvm/shell-initialized paths won't be found via process.env.PATH alone.
270
+ * which() uses the platform shell (which/where) to resolve the full path.
271
+ *
272
+ * @param {string} command - Command name (e.g., 'npx', 'mvn', 'git')
273
+ * @returns {string} Resolved path, or original command as fallback
274
+ */
275
+ function _resolveCommand(command) {
276
+ const resolved = which(command);
277
+ if (resolved) {
278
+ logger.debug('tool-runner - _resolveCommand', `Resolved '${command}' to '${resolved}'`);
279
+ return resolved;
158
280
  }
281
+ logger.debug('tool-runner - _resolveCommand', `Could not resolve '${command}', using as-is`);
282
+ return command;
283
+ }
159
284
 
160
- return { available: true, path: toolDef.command };
285
+ /**
286
+ * Escape cmd.exe metacharacters with ^ so they are treated as literals.
287
+ * Required when passing args through cmd.exe /c (e.g., for .cmd/.bat files).
288
+ * Without escaping, characters like | are interpreted as pipes by cmd.exe.
289
+ *
290
+ * @param {string} arg - Single argument string
291
+ * @returns {string} Escaped argument safe for cmd.exe
292
+ */
293
+ function _escapeCmdMeta(arg) {
294
+ // ^ must be escaped first to avoid double-escaping
295
+ return arg.replace(/\^/g, '^^')
296
+ .replace(/%/g, '%%')
297
+ .replace(/&/g, '^&')
298
+ .replace(/\|/g, '^|')
299
+ .replace(/</g, '^<')
300
+ .replace(/>/g, '^>')
301
+ .replace(/\(/g, '^(')
302
+ .replace(/\)/g, '^)');
303
+ }
304
+
305
+ /**
306
+ * Prepare command and args for execFileSync when the resolved command is a
307
+ * .cmd/.bat file. These are batch scripts that must run through cmd.exe —
308
+ * execFileSync bypasses the shell, so direct execution throws EINVAL
309
+ * (CVE-2024-27980) on native Windows, or pipes args through cmd.exe on WSL
310
+ * where Windows interop auto-routes .cmd files.
311
+ *
312
+ * Detection is by file extension, NOT os.platform(), because WSL2 reports
313
+ * 'linux' but resolves Windows tools (npx.cmd, mvn.cmd) via PATH interop.
314
+ *
315
+ * Args are escaped for cmd.exe metacharacters (|, &, <, >, ^, (, ))
316
+ * to prevent shell interpretation (e.g., | in Spotless regex patterns).
317
+ *
318
+ * @param {string} command - Resolved command path
319
+ * @param {string[]} args - Command arguments
320
+ * @returns {{ command: string, args: string[] }} Adjusted for platform
321
+ */
322
+ function _prepareExec(command, args) {
323
+ if (/\.(cmd|bat)$/i.test(command)) {
324
+ logger.debug('tool-runner - _prepareExec', 'Wrapping .cmd with cmd.exe /c', { command });
325
+ return {
326
+ command: process.env.ComSpec || 'cmd.exe',
327
+ args: ['/c', command, ...args.map(_escapeCmdMeta)]
328
+ };
329
+ }
330
+ return { command, args };
161
331
  }
162
332
 
163
333
  /**
@@ -174,6 +344,58 @@ export function filterFilesByTool(files, toolDef) {
174
344
  });
175
345
  }
176
346
 
347
+ /**
348
+ * Resolve command, args, and cwd for a tool invocation.
349
+ * Shared by runTool and runToolFix to avoid duplication.
350
+ *
351
+ * When a local binary is provided, it replaces npx and the first arg (tool name)
352
+ * is stripped. When toolCwd is provided, files become absolute so they resolve
353
+ * correctly from the tool's config directory (needed for plugin resolution).
354
+ *
355
+ * @param {ToolDefinition} toolDef - Tool definition
356
+ * @param {string[]} rawArgs - Args from toolDef.args() or toolDef.fixArgs()
357
+ * @param {Object} options - Caller options (cwd, localBin, toolCwd)
358
+ * @param {string} cwd - Base working directory (repo root)
359
+ * @returns {{ exec: { command: string, args: string[] }, cwd: string, fullCommand: string }}
360
+ */
361
+ function _prepareToolCall(toolDef, rawArgs, options, cwd) {
362
+ let resolvedCommand, execArgs;
363
+ if (options.localBin) {
364
+ resolvedCommand = options.localBin;
365
+ // rawArgs[0] is the tool name (e.g. 'eslint') as passed to npx.
366
+ // Strip it since localBin IS the tool — the wrapper is not needed.
367
+ // Contract: toolDef.args() must always place the tool name first.
368
+ execArgs = rawArgs.slice(1);
369
+ logger.debug('tool-runner - _prepareToolCall', `Using local binary for ${toolDef.name}`, {
370
+ localBin: options.localBin,
371
+ toolCwd: options.toolCwd || null
372
+ });
373
+ } else {
374
+ resolvedCommand = _resolveCommand(toolDef.command);
375
+ execArgs = rawArgs;
376
+ }
377
+
378
+ const exec = _prepareExec(resolvedCommand, execArgs);
379
+ const effectiveCwd = options.toolCwd || cwd;
380
+ const fullCommand = [exec.command, ...exec.args].join(' ');
381
+
382
+ return { exec, cwd: effectiveCwd, fullCommand };
383
+ }
384
+
385
+ /**
386
+ * When toolCwd is set, convert repo-relative file paths to absolute so they
387
+ * resolve correctly from the tool's config directory.
388
+ *
389
+ * @param {string[]} files - File paths (may be repo-relative)
390
+ * @param {string} cwd - Repo root (base for resolving relative paths)
391
+ * @param {string} [toolCwd] - Tool's config directory
392
+ * @returns {string[]} Adjusted file paths
393
+ */
394
+ function _resolveFilePaths(files, cwd, toolCwd) {
395
+ if (!toolCwd) return files;
396
+ return files.map((f) => path.isAbsolute(f) ? f : path.resolve(cwd, f));
397
+ }
398
+
177
399
  /**
178
400
  * Execute a tool on files and parse output
179
401
  *
@@ -182,13 +404,17 @@ export function filterFilesByTool(files, toolDef) {
182
404
  * @param {Object} options - Execution options
183
405
  * @param {number} [options.timeout] - Timeout in ms
184
406
  * @param {string} [options.cwd] - Working directory
407
+ * @param {string} [options.localBin] - Local binary path (bypasses npx resolution)
408
+ * @param {string} [options.toolCwd] - Run tool from this directory (for plugin resolution)
409
+ * @param {Object} [options.env] - Extra environment variables (merged with process.env)
185
410
  * @returns {ToolRunResult} Parsed result
186
411
  */
187
412
  export function runTool(toolDef, files, options = {}) {
188
413
  const { timeout = toolDef.timeout || 30000, cwd = process.cwd() } = options;
189
414
 
190
- const args = toolDef.args(files);
191
- const fullCommand = [toolDef.command, ...args].join(' ');
415
+ const effectiveFiles = _resolveFilePaths(files, cwd, options.toolCwd);
416
+ const rawArgs = toolDef.args(effectiveFiles);
417
+ const { exec, cwd: effectiveCwd, fullCommand } = _prepareToolCall(toolDef, rawArgs, options, cwd);
192
418
 
193
419
  logger.debug('tool-runner - runTool', `Executing ${toolDef.name}`, {
194
420
  command: fullCommand,
@@ -205,13 +431,23 @@ export function runTool(toolDef, files, options = {}) {
205
431
  fixedFiles: []
206
432
  };
207
433
 
434
+ // Build execFileSync options. When tool provides env overrides (e.g., MAVEN_OPTS
435
+ // for Spotless regex), merge them with process.env so PATH and other vars are preserved.
436
+ const execOptions = {
437
+ encoding: 'utf8',
438
+ timeout,
439
+ cwd: effectiveCwd,
440
+ stdio: ['pipe', 'pipe', 'pipe']
441
+ };
442
+ if (options.env) {
443
+ execOptions.env = { ...process.env, ...options.env };
444
+ }
445
+
208
446
  try {
209
- const stdout = execSync(fullCommand, {
210
- encoding: 'utf8',
211
- timeout,
212
- cwd,
213
- stdio: ['pipe', 'pipe', 'pipe']
214
- });
447
+ // execFileSync bypasses shell — args passed directly to process.
448
+ // Prevents shell interpretation of regex metacharacters (e.g., | in Spotless regex)
449
+ // and eliminates shell injection risk from file paths.
450
+ const stdout = execFileSync(exec.command, exec.args, execOptions);
215
451
 
216
452
  // Exit code 0 = no issues; parse anyway for warnings
217
453
  if (toolDef.parseOutput && stdout.trim()) {
@@ -234,13 +470,16 @@ export function runTool(toolDef, files, options = {}) {
234
470
  message: `${toolDef.name} timed out after ${timeout / 1000}s`
235
471
  });
236
472
  } else {
237
- // Genuine execution error
473
+ // Genuine execution error (ENOENT, EACCES, etc.)
238
474
  const stderr = err.stderr ? err.stderr.toString().trim() : '';
239
475
  logger.debug('tool-runner - runTool', `${toolDef.name} execution error`, {
240
476
  exitCode: err.status,
477
+ code: err.code,
241
478
  stderr
242
479
  });
243
480
 
481
+ result.executionFailed = true;
482
+
244
483
  // If no parseable output, treat as tool error
245
484
  if (!err.stdout) {
246
485
  result.errors.push({
@@ -269,6 +508,9 @@ export function runTool(toolDef, files, options = {}) {
269
508
  * @param {number} [options.timeout] - Timeout in ms
270
509
  * @param {string} [options.cwd] - Working directory
271
510
  * @param {boolean} [options.restage] - Re-stage fixed files (default: true)
511
+ * @param {string} [options.localBin] - Local binary path (bypasses npx resolution)
512
+ * @param {string} [options.toolCwd] - Run tool from this directory (for plugin resolution)
513
+ * @param {Object} [options.env] - Extra environment variables (merged with process.env)
272
514
  * @returns {{ fixedFiles: string[], fixedCount: number }} Fix results
273
515
  */
274
516
  export function runToolFix(toolDef, files, options = {}) {
@@ -279,26 +521,37 @@ export function runToolFix(toolDef, files, options = {}) {
279
521
  return { fixedFiles: [], fixedCount: 0 };
280
522
  }
281
523
 
282
- const args = toolDef.fixArgs(files);
283
- const fullCommand = [toolDef.command, ...args].join(' ');
524
+ const effectiveFiles = _resolveFilePaths(files, cwd, options.toolCwd);
525
+ const rawArgs = toolDef.fixArgs(effectiveFiles);
526
+ const { exec, cwd: effectiveCwd, fullCommand } = _prepareToolCall(toolDef, rawArgs, options, cwd);
284
527
 
285
528
  logger.debug('tool-runner - runToolFix', `Auto-fixing with ${toolDef.name}`, {
286
529
  command: fullCommand,
287
530
  fileCount: files.length
288
531
  });
289
532
 
533
+ const execOptions = {
534
+ encoding: 'utf8',
535
+ timeout,
536
+ cwd: effectiveCwd,
537
+ stdio: ['pipe', 'pipe', 'pipe']
538
+ };
539
+ if (options.env) {
540
+ execOptions.env = { ...process.env, ...options.env };
541
+ }
542
+
290
543
  try {
291
- execSync(fullCommand, {
292
- encoding: 'utf8',
293
- timeout,
294
- cwd,
295
- stdio: ['pipe', 'pipe', 'pipe']
296
- });
544
+ execFileSync(exec.command, exec.args, execOptions);
297
545
  } catch (err) {
298
546
  if (err.killed) {
299
547
  logger.warning(`${toolDef.name} auto-fix timed out`);
300
548
  return { fixedFiles: [], fixedCount: 0 };
301
549
  }
550
+ // ENOENT = command binary not found (process never started, err.status is null)
551
+ if (err.code === 'ENOENT') {
552
+ logger.debug('tool-runner - runToolFix', `${toolDef.name} not found (ENOENT)`);
553
+ return { fixedFiles: [], fixedCount: 0 };
554
+ }
302
555
  // Distinguish "command not found" from "partial fix exited non-zero"
303
556
  // Exit code 1 with stderr containing "not recognized" or "not found" = command missing
304
557
  const stderr = err.stderr ? err.stderr.toString() : '';
@@ -323,7 +576,7 @@ export function runToolFix(toolDef, files, options = {}) {
323
576
  if (restage) {
324
577
  for (const file of files) {
325
578
  try {
326
- execSync(`git add "${file}"`, { cwd, stdio: 'pipe' });
579
+ execFileSync(_resolveCommand('git'), ['add', file], { cwd, stdio: 'pipe' });
327
580
  fixedFiles.push(file);
328
581
  } catch {
329
582
  // File may not have changed; that's fine
@@ -348,6 +601,7 @@ export function runToolFix(toolDef, files, options = {}) {
348
601
  * @param {number} [options.timeout] - Timeout in ms
349
602
  * @param {string} [options.cwd] - Working directory
350
603
  * @param {boolean} [options.restage] - Re-stage after fix (default: true)
604
+ * @param {Object} [options.env] - Extra environment variables (merged with process.env)
351
605
  * @returns {ToolRunResult} Final result after optional fix
352
606
  */
353
607
  export function runToolWithAutoFix(toolDef, files, options = {}) {
@@ -358,7 +612,7 @@ export function runToolWithAutoFix(toolDef, files, options = {}) {
358
612
 
359
613
  // Step 2: If any issues (errors or warnings) and autoFix enabled, try to fix
360
614
  const hasIssues = initialResult.errors.length > 0 || initialResult.warnings.length > 0;
361
- if (autoFix && toolDef.fixArgs && hasIssues) {
615
+ if (autoFix && toolDef.fixArgs && hasIssues && !initialResult.executionFailed) {
362
616
  logger.info(`🔧 Auto-fixing ${toolDef.name} issues...`);
363
617
 
364
618
  const fixResult = runToolFix(toolDef, files, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.35.0",
3
+ "version": "2.35.2",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  "lint:fix": "eslint lib/ bin/claude-hooks --fix",
21
21
  "format": "prettier --write \"lib/**/*.js\" \"bin/**\" \"test/**/*.js\"",
22
22
  "precommit": "npm run lint && npm run test:smoke",
23
- "prepublishOnly": "npm run test:all"
23
+ "prepublishOnly": "npm run test:all",
24
+ "library:tokens": "node .library/tools/measure-tokens.js",
25
+ "library:graph": "node .library/tools/generate-graph.js"
24
26
  },
25
27
  "keywords": [
26
28
  "git",
@@ -64,6 +66,8 @@
64
66
  "@types/jest": "^29.5.0",
65
67
  "eslint": "^8.57.1",
66
68
  "jest": "^29.7.0",
69
+ "js-tiktoken": "^1.0.18",
70
+ "madge": "^8.0.0",
67
71
  "prettier": "^3.2.0"
68
72
  }
69
73
  }