claude-git-hooks 2.35.0 → 2.35.3

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,58 @@ 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.3] - 2026-04-22
9
+
10
+ ### ✨ Added
11
+ - Code Knowledge Library (`.library/`) with 25 reference books covering source modules — structured documentation with call signatures, I/O behavior, cross-references, and gotchas per module (#125-#131)
12
+ - By-code index for navigating from source file paths to their reference books (`by-code/utils.md`, `by-code/commands.md`, `by-code/hooks.md`)
13
+ - By-domain index with curated reading lists for four business workflows: commit pipeline, release management, PR analysis, and GitHub integration (`by-domain/`)
14
+ - By-task-type index with step-by-step reading sequences for development tasks, starting with add-new-command guide (`by-task-type/`)
15
+ - Dependency graph generator tool and path resolver for library infrastructure (`tools/`)
16
+ - Book schema and token measurement infrastructure with calibration notes and category completeness validation
17
+ - Classification report and category completeness report validating all 66 source modules against the 8-category taxonomy
18
+
19
+ ### 🐛 Fixed
20
+ - Spotless linting timeouts and cmd.exe per-file mode on Windows — resolves batch-mode failures when Spotless wrapper is a `.cmd`/`.bat` file (#124, #125)
21
+
22
+
23
+ ## [2.35.2] - 2026-04-13
24
+
25
+ ### ✨ Added
26
+ - Added Maven Wrapper (mvnw/mvnw.cmd) auto-detection for Spotless — bypasses cmd.exe metacharacter issues on Windows/WSL2
27
+ - Added per-file execution mode for Spotless to avoid pipe (`|`) metacharacter in regex arguments on Windows
28
+ - Added `env` option to `runTool`, `runToolFix`, and `runToolWithAutoFix` for passing extra environment variables to tool invocations
29
+ - Added `configDir` to `_detectInProjectFiles` return value for downstream Maven Wrapper resolution
30
+
31
+ ### 🐛 Fixed
32
+ - 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
33
+
34
+
35
+ ## [2.35.1] - 2026-04-08
36
+
37
+ ### ✨ Added
38
+ - 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)
39
+ - 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
40
+ - WSL2/Windows interop support for .cmd/.bat tool binaries, with proper cmd.exe metacharacter escaping
41
+ - Source logging for linter tool resolution (remote/remote default/local) to make non-determinism visible
42
+ - New `localBin` and `toolCwd` options for `runTool`/`runToolFix`/`runToolWithAutoFix` enabling direct binary execution and correct plugin resolution in monorepos
43
+
44
+ ### 🔧 Changed
45
+ - 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
46
+ - 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)
47
+ - Project file scanning starts from repo root instead of cwd, fixing detection when git hooks run from subdirectories
48
+ - Increased judge timeout from 120s to 180s
49
+
50
+ ### 🐛 Fixed
51
+ - Fixed tool auto-fix being attempted after execution failures (ENOENT, EACCES) by tracking `executionFailed` flag and skipping fix step
52
+ - Fixed ENOENT handling in `runToolFix` — missing command binary now returns gracefully instead of being misinterpreted as a partial fix
53
+ - Fixed `git add` in restage step to use `execFileSync` with proper argument array instead of shell string interpolation
54
+
55
+ ### 🔒 Security
56
+ - Eliminated shell injection risk in tool execution by switching from `execSync` (shell-based) to `execFileSync` (direct process spawn)
57
+ - Added `_escapeCmdMeta()` and `_prepareExec()` for safe cmd.exe argument passing when executing .cmd/.bat files on Windows/WSL
58
+
59
+
8
60
  ## [2.35.0] - 2026-03-27
9
61
 
10
62
  ### ✨ Added
package/CLAUDE.md CHANGED
@@ -217,18 +217,19 @@ preset config (.claude/presets/{name}/config.json) ← HIGHEST PRIORITY
217
217
  | Orchestrator threshold | 3 files | Commits with ≥3 files use Opus orchestration |
218
218
  | Orchestrator model | opus | Internal constant — not user-configurable |
219
219
  | Orchestrator timeout | 60s | Lightweight call (file overview only) |
220
- | Analysis timeout | 300s | Per-batch timeout |
220
+ | Analysis timeout | 360s | Per-batch timeout |
221
221
  | Commit msg timeout | 300s | Message generation timeout |
222
222
  | PR metadata timeout | 180s | Engine default (reads config) |
223
223
  | Judge model | sonnet | Default, configurable via `config.judge.model` |
224
- | Judge timeout | 120s | Per-judge call timeout |
224
+ | Judge timeout | 360s | Per-judge call timeout |
225
225
  | PR analysis model | sonnet | Default, configurable via `config.prAnalysis.model` |
226
226
  | PR analysis timeout | 300s | Per-analysis Claude call |
227
227
  | Linting enabled | true | Runs linters before Claude analysis |
228
228
  | Linting auto-fix | true | Auto-fix and re-stage files |
229
229
  | Linting fail on error | true | Block commit on linting errors |
230
230
  | Linting fail on warn | false | Do not block on warnings |
231
- | Linting timeout | 30s | Per-linter timeout |
231
+ | Linting timeout | 30s | Per-linter timeout (tool-specific overrides if higher) |
232
+ | Spotless timeout | 120s | Per-invocation; overrides config default |
232
233
 
233
234
  **Judge behavior (v2.20.0):**
234
235
 
@@ -379,6 +380,10 @@ git commit
379
380
  → for each tool (formatters first, then linters):
380
381
  isToolAvailable() → not found? warn + install hint → skip
381
382
  filterFilesByTool() → matching files
383
+ → timeout = max(config.linting.timeout, toolDef.timeout)
384
+ → perFile tools (Spotless): resolve command path
385
+ .cmd/.bat → per-file mode (cmd.exe pipe safety)
386
+ otherwise → batch mode (single JVM startup)
382
387
  runToolWithAutoFix() → check → auto-fix → re-stage → re-check
383
388
  → unfixable issues forwarded to judge
384
389
 
@@ -1350,7 +1355,7 @@ Recurring patterns validated across 15+ automation sessions. Apply these in new
1350
1355
  - Enables centralized output control and debug mode
1351
1356
 
1352
1357
  9. **DO NOT execute Claude CLI without timeout**
1353
- - Always configure timeout (default: 150s analysis, 180s commit msg)
1358
+ - Always configure timeout (default: 360s analysis, 300s commit msg)
1354
1359
  - Timeout configurable in `.claude/config.json`
1355
1360
 
1356
1361
  10. **DO NOT ignore platform-specific issues**
package/lib/config.js CHANGED
@@ -33,7 +33,7 @@ const HARDCODED = {
33
33
  analysis: {
34
34
  maxFileSize: 1000000, // 1MB - sufficient for most files
35
35
  maxFiles: 30, // Reasonable limit per commit
36
- timeout: 300000, // 5 minutes - adequate for Claude API
36
+ timeout: 360000, // 6 minutes - adequate for Claude API
37
37
  contextLines: 3, // Git default
38
38
  ignoreExtensions: [] // Can be set in advanced config only
39
39
  },
@@ -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 = 360000;
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,
@@ -23,6 +25,7 @@ import {
23
25
  displayToolResult
24
26
  } from './tool-runner.js';
25
27
  import { getRepoRoot } from './git-operations.js';
28
+ import { which } from './which-command.js';
26
29
  import { fetchRemoteConfig } from './remote-config.js';
27
30
  import logger from './logger.js';
28
31
 
@@ -212,6 +215,36 @@ export function filesToSpotlessRegex(files) {
212
215
  return escaped.join('|');
213
216
  }
214
217
 
218
+ /**
219
+ * Find Maven Wrapper (mvnw/mvnw.cmd) near the pom.xml directory.
220
+ * Maven Wrapper invokes Java directly, bypassing cmd.exe — avoids
221
+ * metacharacter issues with | in command-line args on Windows/WSL2.
222
+ *
223
+ * @param {string} configDir - Directory containing pom.xml
224
+ * @returns {string|null} Path to mvnw/mvnw.cmd, or null if not found
225
+ */
226
+ function _findMavenWrapper(configDir) {
227
+ if (!configDir) return null;
228
+
229
+ // On Windows, prefer .cmd (executable) over extensionless (bash script).
230
+ // On Linux/macOS/WSL, prefer extensionless (native) over .cmd.
231
+ const candidates = os.platform() === 'win32'
232
+ ? [path.join(configDir, 'mvnw.cmd'), path.join(configDir, 'mvnw')]
233
+ : [path.join(configDir, 'mvnw'), path.join(configDir, 'mvnw.cmd')];
234
+
235
+ for (const candidate of candidates) {
236
+ if (fs.existsSync(candidate)) {
237
+ logger.debug('linter-runner - _findMavenWrapper',
238
+ 'Found Maven Wrapper', { path: candidate });
239
+ return candidate;
240
+ }
241
+ }
242
+
243
+ logger.debug('linter-runner - _findMavenWrapper',
244
+ 'No Maven Wrapper found', { configDir });
245
+ return null;
246
+ }
247
+
215
248
  /**
216
249
  * Available linter tool definitions
217
250
  * @type {Object<string, import('./tool-runner.js').ToolDefinition>}
@@ -223,14 +256,24 @@ export const LINTER_TOOLS = {
223
256
  args: (files) => ['prettier', '--check', ...files],
224
257
  fixArgs: (files) => ['prettier', '--write', ...files],
225
258
  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
- },
259
+ // Array form: multiple detection strategies. First match wins.
260
+ // Prettier may be a direct dep, or bundled via meta-frameworks,
261
+ // or only indicated by a config file.
262
+ detectInProjectFile: [
263
+ {
264
+ filename: 'package.json',
265
+ check: (content) => {
266
+ const pkg = JSON.parse(content);
267
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
268
+ return 'prettier' in allDeps;
269
+ }
270
+ },
271
+ { filename: '.prettierrc', check: () => true },
272
+ { filename: '.prettierrc.json', check: () => true },
273
+ { filename: '.prettierrc.js', check: () => true },
274
+ { filename: 'prettier.config.js', check: () => true },
275
+ { filename: 'prettier.config.mjs', check: () => true }
276
+ ],
234
277
  installHint: 'npm install --save-dev prettier',
235
278
  extensions: ['.js', '.jsx', '.ts', '.tsx', '.css', '.scss', '.html', '.json', '.md', '.yaml', '.yml'],
236
279
  parseOutput: parsePrettierOutput,
@@ -242,14 +285,24 @@ export const LINTER_TOOLS = {
242
285
  args: (files) => ['eslint', '--format', 'json', '--no-error-on-unmatched-pattern', ...files],
243
286
  fixArgs: (files) => ['eslint', '--fix', '--no-error-on-unmatched-pattern', ...files],
244
287
  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
- },
288
+ // Array form: ESLint may be a direct dep, bundled via react-scripts/next,
289
+ // indicated by eslintConfig in package.json, or by a config file.
290
+ detectInProjectFile: [
291
+ {
292
+ filename: 'package.json',
293
+ check: (content) => {
294
+ const pkg = JSON.parse(content);
295
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
296
+ return 'eslint' in allDeps || 'eslintConfig' in pkg;
297
+ }
298
+ },
299
+ { filename: '.eslintrc.json', check: () => true },
300
+ { filename: '.eslintrc.js', check: () => true },
301
+ { filename: '.eslintrc.yml', check: () => true },
302
+ { filename: '.eslintrc', check: () => true },
303
+ { filename: 'eslint.config.js', check: () => true },
304
+ { filename: 'eslint.config.mjs', check: () => true }
305
+ ],
253
306
  installHint: 'npm install --save-dev eslint',
254
307
  extensions: ['.js', '.jsx', '.ts', '.tsx'],
255
308
  parseOutput: parseEslintOutput,
@@ -272,7 +325,14 @@ export const LINTER_TOOLS = {
272
325
  installHint: 'Add spotless-maven-plugin to pom.xml (see https://github.com/diffplug/spotless)',
273
326
  extensions: ['.java'],
274
327
  parseOutput: parseSpotlessOutput,
275
- timeout: 60000
328
+ timeout: 120000,
329
+ // Run per-file to avoid | (pipe) in regex on Windows.
330
+ // filesToSpotlessRegex joins files with | for regex alternation.
331
+ // cmd.exe interprets | as a pipe at every expansion level —
332
+ // ^ escaping is consumed by the first cmd.exe parse, and %VAR% expansion
333
+ // in batch scripts (mvn.cmd) re-exposes unescaped | to a second parse.
334
+ // Per-file execution ensures each regex has at most one file pattern (no |).
335
+ perFile: true
276
336
  },
277
337
  sqlfluff: {
278
338
  name: 'sqlfluff',
@@ -338,6 +398,13 @@ export async function getLinterToolsForPreset(presetName) {
338
398
  });
339
399
  }
340
400
 
401
+ // User-visible source logging — makes non-determinism visible when remote config
402
+ // flips between available and unavailable across invocations
403
+ const hasPresetInRemote = remoteConfig?.presetTools?.[presetName];
404
+ const hasDefaultInRemote = remoteConfig?.presetTools?.default;
405
+ const source = hasPresetInRemote ? 'remote' : hasDefaultInRemote ? 'remote default' : 'local';
406
+ logger.info(`Linter tools [${source}]: ${toolNames.join(', ')}`);
407
+
341
408
  return toolNames.map((name) => LINTER_TOOLS[name]).filter(Boolean);
342
409
  }
343
410
 
@@ -395,14 +462,77 @@ export async function runLinters(files, config, presetName = 'default') {
395
462
  continue;
396
463
  }
397
464
 
398
- // Run tool with optional auto-fix
399
- const result = runToolWithAutoFix(toolDef, matchingFiles, {
465
+ // Maven Wrapper: prefer mvnw when available (invokes Java directly, bypasses cmd.exe).
466
+ // Cannot reuse localBin — _prepareToolCall strips args[0] for npx tools,
467
+ // but Spotless args are Maven goals (spotless:check), not a tool name to strip.
468
+ // Shallow-clone toolDef with wrapper as command instead.
469
+ let effectiveToolDef = toolDef;
470
+ if (!availability.localBin && availability.configDir && toolDef.command === 'mvn') {
471
+ const wrapper = _findMavenWrapper(availability.configDir);
472
+ if (wrapper) {
473
+ effectiveToolDef = { ...toolDef, command: wrapper };
474
+ logger.info(`Using Maven Wrapper: ${path.basename(wrapper)}`);
475
+ }
476
+ }
477
+
478
+ // Use the higher of config timeout and tool-specific timeout.
479
+ // Spotless (120s) should not be silently capped to the config default (30s).
480
+ const effectiveTimeout = Math.max(timeout, effectiveToolDef.timeout || 0);
481
+
482
+ const toolOpts = {
400
483
  autoFix,
401
- timeout,
402
- restage: true
403
- });
484
+ timeout: effectiveTimeout,
485
+ restage: true,
486
+ localBin: availability.localBin,
487
+ toolCwd: availability.configDir
488
+ };
489
+
490
+ // Per-file execution: run once per file to avoid | (pipe) in command args.
491
+ // On Windows, cmd.exe interprets | as a pipe at every expansion level —
492
+ // ^ escaping is consumed at the first parse, and %VAR% expansion in batch
493
+ // scripts (mvn.cmd) re-exposes unescaped | to a second parse.
494
+ // Running per-file ensures each invocation has a single-file regex (no |).
495
+ //
496
+ // However, per-file mode is ONLY needed when the command routes through
497
+ // cmd.exe (.cmd/.bat files). Direct executables and bash scripts (e.g.,
498
+ // mvnw) pass args to the process directly — | is never interpreted as a pipe.
499
+ // Batch mode avoids N separate JVM startups, which is critical for Spotless
500
+ // on Windows/WSL where each startup adds significant overhead.
501
+ let needsPerFile = effectiveToolDef.perFile && matchingFiles.length > 1;
502
+ if (needsPerFile) {
503
+ const resolvedCmd = effectiveToolDef.command.includes(path.sep)
504
+ ? effectiveToolDef.command
505
+ : (which(effectiveToolDef.command) || effectiveToolDef.command);
506
+ if (!/\.(cmd|bat)$/i.test(resolvedCmd)) {
507
+ needsPerFile = false;
508
+ logger.debug('linter-runner - runLinters',
509
+ `${effectiveToolDef.name} command bypasses cmd.exe, using batch mode`,
510
+ { command: resolvedCmd });
511
+ }
512
+ }
513
+
514
+ if (needsPerFile) {
515
+ logger.debug('linter-runner - runLinters',
516
+ `Running ${effectiveToolDef.name} per-file (cmd.exe path)`,
517
+ { fileCount: matchingFiles.length });
518
+
519
+ const perFileResults = matchingFiles.map((file) =>
520
+ runToolWithAutoFix(effectiveToolDef, [file], toolOpts)
521
+ );
404
522
 
405
- results.push(result);
523
+ results.push({
524
+ tool: effectiveToolDef.name,
525
+ skipped: false,
526
+ errors: perFileResults.flatMap((r) => r.errors),
527
+ warnings: perFileResults.flatMap((r) => r.warnings),
528
+ fixedCount: perFileResults.reduce((sum, r) => sum + r.fixedCount, 0),
529
+ fixedFiles: perFileResults.flatMap((r) => r.fixedFiles)
530
+ });
531
+ } else {
532
+ // Normal batch execution
533
+ const result = runToolWithAutoFix(effectiveToolDef, matchingFiles, toolOpts);
534
+ results.push(result);
535
+ }
406
536
  }
407
537
 
408
538
  // 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()) {
@@ -225,8 +461,9 @@ export function runTool(toolDef, files, options = {}) {
225
461
  const parsed = toolDef.parseOutput(err.stdout);
226
462
  result.errors = parsed.errors || [];
227
463
  result.warnings = parsed.warnings || [];
228
- } else if (err.killed) {
229
- // Timeout
464
+ } else if (err.killed || err.code === 'ETIMEDOUT') {
465
+ // Timeout — err.killed for Node.js timeout, ETIMEDOUT for OS-level
466
+ // spawn timeout (common on Windows when cmd.exe → JVM startup is slow)
230
467
  logger.warning(`${toolDef.name} timed out after ${timeout}ms`);
231
468
  result.errors.push({
232
469
  file: '',
@@ -234,13 +471,16 @@ export function runTool(toolDef, files, options = {}) {
234
471
  message: `${toolDef.name} timed out after ${timeout / 1000}s`
235
472
  });
236
473
  } else {
237
- // Genuine execution error
474
+ // Genuine execution error (ENOENT, EACCES, etc.)
238
475
  const stderr = err.stderr ? err.stderr.toString().trim() : '';
239
476
  logger.debug('tool-runner - runTool', `${toolDef.name} execution error`, {
240
477
  exitCode: err.status,
478
+ code: err.code,
241
479
  stderr
242
480
  });
243
481
 
482
+ result.executionFailed = true;
483
+
244
484
  // If no parseable output, treat as tool error
245
485
  if (!err.stdout) {
246
486
  result.errors.push({
@@ -269,6 +509,9 @@ export function runTool(toolDef, files, options = {}) {
269
509
  * @param {number} [options.timeout] - Timeout in ms
270
510
  * @param {string} [options.cwd] - Working directory
271
511
  * @param {boolean} [options.restage] - Re-stage fixed files (default: true)
512
+ * @param {string} [options.localBin] - Local binary path (bypasses npx resolution)
513
+ * @param {string} [options.toolCwd] - Run tool from this directory (for plugin resolution)
514
+ * @param {Object} [options.env] - Extra environment variables (merged with process.env)
272
515
  * @returns {{ fixedFiles: string[], fixedCount: number }} Fix results
273
516
  */
274
517
  export function runToolFix(toolDef, files, options = {}) {
@@ -279,26 +522,37 @@ export function runToolFix(toolDef, files, options = {}) {
279
522
  return { fixedFiles: [], fixedCount: 0 };
280
523
  }
281
524
 
282
- const args = toolDef.fixArgs(files);
283
- const fullCommand = [toolDef.command, ...args].join(' ');
525
+ const effectiveFiles = _resolveFilePaths(files, cwd, options.toolCwd);
526
+ const rawArgs = toolDef.fixArgs(effectiveFiles);
527
+ const { exec, cwd: effectiveCwd, fullCommand } = _prepareToolCall(toolDef, rawArgs, options, cwd);
284
528
 
285
529
  logger.debug('tool-runner - runToolFix', `Auto-fixing with ${toolDef.name}`, {
286
530
  command: fullCommand,
287
531
  fileCount: files.length
288
532
  });
289
533
 
534
+ const execOptions = {
535
+ encoding: 'utf8',
536
+ timeout,
537
+ cwd: effectiveCwd,
538
+ stdio: ['pipe', 'pipe', 'pipe']
539
+ };
540
+ if (options.env) {
541
+ execOptions.env = { ...process.env, ...options.env };
542
+ }
543
+
290
544
  try {
291
- execSync(fullCommand, {
292
- encoding: 'utf8',
293
- timeout,
294
- cwd,
295
- stdio: ['pipe', 'pipe', 'pipe']
296
- });
545
+ execFileSync(exec.command, exec.args, execOptions);
297
546
  } catch (err) {
298
- if (err.killed) {
547
+ if (err.killed || err.code === 'ETIMEDOUT') {
299
548
  logger.warning(`${toolDef.name} auto-fix timed out`);
300
549
  return { fixedFiles: [], fixedCount: 0 };
301
550
  }
551
+ // ENOENT = command binary not found (process never started, err.status is null)
552
+ if (err.code === 'ENOENT') {
553
+ logger.debug('tool-runner - runToolFix', `${toolDef.name} not found (ENOENT)`);
554
+ return { fixedFiles: [], fixedCount: 0 };
555
+ }
302
556
  // Distinguish "command not found" from "partial fix exited non-zero"
303
557
  // Exit code 1 with stderr containing "not recognized" or "not found" = command missing
304
558
  const stderr = err.stderr ? err.stderr.toString() : '';
@@ -323,7 +577,7 @@ export function runToolFix(toolDef, files, options = {}) {
323
577
  if (restage) {
324
578
  for (const file of files) {
325
579
  try {
326
- execSync(`git add "${file}"`, { cwd, stdio: 'pipe' });
580
+ execFileSync(_resolveCommand('git'), ['add', file], { cwd, stdio: 'pipe' });
327
581
  fixedFiles.push(file);
328
582
  } catch {
329
583
  // File may not have changed; that's fine
@@ -348,6 +602,7 @@ export function runToolFix(toolDef, files, options = {}) {
348
602
  * @param {number} [options.timeout] - Timeout in ms
349
603
  * @param {string} [options.cwd] - Working directory
350
604
  * @param {boolean} [options.restage] - Re-stage after fix (default: true)
605
+ * @param {Object} [options.env] - Extra environment variables (merged with process.env)
351
606
  * @returns {ToolRunResult} Final result after optional fix
352
607
  */
353
608
  export function runToolWithAutoFix(toolDef, files, options = {}) {
@@ -358,7 +613,7 @@ export function runToolWithAutoFix(toolDef, files, options = {}) {
358
613
 
359
614
  // Step 2: If any issues (errors or warnings) and autoFix enabled, try to fix
360
615
  const hasIssues = initialResult.errors.length > 0 || initialResult.warnings.length > 0;
361
- if (autoFix && toolDef.fixArgs && hasIssues) {
616
+ if (autoFix && toolDef.fixArgs && hasIssues && !initialResult.executionFailed) {
362
617
  logger.info(`🔧 Auto-fixing ${toolDef.name} issues...`);
363
618
 
364
619
  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.3",
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
  }
@@ -284,7 +284,7 @@ cd python-django
284
284
  "analysis": {
285
285
  "maxFileSize": 1000000,
286
286
  "maxFiles": 12,
287
- "timeout": 180000
287
+ "timeout": 360000
288
288
  },
289
289
  "subagents": {
290
290
  "enabled": true,
@@ -308,7 +308,7 @@ cd python-django
308
308
  analysis: {
309
309
  maxFileSize: 1000000, // 1MB
310
310
  maxFiles: 30,
311
- timeout: 180000 // 3 minutes
311
+ timeout: 360000 // 6 minutes
312
312
  },
313
313
  subagents: {
314
314
  enabled: true,